diff --git a/autoqa/checklist.md b/autoqa/checklist.md index ebe0d1163..2ba4b97b8 100644 --- a/autoqa/checklist.md +++ b/autoqa/checklist.md @@ -3,7 +3,7 @@ ## A. Initial update / migration Data check Before testing, set-up the following in the old version to make sure that we can see the data is properly migrated: -- [ ] Changing appearance / theme to something that is obviously different from default set-up +- [ ] Changing Interface / theme to something that is obviously different from default set-up - [ ] Ensure there are a few chat threads - [ ] Ensure there are a few favourites / star threads - [ ] Ensure there are 2 model downloaded @@ -23,7 +23,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Can resume chat in threads with the previous context - [ ] Assistants - Settings: - - [ ] Appearance + - [ ] Interface - [ ] MCP Servers - [ ] Local API Server - [ ] HTTPS Proxy @@ -56,7 +56,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Ensure `Community` links work and point to the correct website - [ ] Ensure the `Check for Updates` function detect the correct latest version - [ ] [ENG] Create a folder with un-standard character as title (e.g. Chinese character) => change the `App data` location to that folder => test that model is still able to load and run properly. -#### In `Appearance`: +#### In `Interface`: - [ ] Toggle between different `Theme` options to check that they change accordingly and that all elements of the UI are legible with the right contrast: - [ ] Light - [ ] Dark @@ -73,7 +73,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Ensure that when this value is changed, there is no broken UI caused by it - [ ] Code Block - [ ] Show Line Numbers -- [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values +- [ENG] Ensure that when click on `Reset` in the `Interface` section, it reset back to the default values - [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values #### In `Model Providers`: @@ -205,7 +205,7 @@ Ensure that the following section information show up for hardware - [ ] Model option (except if the model / model provider has been deleted or disabled) - [ ] User can send message with different type of text content (e.g text, emoji, ...) - [ ] When request model to generate a markdown table, the table is correctly formatted as returned from the model. -- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Appearance -> Code Block` setting. +- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Interface -> Code Block` setting. - [ ] Users can edit their old message and and user can regenerate the answer based on the new message - [ ] User can click `Copy` to copy the model response - [ ] User can click `Delete` to delete either the user message or the model response. @@ -231,7 +231,7 @@ In `Settings -> General`: - [ ] All threads deleted - [ ] All Assistant deleted except for default Jan Assistant - [ ] `App Data` location is reset back to default path - - [ ] Appearance reset + - [ ] Interface reset - [ ] Model Providers information all reset - [ ] Llama.cpp setting reset - [ ] API keys cleared @@ -261,4 +261,4 @@ In `Settings -> General`: # II. After release - [ ] Check that the App Updater works and user can update to the latest release without any problem - [ ] App restarts after the user finished an update -- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version \ No newline at end of file +- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version diff --git a/docs/src/pages/docs/desktop/server-settings.mdx b/docs/src/pages/docs/desktop/server-settings.mdx index f7be5af26..995cec5df 100644 --- a/docs/src/pages/docs/desktop/server-settings.mdx +++ b/docs/src/pages/docs/desktop/server-settings.mdx @@ -88,14 +88,14 @@ If your computer gets very hot, consider using smaller models or reducing GPU la ## Personalization -### Visual Appearance +### Visual Interface -Customize Jan's look at **Settings** > **Appearance**: +Customize Jan's look at **Settings** > **Interface**: - **Theme**: Choose light or dark mode - **Colors**: Pick your preferred color scheme - **Code highlighting**: Adjust syntax colors for programming discussions -![Appearance](./_assets/settings-04.png) +![Interface](./_assets/settings-04.png) ### Writing Assistance diff --git a/docs/src/pages/docs/desktop/settings.mdx b/docs/src/pages/docs/desktop/settings.mdx index cd4d01ede..bd6ce894e 100644 --- a/docs/src/pages/docs/desktop/settings.mdx +++ b/docs/src/pages/docs/desktop/settings.mdx @@ -83,11 +83,11 @@ Monitor and manage system resources at **Settings > Hardware**: ## Preferences -### Appearance & Theme +### Interface & Theme Control the visual theme of Jan's interface with any color combo you'd like. You can also control the color use in the code blocks.
-![Appearance](./_assets/settings-04.png) +![Interface](./_assets/settings-04.png)
### Spell Check diff --git a/tests/checklist.md b/tests/checklist.md index 8e9e65d4b..7e843bdb8 100644 --- a/tests/checklist.md +++ b/tests/checklist.md @@ -3,7 +3,7 @@ ## A. Initial update / migration Data check Before testing, set-up the following in the old version to make sure that we can see the data is properly migrated: -- [ ] Changing appearance / theme to something that is obviously different from default set-up +- [ ] Changing Interface / theme to something that is obviously different from default set-up - [ ] Ensure there are a few chat threads - [ ] Ensure there are a few favourites / star threads - [ ] Ensure there are 2 model downloaded @@ -23,7 +23,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Can resume chat in threads with the previous context - [ ] Assistants - Settings: - - [ ] Appearance + - [ ] Interface - [ ] MCP Servers - [ ] Local API Server - [ ] HTTPS Proxy @@ -56,7 +56,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Ensure `Community` links work and point to the correct website - [ ] Ensure the `Check for Updates` function detect the correct latest version - [ ] [ENG] Create a folder with un-standard character as title (e.g. Chinese character) => change the `App data` location to that folder => test that model is still able to load and run properly. -#### In `Appearance`: +#### In `Interface`: - [ ] Toggle between different `Theme` options to check that they change accordingly and that all elements of the UI are legible with the right contrast: - [ ] Light - [ ] Dark @@ -74,7 +74,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Code Block - [ ] Show Line Numbers - [ ] [0.7.0] Compact Token Counter will show token counter in side chat input when toggle, if not it will show a small token counter below the chat input -- [ ] [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values +- [ ] [ENG] Ensure that when click on `Reset` in the `Interface` section, it reset back to the default values - [ ] [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values #### In `Model Providers`: @@ -228,7 +228,7 @@ Ensure that the following section information show up for hardware - [ ] Model option (except if the model / model provider has been deleted or disabled) - [ ] User can send message with different type of text content (e.g text, emoji, ...) - [ ] When request model to generate a markdown table, the table is correctly formatted as returned from the model. -- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Appearance -> Code Block` setting. +- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Interface -> Code Block` setting. - [ ] [0.7.0] LaTeX formulas now render correctly in chat. Both inline \(...\) and block \[...\] formats are supported. Code blocks and HTML tags are not affected - [ ] Users can edit their old message and user can regenerate the answer based on the new message - [ ] User can click `Copy` to copy the model response @@ -269,7 +269,7 @@ In `Settings -> General`: - [ ] All threads deleted - [ ] All Assistant deleted except for default Jan Assistant - [ ] `App Data` location is reset back to default path - - [ ] Appearance reset + - [ ] Interface reset - [ ] Model Providers information all reset - [ ] Llama.cpp setting reset - [ ] API keys cleared @@ -299,4 +299,4 @@ In `Settings -> General`: # II. After release - [ ] Check that the App Updater works and user can update to the latest release without any problem - [ ] App restarts after the user finished an update -- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version \ No newline at end of file +- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts index f13f5fcab..95aa68ed2 100644 --- a/web-app/src/constants/localStorage.ts +++ b/web-app/src/constants/localStorage.ts @@ -5,7 +5,7 @@ export const localStorageKey = { theme: 'theme', modelProvider: 'model-provider', modelSources: 'model-sources', - settingAppearance: 'setting-appearance', + settingInterface: 'setting-interface', settingGeneral: 'setting-general', settingCodeBlock: 'setting-code-block', settingLocalApiServer: 'setting-local-api-server', diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index 16fea5120..f92a18486 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -11,7 +11,7 @@ export const route = { providers: '/settings/providers/$providerName', general: '/settings/general', attachments: '/settings/attachments', - appearance: '/settings/appearance', + interface: '/settings/interface', privacy: '/settings/privacy', shortcuts: '/settings/shortcuts', extensions: '/settings/extensions', diff --git a/web-app/src/constants/threadScroll.ts b/web-app/src/constants/threadScroll.ts new file mode 100644 index 000000000..c7d4cde6e --- /dev/null +++ b/web-app/src/constants/threadScroll.ts @@ -0,0 +1,33 @@ +export const THREAD_SCROLL_BEHAVIOR = { + // FLOW: "chatgpt" behavior (keep viewport anchored to the latest user message) + FLOW: 'flow', + // STICKY: auto-follow streaming replies + STICKY: 'sticky', +} as const + +export type ThreadScrollBehavior = + (typeof THREAD_SCROLL_BEHAVIOR)[keyof typeof THREAD_SCROLL_BEHAVIOR] + +export const DEFAULT_THREAD_SCROLL_BEHAVIOR = + THREAD_SCROLL_BEHAVIOR.FLOW + +export const threadScrollBehaviorOptions: Array<{ + value: ThreadScrollBehavior + translationKey: string +}> = [ + { + value: THREAD_SCROLL_BEHAVIOR.FLOW, + translationKey: 'settings:interface.threadScrollFlowTitle', + }, + { + value: THREAD_SCROLL_BEHAVIOR.STICKY, + translationKey: 'settings:interface.threadScrollStickyTitle', + }, +] + +export const isThreadScrollBehavior = ( + value: unknown +): value is ThreadScrollBehavior => + value === THREAD_SCROLL_BEHAVIOR.FLOW || + value === THREAD_SCROLL_BEHAVIOR.STICKY + diff --git a/web-app/src/containers/ChatWidthSwitcher.tsx b/web-app/src/containers/ChatWidthSwitcher.tsx index 10417200e..c58cbb47b 100644 --- a/web-app/src/containers/ChatWidthSwitcher.tsx +++ b/web-app/src/containers/ChatWidthSwitcher.tsx @@ -1,11 +1,11 @@ -import { Skeleton } from '@/components/ui/skeleton' -import { useAppearance } from '@/hooks/useAppearance' +import { Skeleton } from '@/components/ui/skeleton' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { IconCircleCheckFilled } from '@tabler/icons-react' import { useTranslation } from '@/i18n/react-i18next-compat' export function ChatWidthSwitcher() { - const { chatWidth, setChatWidth } = useAppearance() + const { chatWidth, setChatWidth } = useInterfaceSettings() const { t } = useTranslation() return ( diff --git a/web-app/src/containers/ColorPickerAppAccentColor.tsx b/web-app/src/containers/ColorPickerAppAccentColor.tsx index 85178b7a0..1b38fc2f5 100644 --- a/web-app/src/containers/ColorPickerAppAccentColor.tsx +++ b/web-app/src/containers/ColorPickerAppAccentColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance, isDefaultColorAccent } from '@/hooks/useAppearance' +import { useInterfaceSettings, isDefaultColorAccent } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' export function ColorPickerAppAccentColor() { - const { appAccentBgColor, setAppAccentBgColor } = useAppearance() + const { appAccentBgColor, setAppAccentBgColor } = useInterfaceSettings() const predefineAppAccentBgColor: RgbaColor[] = [ { diff --git a/web-app/src/containers/ColorPickerAppBgColor.tsx b/web-app/src/containers/ColorPickerAppBgColor.tsx index c60b34f13..a2001f05b 100644 --- a/web-app/src/containers/ColorPickerAppBgColor.tsx +++ b/web-app/src/containers/ColorPickerAppBgColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance, useBlurSupport } from '@/hooks/useAppearance' +import { useInterfaceSettings, useBlurSupport } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -11,7 +11,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat' import { useTheme } from '@/hooks/useTheme' export function ColorPickerAppBgColor() { - const { appBgColor, setAppBgColor } = useAppearance() + const { appBgColor, setAppBgColor } = useInterfaceSettings() const { isDark } = useTheme() const { t } = useTranslation() const showAlphaSlider = useBlurSupport() diff --git a/web-app/src/containers/ColorPickerAppDestructiveColor.tsx b/web-app/src/containers/ColorPickerAppDestructiveColor.tsx index f1f50aad4..7b2fc0fd0 100644 --- a/web-app/src/containers/ColorPickerAppDestructiveColor.tsx +++ b/web-app/src/containers/ColorPickerAppDestructiveColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance, isDefaultColorDestructive } from '@/hooks/useAppearance' +import { useInterfaceSettings, isDefaultColorDestructive } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -10,7 +10,7 @@ import { import { useTheme } from '@/hooks/useTheme' export function ColorPickerAppDestructiveColor() { - const { appDestructiveBgColor, setAppDestructiveBgColor } = useAppearance() + const { appDestructiveBgColor, setAppDestructiveBgColor } = useInterfaceSettings() const { isDark } = useTheme() const predefineAppDestructiveBgColor: RgbaColor[] = [ diff --git a/web-app/src/containers/ColorPickerAppMainView.tsx b/web-app/src/containers/ColorPickerAppMainView.tsx index d6e918903..f90e7f114 100644 --- a/web-app/src/containers/ColorPickerAppMainView.tsx +++ b/web-app/src/containers/ColorPickerAppMainView.tsx @@ -1,4 +1,4 @@ -import { useAppearance, isDefaultColorMainView } from '@/hooks/useAppearance' +import { useInterfaceSettings, isDefaultColorMainView } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -10,7 +10,7 @@ import { import { useTheme } from '@/hooks/useTheme' export function ColorPickerAppMainView() { - const { appMainViewBgColor, setAppMainViewBgColor } = useAppearance() + const { appMainViewBgColor, setAppMainViewBgColor } = useInterfaceSettings() const { isDark } = useTheme() const predefineAppMainViewBgColor: RgbaColor[] = [ diff --git a/web-app/src/containers/ColorPickerAppPrimaryColor.tsx b/web-app/src/containers/ColorPickerAppPrimaryColor.tsx index 188d66c3f..db96c71cf 100644 --- a/web-app/src/containers/ColorPickerAppPrimaryColor.tsx +++ b/web-app/src/containers/ColorPickerAppPrimaryColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance, isDefaultColorPrimary } from '@/hooks/useAppearance' +import { useInterfaceSettings, isDefaultColorPrimary } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' export function ColorPickerAppPrimaryColor() { - const { appPrimaryBgColor, setAppPrimaryBgColor } = useAppearance() + const { appPrimaryBgColor, setAppPrimaryBgColor } = useInterfaceSettings() const predefineappPrimaryBgColor: RgbaColor[] = [ { diff --git a/web-app/src/containers/FontSizeSwitcher.tsx b/web-app/src/containers/FontSizeSwitcher.tsx index 3e36f2d36..d347d0235 100644 --- a/web-app/src/containers/FontSizeSwitcher.tsx +++ b/web-app/src/containers/FontSizeSwitcher.tsx @@ -1,15 +1,15 @@ -import { +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { fontSizeOptions, useAppearance } from '@/hooks/useAppearance' +import { fontSizeOptions, useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { useTranslation } from '@/i18n/react-i18next-compat' export function FontSizeSwitcher() { - const { fontSize, setFontSize } = useAppearance() + const { fontSize, setFontSize } = useInterfaceSettings() const { t } = useTranslation() return ( diff --git a/web-app/src/containers/ScrollToBottom.tsx b/web-app/src/containers/ScrollToBottom.tsx index b1259480f..48f2c2981 100644 --- a/web-app/src/containers/ScrollToBottom.tsx +++ b/web-app/src/containers/ScrollToBottom.tsx @@ -1,9 +1,9 @@ -import { useThreadScrolling } from '@/hooks/useThreadScrolling' +import { useThreadScrolling } from '@/hooks/useThreadScrolling' import { memo } from 'react' import { GenerateResponseButton } from './GenerateResponseButton' import { useMessages } from '@/hooks/useMessages' import { useShallow } from 'zustand/react/shallow' -import { useAppearance } from '@/hooks/useAppearance' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { cn } from '@/lib/utils' import { ArrowDown } from 'lucide-react' import { useTranslation } from '@/i18n/react-i18next-compat' @@ -17,7 +17,7 @@ const ScrollToBottom = ({ scrollContainerRef: React.RefObject }) => { const { t } = useTranslation() - const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor) + const appMainViewBgColor = useInterfaceSettings((state) => state.appMainViewBgColor) const { showScrollToBottomBtn, scrollToBottom } = useThreadScrolling(threadId, scrollContainerRef) const { messages } = useMessages( diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index d85c4a03c..a0c434b67 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -80,8 +80,8 @@ const SettingsMenu = () => { isEnabled: PlatformFeatures[PlatformFeature.ATTACHMENTS], }, { - title: 'common:appearance', - route: route.settings.appearance, + title: 'common:interface', + route: route.settings.interface, hasSubMenu: false, isEnabled: true, }, diff --git a/web-app/src/containers/ThreadScrollBehaviorSwitcher.tsx b/web-app/src/containers/ThreadScrollBehaviorSwitcher.tsx new file mode 100644 index 000000000..41cf16dab --- /dev/null +++ b/web-app/src/containers/ThreadScrollBehaviorSwitcher.tsx @@ -0,0 +1,568 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' +import { THREAD_SCROLL_BEHAVIOR } from '@/constants/threadScroll' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { IconCircleCheckFilled } from '@tabler/icons-react' + +export function ThreadScrollBehaviorSwitcher() { + const { threadScrollBehavior, setThreadScrollBehavior } = useInterfaceSettings() + const { t } = useTranslation() + + const isFlowSelected = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.FLOW + const isStickySelected = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.STICKY + const placeholder = t('common:placeholder.chatInput') + + return ( +
+ setThreadScrollBehavior(THREAD_SCROLL_BEHAVIOR.FLOW)} + preview={} + /> + setThreadScrollBehavior(THREAD_SCROLL_BEHAVIOR.STICKY)} + preview={} + /> +
+ ) +} + +type ScrollOptionProps = { + title: string + hint: string + placeholder: string + isSelected: boolean + onSelect: () => void + preview?: ReactNode +} + +function ScrollOption({ + title, + hint, + placeholder, + isSelected, + onSelect, + preview, +}: ScrollOptionProps) { + return ( + + ) +} + +function PlaceholderLine({ width = '100%' }: { width?: string }) { + return ( +
+ ) +} + +const FLOW_HISTORY_FRAMES = [ + { id: 'history-1', widths: ['82%', '68%', '46%'] }, + { id: 'history-2', widths: ['78%', '60%', '38%'] }, + { id: 'history-3', widths: ['70%', '52%'] }, +] as const + +const FLOW_TYPING_TEXT = 'Jan, help me summarize this' +// Mirror Flow's history reveal timing so the typing cadence stays aligned. +const STICKY_USER_MESSAGE_DELAY = + FLOW_HISTORY_FRAMES.length * 160 + 380 +// Extend sticky preview loop so it resets in sync with Flow preview. +const STICKY_LOOP_ALIGNMENT_DELAY = 900 + +function FlowScrollPreview({ placeholder }: { placeholder: string }) { + const [step, setStep] = useState(0) + const [typedText, setTypedText] = useState('') + const [sentMessage, setSentMessage] = useState('') + const [streamProgress, setStreamProgress] = useState(0) + const [historyMessages, setHistoryMessages] = useState< + Array<{ id: string; widths: readonly string[] }> + >(FLOW_HISTORY_FRAMES) + const [exitingHistoryIds, setExitingHistoryIds] = useState([]) + const [userMessageVisible, setUserMessageVisible] = useState(false) + const [streamStage, setStreamStage] = useState<'idle' | 'streaming' | 'complete'>('idle') + + const timersRef = useRef([]) + const historyRef = useRef(historyMessages) + + useEffect(() => { + historyRef.current = historyMessages + }, [historyMessages]) + + useEffect(() => { + return () => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current = [] + } + }, []) + + useEffect(() => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current = [] + + if (step === 0) { + setHistoryMessages(FLOW_HISTORY_FRAMES) + setExitingHistoryIds([]) + setTypedText('') + setSentMessage('') + setUserMessageVisible(false) + setStreamStage('idle') + setStreamProgress(0) + + const timer = window.setTimeout(() => setStep(1), 1200) + timersRef.current.push(timer) + return + } + + if (step === 1) { + setTypedText('') + let index = 0 + const type = () => { + index += 1 + setTypedText(FLOW_TYPING_TEXT.slice(0, index)) + if (index < FLOW_TYPING_TEXT.length) { + const timer = window.setTimeout(type, 60) + timersRef.current.push(timer) + } else { + const timer = window.setTimeout(() => setStep(2), 300) + timersRef.current.push(timer) + } + } + const start = window.setTimeout(type, 160) + timersRef.current.push(start) + return + } + + if (step === 2) { + const ids = historyRef.current.map((msg) => msg.id) + if (ids.length === 0) { + const timer = window.setTimeout(() => { + setSentMessage(FLOW_TYPING_TEXT) + setUserMessageVisible(true) + setStep(3) + }, 120) + timersRef.current.push(timer) + return + } + + ids.forEach((id, index) => { + const exitTimer = window.setTimeout(() => { + setExitingHistoryIds((prev) => + prev.includes(id) ? prev : [...prev, id] + ) + }, index * 160) + timersRef.current.push(exitTimer) + + const removeTimer = window.setTimeout(() => { + setHistoryMessages((prev) => prev.filter((msg) => msg.id !== id)) + }, index * 160 + 320) + timersRef.current.push(removeTimer) + }) + + const revealDelay = ids.length * 160 + 380 + const revealTimer = window.setTimeout(() => { + setSentMessage(FLOW_TYPING_TEXT) + setUserMessageVisible(true) + setExitingHistoryIds([]) + setStep(3) + }, revealDelay) + timersRef.current.push(revealTimer) + return + } + + if (step === 3) { + setStreamStage('streaming') + setStreamProgress(0) + + const tick = (value: number) => { + setStreamProgress(value) + if (value >= 100) { + const timer = window.setTimeout(() => setStep(4), 400) + timersRef.current.push(timer) + } else { + const timer = window.setTimeout(() => tick(value + 20), 140) + timersRef.current.push(timer) + } + } + + const start = window.setTimeout(() => tick(20), 180) + timersRef.current.push(start) + return + } + + if (step === 4) { + setStreamStage('complete') + const timer = window.setTimeout(() => setStep(0), 2200) + timersRef.current.push(timer) + } + }, [step]) + + const messageStack = useMemo(() => { + const stack: ReactNode[] = [] + + if (streamStage !== 'idle') { + stack.unshift( + + ) + } + + stack.unshift( + + ) + + if (historyMessages.length > 0) { + stack.push( + + {historyMessages.map((msg) => ( + + ))} + + ) + } + + return stack + }, [ + exitingHistoryIds, + historyMessages, + sentMessage, + streamProgress, + streamStage, + userMessageVisible, + ]) + + return ( +
+
+
+ {messageStack} +
+
+
+ {step === 1 ? ( +
+ {typedText} + +
+ ) : ( + {placeholder} + )} +
+
+ ) +} + +function StickyScrollPreview({ placeholder }: { placeholder: string }) { + const [step, setStep] = useState(0) + const [typedText, setTypedText] = useState('') + const [messages, setMessages] = useState< + Array<{ id: string; type: 'assistant' | 'user'; widths: readonly string[] }> + >(FLOW_HISTORY_FRAMES.map((frame) => ({ ...frame, type: 'assistant' }))) + const [exitingIds, setExitingIds] = useState([]) + const exitingIdsRef = useRef(exitingIds) + const timersRef = useRef([]) + const MAX_ASSISTANTS = FLOW_HISTORY_FRAMES.length + + useEffect(() => { + exitingIdsRef.current = exitingIds + }, [exitingIds]) + + useEffect(() => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current = [] + + if (step === 0) { + setMessages( + FLOW_HISTORY_FRAMES.map((frame) => ({ + ...frame, + type: 'assistant' as const, + })) + ) + setTypedText('') + setExitingIds([]) + const timer = window.setTimeout(() => setStep(1), 1200) + timersRef.current.push(timer) + return + } + + if (step === 1) { + setTypedText('') + let index = 0 + const type = () => { + index += 1 + setTypedText(FLOW_TYPING_TEXT.slice(0, index)) + if (index < FLOW_TYPING_TEXT.length) { + const t = window.setTimeout(type, 60) + timersRef.current.push(t) + } else { + const t = window.setTimeout(() => setStep(2), 300) + timersRef.current.push(t) + } + } + const start = window.setTimeout(type, 160) + timersRef.current.push(start) + return + } + + if (step === 2) { + setMessages((prev) => { + const alreadyHasUser = prev.some((msg) => msg.type === 'user') + if (alreadyHasUser) return prev + return [ + ...prev, + { + id: 'user-message', + type: 'user', + widths: [FLOW_TYPING_TEXT], + }, + ] + }) + + const timer = window.setTimeout(() => setStep(3), STICKY_USER_MESSAGE_DELAY) + timersRef.current.push(timer) + return + } + + if (step === 3) { + const newAssistantMessages: Array<{ id: string; type: 'assistant'; widths: readonly string[] }> = [ + { id: 'assistant-stream-1', type: 'assistant', widths: ['64%', '52%', '36%'] }, + { id: 'assistant-stream-2', type: 'assistant', widths: ['58%', '40%'] }, + { id: 'assistant-stream-3', type: 'assistant', widths: ['62%', '53%', '42%', '24%'] }, + ] + + newAssistantMessages.forEach((message, index) => { + const timer = window.setTimeout(() => { + setMessages((prev) => { + const updated = [...prev, message] + + const assistantCount = updated.filter( + (item) => item.type === 'assistant' + ).length + + if (assistantCount > MAX_ASSISTANTS) { + const target = updated.find( + (item) => + item.type === 'assistant' && + item.id !== message.id && + !exitingIdsRef.current.includes(item.id) + ) + + if (target) { + setExitingIds((current) => + current.includes(target.id) + ? current + : [...current, target.id] + ) + + const removalTimer = window.setTimeout(() => { + setMessages((current) => + current.filter((item) => item.id !== target.id) + ) + setExitingIds((current) => + current.filter((id) => id !== target.id) + ) + }, 260) + + timersRef.current.push(removalTimer) + } + } + + return updated + }) + + if (index === newAssistantMessages.length - 1) { + const loopTimer = window.setTimeout( + () => setStep(0), + 2000 + STICKY_LOOP_ALIGNMENT_DELAY + ) + timersRef.current.push(loopTimer) + } + }, index * 220) + timersRef.current.push(timer) + }) + } + }, [step]) + + useEffect(() => { + return () => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current = [] + } + }, []) + + return ( +
+
+
+ {messages.map((message) => { + if (message.type === 'user') { + return ( +
+ {message.widths[0]} +
+ ) + } + + return ( + + ) + })} +
+
+
+ {step === 1 ? ( +
+ {typedText} + +
+ ) : ( + {placeholder} + )} +
+
+ ) +} + +function HistoryContainer({ children }: { children: ReactNode }) { + return <>{children} +} + +function HistoryBubble({ + widths, + exiting, + collapse = true, +}: { + widths: readonly string[] + exiting: boolean + collapse?: boolean +}) { + return ( +
+ {widths.map((width, idx) => ( +
+ ))} +
+ ) +} + +function UserBubble({ text, visible }: { text: string; visible: boolean }) { + return ( +
+ {text} +
+ ) +} + +function AssistantStreamBubble({ + progress, + state, +}: { + progress: number + state: 'idle' | 'streaming' | 'complete' +}) { + const isVisible = state !== 'idle' + const isComplete = state === 'complete' + + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/web-app/src/containers/__tests__/SettingsMenu.test.tsx b/web-app/src/containers/__tests__/SettingsMenu.test.tsx index d5d88af5b..31ee86c9d 100644 --- a/web-app/src/containers/__tests__/SettingsMenu.test.tsx +++ b/web-app/src/containers/__tests__/SettingsMenu.test.tsx @@ -110,7 +110,7 @@ describe('SettingsMenu', () => { render() expect(screen.getByText('common:general')).toBeInTheDocument() - expect(screen.getByText('common:appearance')).toBeInTheDocument() + expect(screen.getByText('common:interface')).toBeInTheDocument() expect(screen.getByText('common:privacy')).toBeInTheDocument() expect(screen.getByText('common:modelProviders')).toBeInTheDocument() // Platform-specific features tested separately diff --git a/web-app/src/hooks/__tests__/useGeneralSetting.test.ts b/web-app/src/hooks/__tests__/useGeneralSetting.test.ts index f55835c0f..48574705d 100644 --- a/web-app/src/hooks/__tests__/useGeneralSetting.test.ts +++ b/web-app/src/hooks/__tests__/useGeneralSetting.test.ts @@ -40,6 +40,7 @@ describe('useGeneralSetting', () => { useGeneralSetting.setState({ currentLanguage: 'en', spellCheckChatInput: true, + tokenCounterCompact: true, huggingfaceToken: undefined, }) diff --git a/web-app/src/hooks/__tests__/useAppearance.test.ts b/web-app/src/hooks/__tests__/useInterfaceSettings.test.ts similarity index 74% rename from web-app/src/hooks/__tests__/useAppearance.test.ts rename to web-app/src/hooks/__tests__/useInterfaceSettings.test.ts index 3d28213ed..9fb4f7b3f 100644 --- a/web-app/src/hooks/__tests__/useAppearance.test.ts +++ b/web-app/src/hooks/__tests__/useInterfaceSettings.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useAppearance } from '../useAppearance' +import { useInterfaceSettings } from '../useInterfaceSettings' +import { THREAD_SCROLL_BEHAVIOR } from '@/constants/threadScroll' // Mock constants vi.mock('@/constants/localStorage', () => ({ localStorageKey: { - appearance: 'appearance', + settingInterface: 'setting-interface', }, })) @@ -35,13 +36,13 @@ Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true }) Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true }) Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true }) -describe('useAppearance', () => { +describe('useInterfaceSettings', () => { beforeEach(() => { vi.clearAllMocks() }) it('should initialize with default values', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) expect(result.current.fontSize).toBe('15px') expect(result.current.chatWidth).toBe('compact') @@ -51,10 +52,12 @@ describe('useAppearance', () => { b: 25, a: 1, }) + expect(result.current.threadScrollBehavior).toBe(THREAD_SCROLL_BEHAVIOR.FLOW) + expect(typeof result.current.setThreadScrollBehavior).toBe('function') }) it('should update font size', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) act(() => { result.current.setFontSize('18px') @@ -64,7 +67,7 @@ describe('useAppearance', () => { }) it('should update chat width', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) act(() => { result.current.setChatWidth('full') @@ -73,8 +76,34 @@ describe('useAppearance', () => { expect(result.current.chatWidth).toBe('full') }) + describe('thread scroll behavior', () => { + it('should update to sticky mode', () => { + const { result } = renderHook(() => useInterfaceSettings()) + + act(() => { + result.current.setThreadScrollBehavior(THREAD_SCROLL_BEHAVIOR.STICKY) + }) + + expect(result.current.threadScrollBehavior).toBe( + THREAD_SCROLL_BEHAVIOR.STICKY + ) + }) + + it('should fall back to default for invalid values', () => { + const { result } = renderHook(() => useInterfaceSettings()) + + act(() => { + result.current.setThreadScrollBehavior('invalid' as any) + }) + + expect(result.current.threadScrollBehavior).toBe( + THREAD_SCROLL_BEHAVIOR.FLOW + ) + }) + }) + it('should update app background color', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const newColor = { r: 100, g: 100, b: 100, a: 1 } act(() => { @@ -85,7 +114,7 @@ describe('useAppearance', () => { }) it('should update main view background color', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const newColor = { r: 200, g: 200, b: 200, a: 1 } act(() => { @@ -96,7 +125,7 @@ describe('useAppearance', () => { }) it('should update primary background color', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const newColor = { r: 50, g: 100, b: 150, a: 1 } act(() => { @@ -107,7 +136,7 @@ describe('useAppearance', () => { }) it('should update accent background color', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const newColor = { r: 255, g: 100, b: 50, a: 1 } act(() => { @@ -118,7 +147,7 @@ describe('useAppearance', () => { }) it('should update destructive background color', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const newColor = { r: 255, g: 0, b: 0, a: 1 } act(() => { @@ -128,8 +157,8 @@ describe('useAppearance', () => { expect(result.current.appDestructiveBgColor).toEqual(newColor) }) - it('should reset appearance to defaults', () => { - const { result } = renderHook(() => useAppearance()) + it('should reset interface settings to defaults', () => { + const { result } = renderHook(() => useInterfaceSettings()) // Change some values first act(() => { @@ -140,11 +169,11 @@ describe('useAppearance', () => { // Reset act(() => { - result.current.resetAppearance() + result.current.resetInterface() }) expect(result.current.fontSize).toBe('15px') - // Note: resetAppearance doesn't reset chatWidth, only visual properties + // Note: resetInterface doesn't reset chatWidth, only visual properties expect(result.current.chatWidth).toBe('full') expect(result.current.appBgColor).toEqual({ r: 255, @@ -160,14 +189,14 @@ describe('useAppearance', () => { Object.defineProperty(global, 'IS_WEB_APP', { value: false }) Object.defineProperty(global, 'IS_WINDOWS', { value: true }) - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) expect(result.current.appBgColor.a).toBe(1) }) }) - describe('Reset appearance functionality', () => { + describe('Reset interface functionality', () => { beforeEach(() => { // Mock document.documentElement.style.setProperty Object.defineProperty(document.documentElement, 'style', { @@ -178,11 +207,11 @@ describe('useAppearance', () => { }) }) - it('should reset CSS variables when resetAppearance is called', () => { - const { result } = renderHook(() => useAppearance()) + it('should reset CSS variables when resetInterface is called', () => { + const { result } = renderHook(() => useInterfaceSettings()) act(() => { - result.current.resetAppearance() + result.current.resetInterface() }) expect(document.documentElement.style.setProperty).toHaveBeenCalledWith( @@ -212,7 +241,7 @@ describe('useAppearance', () => { writable: true, }) - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const testColor = { r: 128, g: 64, b: 192, a: 0.8 } act(() => { @@ -224,7 +253,7 @@ describe('useAppearance', () => { }) it('should handle transparent colors', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const transparentColor = { r: 100, g: 100, b: 100, a: 0 } act(() => { @@ -249,7 +278,7 @@ describe('useAppearance', () => { writable: true, }) - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const testColor = { r: 128, g: 64, b: 192, a: 0.5 } act(() => { @@ -267,7 +296,7 @@ describe('useAppearance', () => { describe('Edge cases', () => { it('should handle invalid color values gracefully', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) const invalidColor = { r: -10, g: 300, b: 128, a: 2 } act(() => { @@ -280,7 +309,7 @@ describe('useAppearance', () => { describe('Type checking', () => { it('should only accept valid font sizes', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) // These should work act(() => { @@ -305,7 +334,7 @@ describe('useAppearance', () => { }) it('should only accept valid chat widths', () => { - const { result } = renderHook(() => useAppearance()) + const { result } = renderHook(() => useInterfaceSettings()) act(() => { result.current.setChatWidth('full') diff --git a/web-app/src/hooks/useGeneralSetting.ts b/web-app/src/hooks/useGeneralSetting.ts index e76c49017..b81b09e4d 100644 --- a/web-app/src/hooks/useGeneralSetting.ts +++ b/web-app/src/hooks/useGeneralSetting.ts @@ -2,8 +2,7 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { localStorageKey } from '@/constants/localStorage' import { ExtensionManager } from '@/lib/extension' - -type LeftPanelStoreState = { +type GeneralSettingState = { currentLanguage: Language spellCheckChatInput: boolean tokenCounterCompact: boolean @@ -14,7 +13,7 @@ type LeftPanelStoreState = { setCurrentLanguage: (value: Language) => void } -export const useGeneralSetting = create()( +export const useGeneralSetting = create()( persist( (set) => ({ currentLanguage: 'en', @@ -50,3 +49,5 @@ export const useGeneralSetting = create()( } ) ) + + diff --git a/web-app/src/hooks/useAppearance.ts b/web-app/src/hooks/useInterfaceSettings.ts similarity index 81% rename from web-app/src/hooks/useAppearance.ts rename to web-app/src/hooks/useInterfaceSettings.ts index 1ab7797fc..e8e7b18a1 100644 --- a/web-app/src/hooks/useAppearance.ts +++ b/web-app/src/hooks/useInterfaceSettings.ts @@ -7,11 +7,17 @@ import { useTheme } from './useTheme' import { useEffect, useState } from 'react' import { getServiceHub } from '@/hooks/useServiceHub' import { supportsBlurEffects } from '@/utils/blurSupport' +import { + DEFAULT_THREAD_SCROLL_BEHAVIOR, + THREAD_SCROLL_BEHAVIOR, + ThreadScrollBehavior, + isThreadScrollBehavior, +} from '@/constants/threadScroll' export type FontSize = '14px' | '15px' | '16px' | '18px' export type ChatWidth = 'full' | 'compact' -interface AppearanceState { +interface InterfaceSettingsState { chatWidth: ChatWidth fontSize: FontSize appBgColor: RgbaColor @@ -24,6 +30,7 @@ interface AppearanceState { appAccentTextColor: string appDestructiveTextColor: string appLeftPanelTextColor: string + threadScrollBehavior: ThreadScrollBehavior setChatWidth: (size: ChatWidth) => void setFontSize: (size: FontSize) => void setAppBgColor: (color: RgbaColor) => void @@ -31,9 +38,26 @@ interface AppearanceState { setAppPrimaryBgColor: (color: RgbaColor) => void setAppAccentBgColor: (color: RgbaColor) => void setAppDestructiveBgColor: (color: RgbaColor) => void - resetAppearance: () => void + setThreadScrollBehavior: (value: ThreadScrollBehavior) => void + resetInterface: () => void } +const LEGACY_INTERFACE_STORAGE_KEY = 'setting-appearance' as const +const GENERAL_SETTINGS_STORAGE_KEY = localStorageKey.settingGeneral + +type InterfaceSettingsPersistedSlice = Omit< + InterfaceSettingsState, + | 'resetInterface' + | 'setChatWidth' + | 'setFontSize' + | 'setAppBgColor' + | 'setAppMainViewBgColor' + | 'setAppPrimaryBgColor' + | 'setAppAccentBgColor' + | 'setAppDestructiveBgColor' + | 'setThreadScrollBehavior' +> + const getBrightness = ({ r, g, b }: RgbaColor) => (r * 299 + g * 587 + b * 114) / 1000 @@ -62,7 +86,7 @@ const getAlphaValue = () => { return 0.4 } -// Default appearance settings +// Default interface settings const defaultFontSize: FontSize = '15px' const defaultAppBgColor: RgbaColor = { r: 25, @@ -154,6 +178,162 @@ export const getDefaultTextColor = (isDark: boolean): string => { return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor } +const getIsDarkTheme = (): boolean => { + try { + return !!useTheme.getState().isDark + } catch { + return false + } +} + +const copyColor = (color: RgbaColor): RgbaColor => ({ + r: color.r, + g: color.g, + b: color.b, + a: color.a, +}) + +const createDefaultInterfaceValues = (): InterfaceSettingsPersistedSlice => { + const isDark = getIsDarkTheme() + const defaultTextColor = getDefaultTextColor(isDark) + + return { + chatWidth: 'compact', + fontSize: defaultFontSize, + appBgColor: copyColor(defaultAppBgColor), + appMainViewBgColor: copyColor(defaultAppMainViewBgColor), + appPrimaryBgColor: copyColor(defaultAppPrimaryBgColor), + appAccentBgColor: copyColor(defaultAppAccentBgColor), + appDestructiveBgColor: copyColor(defaultAppDestructiveBgColor), + appLeftPanelTextColor: defaultTextColor, + appMainViewTextColor: defaultTextColor, + appPrimaryTextColor: defaultTextColor, + appAccentTextColor: defaultTextColor, + appDestructiveTextColor: '#FFF', + threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR, + } +} + +const buildDefaultPersistedSnapshot = () => + JSON.stringify({ state: createDefaultInterfaceValues(), version: 0 }) + +const validatePersistedSnapshot = (value: string): string | null => { + try { + const parsed = JSON.parse(value) as { + state?: Record + version?: unknown + } + + if (parsed && typeof parsed === 'object' && parsed.state) { + const draft = { ...parsed } + if ( + !isThreadScrollBehavior( + draft.state.threadScrollBehavior as ThreadScrollBehavior + ) + ) { + draft.state = { + ...draft.state, + threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR, + } + return JSON.stringify(draft) + } + return value + } + } catch { + // ignore parse failures + } + + return null +} + +const migrateLegacySnapshot = (): string | null => { + const legacy = localStorage.getItem(LEGACY_INTERFACE_STORAGE_KEY) + if (!legacy) return null + + const migrated = + validatePersistedSnapshot(legacy) ?? buildDefaultPersistedSnapshot() + + localStorage.setItem(localStorageKey.settingInterface, migrated) + localStorage.removeItem(LEGACY_INTERFACE_STORAGE_KEY) + + return migrated +} + +const migrateFromGeneralSettings = (): string | null => { + const general = localStorage.getItem(GENERAL_SETTINGS_STORAGE_KEY) + if (!general) return null + + try { + const parsed = JSON.parse(general) as { + state?: Record + version?: unknown + } + + const legacyBehavior = parsed?.state?.threadScrollBehavior + + if (!isThreadScrollBehavior(legacyBehavior)) { + return null + } + + const nextInterfaceState = { + ...createDefaultInterfaceValues(), + threadScrollBehavior: legacyBehavior, + } + + const migrated = JSON.stringify({ + state: nextInterfaceState, + version: 0, + }) + + localStorage.setItem(localStorageKey.settingInterface, migrated) + + if (parsed?.state) { + const { threadScrollBehavior: _removed, ...rest } = parsed.state + const cleaned = JSON.stringify({ + ...parsed, + state: rest, + }) + localStorage.setItem(GENERAL_SETTINGS_STORAGE_KEY, cleaned) + } + + return migrated + } catch { + return null + } +} + +const interfaceStorage = createJSONStorage(() => ({ + getItem: (name: string) => { + const existing = localStorage.getItem(name) + if (existing !== null) { + const valid = validatePersistedSnapshot(existing) + if (valid) { + if (valid !== existing) { + localStorage.setItem(name, valid) + } + return valid + } + + const fallback = buildDefaultPersistedSnapshot() + localStorage.setItem(name, fallback) + return fallback + } + + if (name !== localStorageKey.settingInterface) { + return null + } + + return migrateLegacySnapshot() ?? migrateFromGeneralSettings() + }, + setItem: (name: string, value: string) => { + const valid = validatePersistedSnapshot(value) + localStorage.setItem(name, valid ?? buildDefaultPersistedSnapshot()) + }, + removeItem: (name: string) => { + localStorage.removeItem(name) + }, +})) + // Hook to check if alpha slider should be shown export const useBlurSupport = () => { const [supportsBlur, setSupportsBlur] = useState( @@ -207,24 +387,13 @@ export const useBlurSupport = () => { return IS_TAURI && (IS_MACOS || supportsBlur) } -export const useAppearance = create()( +export const useInterfaceSettings = create()( persist( (set) => { + const defaultState = createDefaultInterfaceValues() return { - chatWidth: 'compact', - fontSize: defaultFontSize, - appBgColor: defaultAppBgColor, - appMainViewBgColor: defaultAppMainViewBgColor, - appPrimaryBgColor: defaultAppPrimaryBgColor, - appAccentBgColor: defaultAppAccentBgColor, - appDestructiveBgColor: defaultAppDestructiveBgColor, - appLeftPanelTextColor: getDefaultTextColor(useTheme.getState().isDark), - appMainViewTextColor: getDefaultTextColor(useTheme.getState().isDark), - appPrimaryTextColor: getDefaultTextColor(useTheme.getState().isDark), - appAccentTextColor: getDefaultTextColor(useTheme.getState().isDark), - appDestructiveTextColor: '#FFF', - - resetAppearance: () => { + ...defaultState, + resetInterface: () => { const { isDark } = useTheme.getState() // Reset font size @@ -341,20 +510,28 @@ export const useAppearance = create()( ) // Update state - set({ - fontSize: defaultFontSize, - appBgColor: defaultBg, - appMainViewBgColor: defaultMainView, - appPrimaryBgColor: defaultPrimary, - appAccentBgColor: defaultAccent, + set({ + fontSize: defaultFontSize, + appBgColor: defaultBg, + appMainViewBgColor: defaultMainView, + appPrimaryBgColor: defaultPrimary, + appAccentBgColor: defaultAccent, appLeftPanelTextColor: defaultTextColor, - appMainViewTextColor: defaultTextColor, - appPrimaryTextColor: '#FFF', - appAccentTextColor: '#FFF', - appDestructiveBgColor: defaultDestructive, - appDestructiveTextColor: '#FFF', - }) - }, + appMainViewTextColor: defaultTextColor, + appPrimaryTextColor: '#FFF', + appAccentTextColor: '#FFF', + appDestructiveBgColor: defaultDestructive, + appDestructiveTextColor: '#FFF', + threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR, + }) + }, + + setThreadScrollBehavior: (value: ThreadScrollBehavior) => + set({ + threadScrollBehavior: isThreadScrollBehavior(value) + ? value + : DEFAULT_THREAD_SCROLL_BEHAVIOR, + }), setChatWidth: (value: ChatWidth) => { set({ chatWidth: value }) @@ -638,8 +815,8 @@ export const useAppearance = create()( } }, { - name: localStorageKey.settingAppearance, - storage: createJSONStorage(() => localStorage), + name: localStorageKey.settingInterface, + storage: interfaceStorage, // Apply settings when hydrating from storage onRehydrateStorage: () => (state) => { if (state) { @@ -653,7 +830,7 @@ export const useAppearance = create()( const { isDark } = useTheme.getState() // Just use the stored color as-is during rehydration - // The AppearanceProvider will handle alpha normalization after blur detection + // The InterfaceProvider will handle alpha normalization after blur detection const finalColor = state.appBgColor let finalColorMainView = state.appMainViewBgColor diff --git a/web-app/src/hooks/useThreadScrolling.tsx b/web-app/src/hooks/useThreadScrolling.tsx index 41362db61..005d32865 100644 --- a/web-app/src/hooks/useThreadScrolling.tsx +++ b/web-app/src/hooks/useThreadScrolling.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppState } from './useAppState' import { useMessages } from './useMessages' +import { useInterfaceSettings } from './useInterfaceSettings' +import { THREAD_SCROLL_BEHAVIOR } from '@/constants/threadScroll' const VIEWPORT_PADDING = 40 // Offset from viewport bottom for user message positioning const MAX_DOM_RETRY_ATTEMPTS = 5 // Maximum attempts to find DOM elements before giving up @@ -18,6 +20,13 @@ export const useThreadScrolling = ( const [hasScrollbar, setHasScrollbar] = useState(false) const lastScrollTopRef = useRef(0) const lastAssistantMessageRef = useRef(null) + const threadScrollBehavior = useInterfaceSettings( + (state) => state.threadScrollBehavior + ) + const isFlowScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.FLOW + const isStickyScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.STICKY + const [isStickyScrollLocked, setIsStickyScrollLocked] = useState(false) + const stickyScrollStreamingRef = useRef(false) const messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0) const lastMessageRole = useMessages((state) => { @@ -43,7 +52,8 @@ export const useThreadScrolling = ( }, [scrollContainerRef]) - const showScrollToBottomBtn = !isAtBottom && hasScrollbar + const showScrollToBottomBtn = + !isAtBottom && hasScrollbar && !(isStickyScroll && isStickyScrollLocked) const scrollToBottom = useCallback((smooth = false) => { if (scrollContainerRef.current) { @@ -103,9 +113,49 @@ export const useThreadScrolling = ( } }, [checkScrollState, scrollToBottom]) + useEffect(() => { + if (!isStickyScroll) { + if (stickyScrollStreamingRef.current) { + stickyScrollStreamingRef.current = false + } + if (isStickyScrollLocked) { + setIsStickyScrollLocked(false) + } + return + } + + const isCurrentThreadStreaming = + !!streamingContent && streamingContent.thread_id === threadId + + if (isCurrentThreadStreaming && !stickyScrollStreamingRef.current) { + stickyScrollStreamingRef.current = true + setIsStickyScrollLocked(true) + } else if (!isCurrentThreadStreaming && stickyScrollStreamingRef.current) { + stickyScrollStreamingRef.current = false + requestAnimationFrame(() => { + scrollToBottom(false) + checkScrollState() + setTimeout(() => { + setIsStickyScrollLocked(false) + }, 1000) + }) + } + }, [ + checkScrollState, + isStickyScroll, + isStickyScrollLocked, + scrollToBottom, + streamingContent, + threadId, + ]) + const prevCountRef = useRef(messageCount) useEffect(() => { + if (!isFlowScroll) { + prevCountRef.current = messageCount + return + } const prevCount = prevCountRef.current const becameLonger = messageCount > prevCount const isUserMessage = lastMessageRole === 'user' @@ -146,12 +196,17 @@ export const useThreadScrolling = ( } prevCountRef.current = messageCount - }, [messageCount, lastMessageRole]) + }, [isFlowScroll, lastMessageRole, messageCount]) useEffect(() => { const previouslyStreaming = wasStreamingRef.current const currentlyStreaming = !!streamingContent && streamingContent.thread_id === threadId + if (!isFlowScroll) { + wasStreamingRef.current = currentlyStreaming + return + } + const streamingStarted = !previouslyStreaming && currentlyStreaming const streamingEnded = previouslyStreaming && !currentlyStreaming const hasPaddingToAdjust = originalPaddingRef.current > 0 @@ -197,7 +252,31 @@ export const useThreadScrolling = ( } wasStreamingRef.current = currentlyStreaming - }, [streamingContent, threadId]) + }, [getDOMElements, isFlowScroll, streamingContent, threadId]) + + useEffect(() => { + if (isFlowScroll) return + setPaddingHeight(0) + originalPaddingRef.current = 0 + }, [isFlowScroll]) + + useEffect(() => { + if (!isStickyScroll) return + scrollToBottom(false) + }, [isStickyScroll, scrollToBottom, threadId]) + + useEffect(() => { + if (!isStickyScroll) return + if (isStickyScrollLocked) return + if (messageCount === 0) return + scrollToBottom(true) + }, [isStickyScroll, isStickyScrollLocked, messageCount, scrollToBottom]) + + useEffect(() => { + if (!isStickyScroll) return + if (streamingContent?.thread_id !== threadId) return + scrollToBottom(false) + }, [isStickyScroll, scrollToBottom, streamingContent, threadId]) useEffect(() => { userIntendedPositionRef.current = null @@ -207,14 +286,18 @@ export const useThreadScrolling = ( prevCountRef.current = messageCount scrollToBottom(false) checkScrollState() + stickyScrollStreamingRef.current = false + setIsStickyScrollLocked(false) + // Only reset when switching threads; keep deps limited intentionally. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId]) return useMemo( () => ({ showScrollToBottomBtn, scrollToBottom, - paddingHeight + paddingHeight, }), - [showScrollToBottomBtn, scrollToBottom, paddingHeight] + [paddingHeight, scrollToBottom, showScrollToBottomBtn] ) } diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index 699c15a08..737b3b21a 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -8,7 +8,7 @@ "general": "Allgemein", "settings": "Einstellungen", "modelProviders": "Modell Anbieter", - "appearance": "Erscheinung", + "interface": "Erscheinung", "privacy": "Privatsphäre", "keyboardShortcuts": "Shortcuts", "newChat": "Neuer Chat", diff --git a/web-app/src/locales/de-DE/settings.json b/web-app/src/locales/de-DE/settings.json index c57667679..cefc27f29 100644 --- a/web-app/src/locales/de-DE/settings.json +++ b/web-app/src/locales/de-DE/settings.json @@ -78,7 +78,7 @@ "goToSettings": "Gehe zu den Einstellungen", "goToSettingsDesc": "Einstellungen öffnen." }, - "appearance": { + "interface": { "title": "Erscheinungsbild", "theme": "Theme", "themeDesc": "Dem Betriebssystem anpassen.", @@ -96,8 +96,8 @@ "destructiveDesc": "Lege die Farbe für destruktive Aktionen fest.", "resetToDefault": "Auf Werkseinstellungen zurücksetzen", "resetToDefaultDesc": "Setzt alle Darstellungseinstellungen auf die Standardeinstellungen zurück.", - "resetAppearanceSuccess": "Erscheinungsbild erfolgreich zurückgesetzt", - "resetAppearanceSuccessDesc": "Alle Darstellungseinstellungen wurden auf die Standardeinstellungen zurückgesetzt.", + "resetInterfaceSuccess": "Erscheinungsbild erfolgreich zurückgesetzt", + "resetInterfaceSuccessDesc": "Alle Darstellungseinstellungen wurden auf die Standardeinstellungen zurückgesetzt.", "chatWidth": "Chat Breite", "chatWidthDesc": "Passe die Breite der Chatansicht an.", "tokenCounterCompact": "Kompakter Token-Zähler", diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index 026f430e8..c1ac54841 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -9,7 +9,7 @@ "general": "General", "settings": "Settings", "modelProviders": "Model Providers", - "appearance": "Appearance", + "interface": "Interface", "privacy": "Privacy", "keyboardShortcuts": "Shortcuts", "newChat": "New Chat", diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index dee73ff23..12e4f8e4a 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -78,8 +78,8 @@ "goToSettings": "Go to Settings", "goToSettingsDesc": "Open settings." }, - "appearance": { - "title": "Appearance", + "interface": { + "title": "Interface", "theme": "Theme", "themeDesc": "Match the OS theme.", "fontSize": "Font Size", @@ -89,17 +89,25 @@ "appMainView": "App Main View", "appMainViewDesc": "Set the main content area's background color.", "primary": "Primary", - "primaryDesc": "Set the primary color for UI components.", + "primaryDesc": "Set the primary color for interface accents.", "accent": "Accent", - "accentDesc": "Set the accent color for UI highlights.", + "accentDesc": "Select the accent color used across the UI.", "destructive": "Destructive", - "destructiveDesc": "Set the color for destructive actions.", + "destructiveDesc": "Set the highlight color for destructive actions.", "resetToDefault": "Reset to Default", - "resetToDefaultDesc": "Reset all appearance settings to default.", - "resetAppearanceSuccess": "Appearance reset successfully", - "resetAppearanceSuccessDesc": "All appearance settings have been restored to default.", - "chatWidth": "Chat Width", - "chatWidthDesc": "Customize the width of the chat view.", + "resetToDefaultDesc": "Reset all interface settings to default.", + "resetInterfaceSuccess": "Interface reset successfully", + "resetInterfaceSuccessDesc": "All interface settings have been restored to default.", + "chatWidth": "Chat Layout Width", + "chatWidthDesc": "Pick a chat width preset that fits your workspace.", + "threadScrollTitle": "Thread Scroll Behavior", + "threadScrollDesc": "Choose how the chat viewport should react when new messages arrive.", + "threadScrollFlowTitle": "Flow scroll", + "threadScrollFlowHint": "Keeps the viewport anchored to the latest message you send.", + + "threadScrollStickyTitle": "Sticky scroll", + "threadScrollStickyHint": "Automatically follows along as replies stream in real time.", + "tokenCounterCompact": "Compact Token Counter", "tokenCounterCompactDesc": "Show token counter inside chat input. When disabled, token counter appears below the input.", "codeBlockTitle": "Code Block", diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index 77af93d31..f52c378bc 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -8,7 +8,7 @@ "general": "Umum", "settings": "Pengaturan", "modelProviders": "Penyedia Model", - "appearance": "Tampilan", + "interface": "Tampilan", "privacy": "Privasi", "keyboardShortcuts": "Pintasan", "newChat": "Obrolan Baru", diff --git a/web-app/src/locales/id/settings.json b/web-app/src/locales/id/settings.json index e1439209b..3b10b610f 100644 --- a/web-app/src/locales/id/settings.json +++ b/web-app/src/locales/id/settings.json @@ -78,7 +78,7 @@ "goToSettings": "Buka Pengaturan", "goToSettingsDesc": "Buka pengaturan." }, - "appearance": { + "interface": { "title": "Tampilan", "theme": "Tema", "themeDesc": "Sesuaikan dengan tema OS.", @@ -96,8 +96,8 @@ "destructiveDesc": "Atur warna untuk tindakan yang merusak.", "resetToDefault": "Setel Ulang ke Default", "resetToDefaultDesc": "Setel ulang semua pengaturan tampilan ke default.", - "resetAppearanceSuccess": "Tampilan berhasil diatur ulang", - "resetAppearanceSuccessDesc": "Semua pengaturan tampilan telah dikembalikan ke default.", + "resetInterfaceSuccess": "Tampilan berhasil diatur ulang", + "resetInterfaceSuccessDesc": "Semua pengaturan tampilan telah dikembalikan ke default.", "chatWidth": "Lebar Obrolan", "chatWidthDesc": "Sesuaikan lebar tampilan obrolan.", "codeBlockTitle": "Blok Kode", diff --git a/web-app/src/locales/ja/common.json b/web-app/src/locales/ja/common.json index 3d828c550..235c18735 100644 --- a/web-app/src/locales/ja/common.json +++ b/web-app/src/locales/ja/common.json @@ -8,7 +8,7 @@ "general": "全般", "settings": "設定", "modelProviders": "モデルプロバイダー", - "appearance": "外観", + "interface": "外観", "privacy": "プライバシー", "keyboardShortcuts": "ショートカット", "newChat": "新しいチャット", @@ -364,4 +364,4 @@ "description": "スレッドは「{{projectName}}」から正常に削除されました" } } -} \ No newline at end of file +} diff --git a/web-app/src/locales/ja/settings.json b/web-app/src/locales/ja/settings.json index 4e59037c1..0f6b97748 100644 --- a/web-app/src/locales/ja/settings.json +++ b/web-app/src/locales/ja/settings.json @@ -78,7 +78,7 @@ "goToSettings": "設定に移動", "goToSettingsDesc": "設定を開きます。" }, - "appearance": { + "interface": { "title": "外観", "theme": "テーマ", "themeDesc": "OSのテーマに合わせます。", @@ -96,8 +96,8 @@ "destructiveDesc": "破壊的なアクションの色を設定します。", "resetToDefault": "デフォルトにリセット", "resetToDefaultDesc": "すべての外観設定をデフォルトにリセットします。", - "resetAppearanceSuccess": "外観が正常にリセットされました", - "resetAppearanceSuccessDesc": "すべての外観設定がデフォルトに復元されました。", + "resetInterfaceSuccess": "外観が正常にリセットされました", + "resetInterfaceSuccessDesc": "すべての外観設定がデフォルトに復元されました。", "chatWidth": "チャットの幅", "chatWidthDesc": "チャットビューの幅をカスタマイズします。", "tokenCounterCompact": "コンパクトなトークンカウンター", @@ -274,4 +274,4 @@ }, "backendInstallSuccess": "バックエンドは正常にインストールされました", "backendInstallError": "バックエンドのインストールに失敗しました" -} \ No newline at end of file +} diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index ee25f6068..3c5ecff8c 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -8,7 +8,7 @@ "general": "Ogólne", "settings": "Ustawienia", "modelProviders": "Dostawcy Modeli", - "appearance": "Wygląd", + "interface": "Wygląd", "privacy": "Prywatność", "keyboardShortcuts": "Skróty", "newChat": "Nowy Czat", diff --git a/web-app/src/locales/pl/settings.json b/web-app/src/locales/pl/settings.json index 37c29c8ee..794677f9a 100644 --- a/web-app/src/locales/pl/settings.json +++ b/web-app/src/locales/pl/settings.json @@ -78,7 +78,7 @@ "goToSettings": "Przejdź do Ustawień", "goToSettingsDesc": "Otwórz ustawienia." }, - "appearance": { + "interface": { "title": "Wygląd", "theme": "Schemat Kolorystyczny", "themeDesc": "Dopasuj do schematu systemowego.", @@ -96,8 +96,8 @@ "destructiveDesc": "Ustaw kolor operacji destrukcyjnych.", "resetToDefault": "Przywróć Domyślne", "resetToDefaultDesc": "Przywróć domyślne ustawienia wyglądu.", - "resetAppearanceSuccess": "Pomyślnie Przywrócono Ustawienia Wyglądu", - "resetAppearanceSuccessDesc": "Wszystkie ustawienia wyglądu zostały przywrócone do wartości domyślnych.", + "resetInterfaceSuccess": "Pomyślnie Przywrócono Ustawienia Wyglądu", + "resetInterfaceSuccessDesc": "Wszystkie ustawienia wyglądu zostały przywrócone do wartości domyślnych.", "chatWidth": "Szerokość Czatu", "chatWidthDesc": "Ustaw szerokość komponentu czatu.", "codeBlockTitle": "Blok Kodu", diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 28ddd29a7..c1cb349a4 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -8,7 +8,7 @@ "general": "Chung", "settings": "Cài đặt", "modelProviders": "Nhà cung cấp Mô hình", - "appearance": "Giao diện", + "interface": "Giao diện", "privacy": "Quyền riêng tư", "keyboardShortcuts": "Phím tắt", "newChat": "Trò chuyện Mới", diff --git a/web-app/src/locales/vn/settings.json b/web-app/src/locales/vn/settings.json index 74503b602..39c974957 100644 --- a/web-app/src/locales/vn/settings.json +++ b/web-app/src/locales/vn/settings.json @@ -78,7 +78,7 @@ "goToSettings": "Đi tới Cài đặt", "goToSettingsDesc": "Mở cài đặt." }, - "appearance": { + "interface": { "title": "Giao diện", "theme": "Chủ đề", "themeDesc": "Khớp với chủ đề hệ điều hành.", @@ -96,8 +96,8 @@ "destructiveDesc": "Đặt màu cho các hành động hủy.", "resetToDefault": "Đặt lại về mặc định", "resetToDefaultDesc": "Đặt lại tất cả cài đặt giao diện về mặc định.", - "resetAppearanceSuccess": "Giao diện đã được đặt lại thành công", - "resetAppearanceSuccessDesc": "Tất cả cài đặt giao diện đã được khôi phục về mặc định.", + "resetInterfaceSuccess": "Giao diện đã được đặt lại thành công", + "resetInterfaceSuccessDesc": "Tất cả cài đặt giao diện đã được khôi phục về mặc định.", "chatWidth": "Chiều rộng trò chuyện", "chatWidthDesc": "Tùy chỉnh chiều rộng của chế độ xem trò chuyện.", "tokenCounterCompact": "Bộ đếm token nhỏ gọn", diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index 69b15ac90..d30249546 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -8,7 +8,7 @@ "general": "通用", "settings": "设置", "modelProviders": "模型提供商", - "appearance": "外观", + "interface": "外观", "privacy": "隐私", "keyboardShortcuts": "快捷键", "newChat": "新建聊天", diff --git a/web-app/src/locales/zh-CN/settings.json b/web-app/src/locales/zh-CN/settings.json index be81820d9..f3eb70e01 100644 --- a/web-app/src/locales/zh-CN/settings.json +++ b/web-app/src/locales/zh-CN/settings.json @@ -78,7 +78,7 @@ "goToSettings": "转到设置", "goToSettingsDesc": "打开设置。" }, - "appearance": { + "interface": { "title": "外观", "theme": "主题", "themeDesc": "匹配操作系统主题。", @@ -96,8 +96,8 @@ "destructiveDesc": "设置警示操作的颜色。", "resetToDefault": "重置为默认值", "resetToDefaultDesc": "将所有外观设置重置为默认值。", - "resetAppearanceSuccess": "外观重置成功", - "resetAppearanceSuccessDesc": "所有外观设置已恢复为默认值。", + "resetInterfaceSuccess": "外观重置成功", + "resetInterfaceSuccessDesc": "所有外观设置已恢复为默认值。", "chatWidth": "聊天宽度", "chatWidthDesc": "自定义聊天视图的宽度。", "tokenCounterCompact": "紧凑令牌计数器", diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index 809ac0cd4..6bcc82e09 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -8,7 +8,7 @@ "general": "一般", "settings": "設定", "modelProviders": "模型提供者", - "appearance": "外觀", + "interface": "外觀", "privacy": "隱私", "keyboardShortcuts": "快捷鍵", "newChat": "新聊天", diff --git a/web-app/src/locales/zh-TW/settings.json b/web-app/src/locales/zh-TW/settings.json index aed446974..7d6af845e 100644 --- a/web-app/src/locales/zh-TW/settings.json +++ b/web-app/src/locales/zh-TW/settings.json @@ -78,7 +78,7 @@ "goToSettings": "前往設定", "goToSettingsDesc": "開啟設定。" }, - "appearance": { + "interface": { "title": "外觀", "theme": "主題", "themeDesc": "符合作業系統主題。", @@ -96,8 +96,8 @@ "destructiveDesc": "設定毀滅性操作的顏色。", "resetToDefault": "重設為預設值", "resetToDefaultDesc": "將所有外觀設定重設為預設值。", - "resetAppearanceSuccess": "外觀重設成功", - "resetAppearanceSuccessDesc": "所有外觀設定已還原為預設值。", + "resetInterfaceSuccess": "外觀重設成功", + "resetInterfaceSuccessDesc": "所有外觀設定已還原為預設值。", "chatWidth": "聊天寬度", "chatWidthDesc": "自訂聊天檢視的寬度。", "codeBlockTitle": "程式碼區塊", diff --git a/web-app/src/providers/AppearanceProvider.tsx b/web-app/src/providers/InterfaceProvider.tsx similarity index 93% rename from web-app/src/providers/AppearanceProvider.tsx rename to web-app/src/providers/InterfaceProvider.tsx index 290c42231..bcc6d6260 100644 --- a/web-app/src/providers/AppearanceProvider.tsx +++ b/web-app/src/providers/InterfaceProvider.tsx @@ -1,5 +1,5 @@ -import { useEffect } from 'react' -import { useAppearance, useBlurSupport } from '@/hooks/useAppearance' +import { useEffect } from 'react' +import { useInterfaceSettings, useBlurSupport } from '@/hooks/useInterfaceSettings' import { useTheme } from '@/hooks/useTheme' import { isDefaultColor, @@ -8,13 +8,13 @@ import { isDefaultColorPrimary, isDefaultColorAccent, isDefaultColorDestructive, -} from '@/hooks/useAppearance' +} from '@/hooks/useInterfaceSettings' /** - * AppearanceProvider ensures appearance settings are applied on every page load + * InterfaceProvider ensures interface settings are applied on every page load * This component should be mounted at the root level of the application */ -export function AppearanceProvider() { +export function InterfaceProvider() { const { fontSize, appBgColor, @@ -27,11 +27,11 @@ export function AppearanceProvider() { appAccentTextColor, appDestructiveBgColor, appDestructiveTextColor, - } = useAppearance() + } = useInterfaceSettings() const { isDark } = useTheme() const showAlphaSlider = useBlurSupport() - // Force re-apply appearance on mount to fix theme desync issues on Windows + // Force re-apply interface settings on mount to fix theme desync issues on Windows // This ensures that when navigating to routes (like logs), the theme is properly applied useEffect(() => { const { @@ -39,7 +39,7 @@ export function AppearanceProvider() { setAppMainViewBgColor, appBgColor, appMainViewBgColor, - } = useAppearance.getState() + } = useInterfaceSettings.getState() // Re-trigger setters to ensure CSS variables are applied with correct theme setAppBgColor(appBgColor) @@ -48,12 +48,12 @@ export function AppearanceProvider() { // Update colors when blur support changes (important for Windows/Linux) useEffect(() => { - const { setAppBgColor, appBgColor } = useAppearance.getState() + const { setAppBgColor, appBgColor } = useInterfaceSettings.getState() // Re-apply color to update alpha based on blur support setAppBgColor(appBgColor) }, [showAlphaSlider]) - // Apply appearance settings on mount and when they change + // Apply interface settings on mount and when they change useEffect(() => { // Apply font size document.documentElement.style.setProperty('--font-size-base', fontSize) @@ -203,9 +203,9 @@ export function AppearanceProvider() { showAlphaSlider, ]) - // Update appearance when theme changes + // Update interface styling when theme changes useEffect(() => { - // Get the current appearance state + // Get the current interface state const { appBgColor, appMainViewBgColor, @@ -217,7 +217,7 @@ export function AppearanceProvider() { setAppPrimaryBgColor, setAppAccentBgColor, setAppDestructiveBgColor, - } = useAppearance.getState() + } = useInterfaceSettings.getState() // Force re-apply all colors when theme changes to ensure correct dark/light defaults // This is especially important on Windows where the theme might not be properly diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 6d2a97d0b..6c20c988e 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -22,12 +22,12 @@ import { Route as SettingsShortcutsImport } from './routes/settings/shortcuts' import { Route as SettingsPrivacyImport } from './routes/settings/privacy' import { Route as SettingsMcpServersImport } from './routes/settings/mcp-servers' import { Route as SettingsLocalApiServerImport } from './routes/settings/local-api-server' +import { Route as SettingsInterfaceImport } from './routes/settings/interface' import { Route as SettingsHttpsProxyImport } from './routes/settings/https-proxy' import { Route as SettingsHardwareImport } from './routes/settings/hardware' import { Route as SettingsGeneralImport } from './routes/settings/general' import { Route as SettingsExtensionsImport } from './routes/settings/extensions' import { Route as SettingsAttachmentsImport } from './routes/settings/attachments' -import { Route as SettingsAppearanceImport } from './routes/settings/appearance' import { Route as ProjectProjectIdImport } from './routes/project/$projectId' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' import { Route as HubModelIdImport } from './routes/hub/$modelId' @@ -103,6 +103,12 @@ const SettingsLocalApiServerRoute = SettingsLocalApiServerImport.update({ getParentRoute: () => rootRoute, } as any) +const SettingsInterfaceRoute = SettingsInterfaceImport.update({ + id: '/settings/interface', + path: '/settings/interface', + getParentRoute: () => rootRoute, +} as any) + const SettingsHttpsProxyRoute = SettingsHttpsProxyImport.update({ id: '/settings/https-proxy', path: '/settings/https-proxy', @@ -133,12 +139,6 @@ const SettingsAttachmentsRoute = SettingsAttachmentsImport.update({ getParentRoute: () => rootRoute, } as any) -const SettingsAppearanceRoute = SettingsAppearanceImport.update({ - id: '/settings/appearance', - path: '/settings/appearance', - getParentRoute: () => rootRoute, -} as any) - const ProjectProjectIdRoute = ProjectProjectIdImport.update({ id: '/project/$projectId', path: '/project/$projectId', @@ -229,13 +229,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectProjectIdImport parentRoute: typeof rootRoute } - '/settings/appearance': { - id: '/settings/appearance' - path: '/settings/appearance' - fullPath: '/settings/appearance' - preLoaderRoute: typeof SettingsAppearanceImport - parentRoute: typeof rootRoute - } '/settings/attachments': { id: '/settings/attachments' path: '/settings/attachments' @@ -271,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsHttpsProxyImport parentRoute: typeof rootRoute } + '/settings/interface': { + id: '/settings/interface' + path: '/settings/interface' + fullPath: '/settings/interface' + preLoaderRoute: typeof SettingsInterfaceImport + parentRoute: typeof rootRoute + } '/settings/local-api-server': { id: '/settings/local-api-server' path: '/settings/local-api-server' @@ -354,12 +354,12 @@ export interface FileRoutesByFullPath { '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute - '/settings/appearance': typeof SettingsAppearanceRoute '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute + '/settings/interface': typeof SettingsInterfaceRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -380,12 +380,12 @@ export interface FileRoutesByTo { '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute - '/settings/appearance': typeof SettingsAppearanceRoute '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute + '/settings/interface': typeof SettingsInterfaceRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -407,12 +407,12 @@ export interface FileRoutesById { '/hub/$modelId': typeof HubModelIdRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute '/project/$projectId': typeof ProjectProjectIdRoute - '/settings/appearance': typeof SettingsAppearanceRoute '/settings/attachments': typeof SettingsAttachmentsRoute '/settings/extensions': typeof SettingsExtensionsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/hardware': typeof SettingsHardwareRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute + '/settings/interface': typeof SettingsInterfaceRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -435,12 +435,12 @@ export interface FileRouteTypes { | '/hub/$modelId' | '/local-api-server/logs' | '/project/$projectId' - | '/settings/appearance' | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' | '/settings/https-proxy' + | '/settings/interface' | '/settings/local-api-server' | '/settings/mcp-servers' | '/settings/privacy' @@ -460,12 +460,12 @@ export interface FileRouteTypes { | '/hub/$modelId' | '/local-api-server/logs' | '/project/$projectId' - | '/settings/appearance' | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' | '/settings/https-proxy' + | '/settings/interface' | '/settings/local-api-server' | '/settings/mcp-servers' | '/settings/privacy' @@ -485,12 +485,12 @@ export interface FileRouteTypes { | '/hub/$modelId' | '/local-api-server/logs' | '/project/$projectId' - | '/settings/appearance' | '/settings/attachments' | '/settings/extensions' | '/settings/general' | '/settings/hardware' | '/settings/https-proxy' + | '/settings/interface' | '/settings/local-api-server' | '/settings/mcp-servers' | '/settings/privacy' @@ -512,12 +512,12 @@ export interface RootRouteChildren { HubModelIdRoute: typeof HubModelIdRoute LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute - SettingsAppearanceRoute: typeof SettingsAppearanceRoute SettingsAttachmentsRoute: typeof SettingsAttachmentsRoute SettingsExtensionsRoute: typeof SettingsExtensionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsHardwareRoute: typeof SettingsHardwareRoute SettingsHttpsProxyRoute: typeof SettingsHttpsProxyRoute + SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsLocalApiServerRoute: typeof SettingsLocalApiServerRoute SettingsMcpServersRoute: typeof SettingsMcpServersRoute SettingsPrivacyRoute: typeof SettingsPrivacyRoute @@ -538,12 +538,12 @@ const rootRouteChildren: RootRouteChildren = { HubModelIdRoute: HubModelIdRoute, LocalApiServerLogsRoute: LocalApiServerLogsRoute, ProjectProjectIdRoute: ProjectProjectIdRoute, - SettingsAppearanceRoute: SettingsAppearanceRoute, SettingsAttachmentsRoute: SettingsAttachmentsRoute, SettingsExtensionsRoute: SettingsExtensionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsHardwareRoute: SettingsHardwareRoute, SettingsHttpsProxyRoute: SettingsHttpsProxyRoute, + SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsLocalApiServerRoute: SettingsLocalApiServerRoute, SettingsMcpServersRoute: SettingsMcpServersRoute, SettingsPrivacyRoute: SettingsPrivacyRoute, @@ -573,12 +573,12 @@ export const routeTree = rootRoute "/hub/$modelId", "/local-api-server/logs", "/project/$projectId", - "/settings/appearance", "/settings/attachments", "/settings/extensions", "/settings/general", "/settings/hardware", "/settings/https-proxy", + "/settings/interface", "/settings/local-api-server", "/settings/mcp-servers", "/settings/privacy", @@ -612,9 +612,6 @@ export const routeTree = rootRoute "/project/$projectId": { "filePath": "project/$projectId.tsx" }, - "/settings/appearance": { - "filePath": "settings/appearance.tsx" - }, "/settings/attachments": { "filePath": "settings/attachments.tsx" }, @@ -630,6 +627,9 @@ export const routeTree = rootRoute "/settings/https-proxy": { "filePath": "settings/https-proxy.tsx" }, + "/settings/interface": { + "filePath": "settings/interface.tsx" + }, "/settings/local-api-server": { "filePath": "settings/local-api-server.tsx" }, diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index f7e61c152..bf77b387d 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -1,11 +1,11 @@ -import { createRootRoute, Outlet } from '@tanstack/react-router' +import { createRootRoute, Outlet } from '@tanstack/react-router' // import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import LeftPanel from '@/containers/LeftPanel' import DialogAppUpdater from '@/containers/dialogs/AppUpdater' import BackendUpdater from '@/containers/dialogs/BackendUpdater' import { Fragment } from 'react/jsx-runtime' -import { AppearanceProvider } from '@/providers/AppearanceProvider' +import { InterfaceProvider } from '@/providers/InterfaceProvider' import { ThemeProvider } from '@/providers/ThemeProvider' import { KeyboardShortcutsProvider } from '@/providers/KeyboardShortcuts' import { DataProvider } from '@/providers/DataProvider' @@ -212,7 +212,7 @@ function RootLayout() { - + diff --git a/web-app/src/routes/project/$projectId.tsx b/web-app/src/routes/project/$projectId.tsx index 25fd4552b..1c8cf4fa6 100644 --- a/web-app/src/routes/project/$projectId.tsx +++ b/web-app/src/routes/project/$projectId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useParams } from '@tanstack/react-router' +import { createFileRoute, useParams } from '@tanstack/react-router' import { useMemo } from 'react' import { useThreadManagement } from '@/hooks/useThreadManagement' @@ -15,7 +15,7 @@ import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { PlatformFeature } from '@/lib/platform/types' import { IconMessage } from '@tabler/icons-react' import { cn } from '@/lib/utils' -import { useAppearance } from '@/hooks/useAppearance' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { useSmallScreen } from '@/hooks/useMediaQuery' export const Route = createFileRoute('/project/$projectId')({ @@ -36,7 +36,7 @@ function ProjectPageContent() { const { getFolderById } = useThreadManagement() const threads = useThreads((state) => state.threads) - const chatWidth = useAppearance((state) => state.chatWidth) + const chatWidth = useInterfaceSettings((state) => state.chatWidth) const isSmallScreen = useSmallScreen() // Find the project diff --git a/web-app/src/routes/settings/__tests__/appearance.test.tsx b/web-app/src/routes/settings/__tests__/interface.test.tsx similarity index 82% rename from web-app/src/routes/settings/__tests__/appearance.test.tsx rename to web-app/src/routes/settings/__tests__/interface.test.tsx index c7560ad70..3cc695b49 100644 --- a/web-app/src/routes/settings/__tests__/appearance.test.tsx +++ b/web-app/src/routes/settings/__tests__/interface.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { Route as AppearanceRoute } from '../appearance' +import { Route as InterfaceRoute } from '../interface' // Mock all the dependencies vi.mock('@/containers/SettingsMenu', () => ({ @@ -61,6 +61,12 @@ vi.mock('@/containers/ChatWidthSwitcher', () => ({ ChatWidthSwitcher: () =>
Chat Width Switcher
, })) +vi.mock('@/containers/ThreadScrollBehaviorSwitcher', () => ({ + ThreadScrollBehaviorSwitcher: () => ( +
Thread Scroll Switcher
+ ), +})) + vi.mock('@/containers/CodeBlockStyleSwitcher', () => ({ default: () =>
Code Block Style Switcher
, })) @@ -73,9 +79,9 @@ vi.mock('@/containers/CodeBlockExample', () => ({ CodeBlockExample: () =>
Code Block Example
, })) -vi.mock('@/hooks/useAppearance', () => ({ - useAppearance: () => ({ - resetAppearance: vi.fn(), +vi.mock('@/hooks/useInterfaceSettings', () => ({ + useInterfaceSettings: () => ({ + resetInterface: vi.fn(), }), })) @@ -108,7 +114,7 @@ vi.mock('sonner', () => ({ vi.mock('@/constants/routes', () => ({ route: { settings: { - appearance: '/settings/appearance', + interface: '/settings/interface', }, }, })) @@ -120,13 +126,13 @@ vi.mock('@tanstack/react-router', () => ({ }), })) -describe('Appearance Settings Route', () => { +describe('Interface Settings Route', () => { beforeEach(() => { vi.clearAllMocks() }) - it('should render the appearance settings page', () => { - const Component = AppearanceRoute.component as React.ComponentType + it('should render the interface settings page', () => { + const Component = InterfaceRoute.component as React.ComponentType render() expect(screen.getByTestId('header-page')).toBeInTheDocument() @@ -134,8 +140,8 @@ describe('Appearance Settings Route', () => { expect(screen.getByText('common:settings')).toBeInTheDocument() }) - it('should render appearance controls', () => { - const Component = AppearanceRoute.component as React.ComponentType + it('should render interface controls', () => { + const Component = InterfaceRoute.component as React.ComponentType render() expect(screen.getByTestId('theme-switcher')).toBeInTheDocument() @@ -148,14 +154,15 @@ describe('Appearance Settings Route', () => { }) it('should render chat width controls', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() expect(screen.getByTestId('chat-width-switcher')).toBeInTheDocument() + expect(screen.getByTestId('thread-scroll-switcher')).toBeInTheDocument() }) it('should render code block controls', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() expect(screen.getByTestId('code-block-style-switcher')).toBeInTheDocument() @@ -163,8 +170,8 @@ describe('Appearance Settings Route', () => { expect(screen.getByTestId('line-numbers-switcher')).toBeInTheDocument() }) - it('should render reset appearance button', () => { - const Component = AppearanceRoute.component as React.ComponentType + it('should render reset interface button', () => { + const Component = InterfaceRoute.component as React.ComponentType render() const resetButtons = screen.getAllByTestId('button') @@ -172,7 +179,7 @@ describe('Appearance Settings Route', () => { }) it('should render reset buttons', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() const resetButtons = screen.getAllByTestId('button') @@ -185,7 +192,7 @@ describe('Appearance Settings Route', () => { }) it('should render reset functionality', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() const resetButtons = screen.getAllByTestId('button') @@ -199,7 +206,7 @@ describe('Appearance Settings Route', () => { }) it('should render all card items with proper structure', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() const cardItems = screen.getAllByTestId('card-item') @@ -211,7 +218,7 @@ describe('Appearance Settings Route', () => { }) it('should have proper responsive layout classes', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() const cardItems = screen.getAllByTestId('card-item') @@ -226,7 +233,7 @@ describe('Appearance Settings Route', () => { }) it('should render main layout structure', () => { - const Component = AppearanceRoute.component as React.ComponentType + const Component = InterfaceRoute.component as React.ComponentType render() const headerPage = screen.getByTestId('header-page') diff --git a/web-app/src/routes/settings/appearance.tsx b/web-app/src/routes/settings/interface.tsx similarity index 62% rename from web-app/src/routes/settings/appearance.tsx rename to web-app/src/routes/settings/interface.tsx index c7b273600..05b1541b9 100644 --- a/web-app/src/routes/settings/appearance.tsx +++ b/web-app/src/routes/settings/interface.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { route } from '@/constants/routes' import SettingsMenu from '@/containers/SettingsMenu' import HeaderPage from '@/containers/HeaderPage' @@ -11,7 +11,7 @@ import { FontSizeSwitcher } from '@/containers/FontSizeSwitcher' import { ColorPickerAppPrimaryColor } from '@/containers/ColorPickerAppPrimaryColor' import { ColorPickerAppAccentColor } from '@/containers/ColorPickerAppAccentColor' import { ColorPickerAppDestructiveColor } from '@/containers/ColorPickerAppDestructiveColor' -import { useAppearance } from '@/hooks/useAppearance' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { useCodeblock } from '@/hooks/useCodeblock' import { Button } from '@/components/ui/button' import CodeBlockStyleSwitcher from '@/containers/CodeBlockStyleSwitcher' @@ -20,15 +20,16 @@ import { CodeBlockExample } from '@/containers/CodeBlockExample' import { toast } from 'sonner' import { ChatWidthSwitcher } from '@/containers/ChatWidthSwitcher' import { TokenCounterCompactSwitcher } from '@/containers/TokenCounterCompactSwitcher' +import { ThreadScrollBehaviorSwitcher } from '@/containers/ThreadScrollBehaviorSwitcher' // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const Route = createFileRoute(route.settings.appearance as any)({ - component: Appareances, +export const Route = createFileRoute(route.settings.interface as any)({ + component: InterfaceSettings, }) -function Appareances() { +function InterfaceSettings() { const { t } = useTranslation() - const { resetAppearance } = useAppearance() + const { resetInterface } = useInterfaceSettings() const { resetCodeBlockStyle } = useCodeblock() return ( @@ -40,64 +41,64 @@ function Appareances() {
- {/* Appearance */} - + {/* Interface */} + } /> } /> } /> } /> } /> } /> } /> { - resetAppearance() + resetInterface() toast.success( - t('settings:appearance.resetAppearanceSuccess'), + t('settings:interface.resetInterfaceSuccess'), { - id: 'reset-appearance', + id: 'reset-interface', description: t( - 'settings:appearance.resetAppearanceSuccessDesc' + 'settings:interface.resetInterfaceSuccessDesc' ), } ) @@ -112,33 +113,42 @@ function Appareances() { {/* Chat Message */} } /> + {/* Scroll Behavior */} + + + + + {/* Codeblock */} } /> } /> { resetCodeBlockStyle() toast.success( - t('settings:appearance.resetCodeBlockSuccess'), + t('settings:interface.resetCodeBlockSuccess'), { id: 'code-block-style', description: t( - 'settings:appearance.resetCodeBlockSuccessDesc' + 'settings:interface.resetCodeBlockSuccessDesc' ), } ) diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 4857308d2..dcd2b6cdf 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { createFileRoute, useParams, redirect, useNavigate } from '@tanstack/react-router' import cloneDeep from 'lodash.clonedeep' import { cn } from '@/lib/utils' @@ -16,7 +16,7 @@ import { useMessages } from '@/hooks/useMessages' import { useServiceHub } from '@/hooks/useServiceHub' import DropdownAssistant from '@/containers/DropdownAssistant' import { useAssistant } from '@/hooks/useAssistant' -import { useAppearance } from '@/hooks/useAppearance' +import { useInterfaceSettings } from '@/hooks/useInterfaceSettings' import { ContentType, ThreadMessage } from '@janhq/core' import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery' import { useTools } from '@/hooks/useTools' @@ -86,7 +86,7 @@ function ThreadDetail() { const assistants = useAssistant((state) => state.assistants) const setMessages = useMessages((state) => state.setMessages) - const chatWidth = useAppearance((state) => state.chatWidth) + const chatWidth = useInterfaceSettings((state) => state.chatWidth) const isSmallScreen = useSmallScreen() const isMobile = useMobileScreen() useTools()