support 2 type of scroll behavior with proper settings

This commit is contained in:
dinhlongviolin1 2025-10-30 00:59:50 +07:00
parent e531eaa4ad
commit 7ed5ec0cc3
46 changed files with 1178 additions and 261 deletions

View File

@ -3,7 +3,7 @@
## A. Initial update / migration Data check ## 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: 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 chat threads
- [ ] Ensure there are a few favourites / star threads - [ ] Ensure there are a few favourites / star threads
- [ ] Ensure there are 2 model downloaded - [ ] 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 - [ ] Can resume chat in threads with the previous context
- [ ] Assistants - [ ] Assistants
- Settings: - Settings:
- [ ] Appearance - [ ] Interface
- [ ] MCP Servers - [ ] MCP Servers
- [ ] Local API Server - [ ] Local API Server
- [ ] HTTPS Proxy - [ ] 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 `Community` links work and point to the correct website
- [ ] Ensure the `Check for Updates` function detect the correct latest version - [ ] 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. - [ ] [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: - [ ] 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 - [ ] Light
- [ ] Dark - [ ] 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 - [ ] Ensure that when this value is changed, there is no broken UI caused by it
- [ ] Code Block - [ ] Code Block
- [ ] Show Line Numbers - [ ] 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 - [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values
#### In `Model Providers`: #### 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) - [ ] 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, ...) - [ ] 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 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 - [ ] 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 `Copy` to copy the model response
- [ ] User can click `Delete` to delete either the user message or 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 threads deleted
- [ ] All Assistant deleted except for default Jan Assistant - [ ] All Assistant deleted except for default Jan Assistant
- [ ] `App Data` location is reset back to default path - [ ] `App Data` location is reset back to default path
- [ ] Appearance reset - [ ] Interface reset
- [ ] Model Providers information all reset - [ ] Model Providers information all reset
- [ ] Llama.cpp setting reset - [ ] Llama.cpp setting reset
- [ ] API keys cleared - [ ] API keys cleared
@ -261,4 +261,4 @@ In `Settings -> General`:
# II. After release # II. After release
- [ ] Check that the App Updater works and user can update to the latest release without any problem - [ ] 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 - [ ] 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 - [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version

View File

@ -88,14 +88,14 @@ If your computer gets very hot, consider using smaller models or reducing GPU la
## Personalization ## 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 - **Theme**: Choose light or dark mode
- **Colors**: Pick your preferred color scheme - **Colors**: Pick your preferred color scheme
- **Code highlighting**: Adjust syntax colors for programming discussions - **Code highlighting**: Adjust syntax colors for programming discussions
![Appearance](./_assets/settings-04.png) ![Interface](./_assets/settings-04.png)
### Writing Assistance ### Writing Assistance

View File

@ -83,11 +83,11 @@ Monitor and manage system resources at **Settings > Hardware**:
## Preferences ## 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. 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.
<br/> <br/>
![Appearance](./_assets/settings-04.png) ![Interface](./_assets/settings-04.png)
<br/> <br/>
### Spell Check ### Spell Check

View File

@ -3,7 +3,7 @@
## A. Initial update / migration Data check ## 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: 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 chat threads
- [ ] Ensure there are a few favourites / star threads - [ ] Ensure there are a few favourites / star threads
- [ ] Ensure there are 2 model downloaded - [ ] 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 - [ ] Can resume chat in threads with the previous context
- [ ] Assistants - [ ] Assistants
- Settings: - Settings:
- [ ] Appearance - [ ] Interface
- [ ] MCP Servers - [ ] MCP Servers
- [ ] Local API Server - [ ] Local API Server
- [ ] HTTPS Proxy - [ ] 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 `Community` links work and point to the correct website
- [ ] Ensure the `Check for Updates` function detect the correct latest version - [ ] 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. - [ ] [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: - [ ] 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 - [ ] Light
- [ ] Dark - [ ] Dark
@ -74,7 +74,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Code Block - [ ] Code Block
- [ ] Show Line Numbers - [ ] 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 - [ ] [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 - [ ] [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values
#### In `Model Providers`: #### 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) - [ ] 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, ...) - [ ] 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 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 - [ ] [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 - [ ] 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 - [ ] User can click `Copy` to copy the model response
@ -269,7 +269,7 @@ In `Settings -> General`:
- [ ] All threads deleted - [ ] All threads deleted
- [ ] All Assistant deleted except for default Jan Assistant - [ ] All Assistant deleted except for default Jan Assistant
- [ ] `App Data` location is reset back to default path - [ ] `App Data` location is reset back to default path
- [ ] Appearance reset - [ ] Interface reset
- [ ] Model Providers information all reset - [ ] Model Providers information all reset
- [ ] Llama.cpp setting reset - [ ] Llama.cpp setting reset
- [ ] API keys cleared - [ ] API keys cleared
@ -299,4 +299,4 @@ In `Settings -> General`:
# II. After release # II. After release
- [ ] Check that the App Updater works and user can update to the latest release without any problem - [ ] 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 - [ ] 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 - [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version

View File

@ -5,7 +5,7 @@ export const localStorageKey = {
theme: 'theme', theme: 'theme',
modelProvider: 'model-provider', modelProvider: 'model-provider',
modelSources: 'model-sources', modelSources: 'model-sources',
settingAppearance: 'setting-appearance', settingInterface: 'setting-interface',
settingGeneral: 'setting-general', settingGeneral: 'setting-general',
settingCodeBlock: 'setting-code-block', settingCodeBlock: 'setting-code-block',
settingLocalApiServer: 'setting-local-api-server', settingLocalApiServer: 'setting-local-api-server',

View File

@ -11,7 +11,7 @@ export const route = {
providers: '/settings/providers/$providerName', providers: '/settings/providers/$providerName',
general: '/settings/general', general: '/settings/general',
attachments: '/settings/attachments', attachments: '/settings/attachments',
appearance: '/settings/appearance', interface: '/settings/interface',
privacy: '/settings/privacy', privacy: '/settings/privacy',
shortcuts: '/settings/shortcuts', shortcuts: '/settings/shortcuts',
extensions: '/settings/extensions', extensions: '/settings/extensions',

View File

@ -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

View File

@ -1,11 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useAppearance } from '@/hooks/useAppearance' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { IconCircleCheckFilled } from '@tabler/icons-react' import { IconCircleCheckFilled } from '@tabler/icons-react'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
export function ChatWidthSwitcher() { export function ChatWidthSwitcher() {
const { chatWidth, setChatWidth } = useAppearance() const { chatWidth, setChatWidth } = useInterfaceSettings()
const { t } = useTranslation() const { t } = useTranslation()
return ( return (

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColorAccent } from '@/hooks/useAppearance' import { useInterfaceSettings, isDefaultColorAccent } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
export function ColorPickerAppAccentColor() { export function ColorPickerAppAccentColor() {
const { appAccentBgColor, setAppAccentBgColor } = useAppearance() const { appAccentBgColor, setAppAccentBgColor } = useInterfaceSettings()
const predefineAppAccentBgColor: RgbaColor[] = [ const predefineAppAccentBgColor: RgbaColor[] = [
{ {

View File

@ -1,4 +1,4 @@
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance' import { useInterfaceSettings, useBlurSupport } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -11,7 +11,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
export function ColorPickerAppBgColor() { export function ColorPickerAppBgColor() {
const { appBgColor, setAppBgColor } = useAppearance() const { appBgColor, setAppBgColor } = useInterfaceSettings()
const { isDark } = useTheme() const { isDark } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
const showAlphaSlider = useBlurSupport() const showAlphaSlider = useBlurSupport()

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColorDestructive } from '@/hooks/useAppearance' import { useInterfaceSettings, isDefaultColorDestructive } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -10,7 +10,7 @@ import {
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
export function ColorPickerAppDestructiveColor() { export function ColorPickerAppDestructiveColor() {
const { appDestructiveBgColor, setAppDestructiveBgColor } = useAppearance() const { appDestructiveBgColor, setAppDestructiveBgColor } = useInterfaceSettings()
const { isDark } = useTheme() const { isDark } = useTheme()
const predefineAppDestructiveBgColor: RgbaColor[] = [ const predefineAppDestructiveBgColor: RgbaColor[] = [

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColorMainView } from '@/hooks/useAppearance' import { useInterfaceSettings, isDefaultColorMainView } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -10,7 +10,7 @@ import {
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
export function ColorPickerAppMainView() { export function ColorPickerAppMainView() {
const { appMainViewBgColor, setAppMainViewBgColor } = useAppearance() const { appMainViewBgColor, setAppMainViewBgColor } = useInterfaceSettings()
const { isDark } = useTheme() const { isDark } = useTheme()
const predefineAppMainViewBgColor: RgbaColor[] = [ const predefineAppMainViewBgColor: RgbaColor[] = [

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColorPrimary } from '@/hooks/useAppearance' import { useInterfaceSettings, isDefaultColorPrimary } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
export function ColorPickerAppPrimaryColor() { export function ColorPickerAppPrimaryColor() {
const { appPrimaryBgColor, setAppPrimaryBgColor } = useAppearance() const { appPrimaryBgColor, setAppPrimaryBgColor } = useInterfaceSettings()
const predefineappPrimaryBgColor: RgbaColor[] = [ const predefineappPrimaryBgColor: RgbaColor[] = [
{ {

View File

@ -1,15 +1,15 @@
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { fontSizeOptions, useAppearance } from '@/hooks/useAppearance' import { fontSizeOptions, useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
export function FontSizeSwitcher() { export function FontSizeSwitcher() {
const { fontSize, setFontSize } = useAppearance() const { fontSize, setFontSize } = useInterfaceSettings()
const { t } = useTranslation() const { t } = useTranslation()
return ( return (

View File

@ -1,9 +1,9 @@
import { useThreadScrolling } from '@/hooks/useThreadScrolling' import { useThreadScrolling } from '@/hooks/useThreadScrolling'
import { memo } from 'react' import { memo } from 'react'
import { GenerateResponseButton } from './GenerateResponseButton' import { GenerateResponseButton } from './GenerateResponseButton'
import { useMessages } from '@/hooks/useMessages' import { useMessages } from '@/hooks/useMessages'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useAppearance } from '@/hooks/useAppearance' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ArrowDown } from 'lucide-react' import { ArrowDown } from 'lucide-react'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
@ -17,7 +17,7 @@ const ScrollToBottom = ({
scrollContainerRef: React.RefObject<HTMLDivElement | null> scrollContainerRef: React.RefObject<HTMLDivElement | null>
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor) const appMainViewBgColor = useInterfaceSettings((state) => state.appMainViewBgColor)
const { showScrollToBottomBtn, scrollToBottom } = const { showScrollToBottomBtn, scrollToBottom } =
useThreadScrolling(threadId, scrollContainerRef) useThreadScrolling(threadId, scrollContainerRef)
const { messages } = useMessages( const { messages } = useMessages(

View File

@ -80,8 +80,8 @@ const SettingsMenu = () => {
isEnabled: PlatformFeatures[PlatformFeature.ATTACHMENTS], isEnabled: PlatformFeatures[PlatformFeature.ATTACHMENTS],
}, },
{ {
title: 'common:appearance', title: 'common:interface',
route: route.settings.appearance, route: route.settings.interface,
hasSubMenu: false, hasSubMenu: false,
isEnabled: true, isEnabled: true,
}, },

View File

@ -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 (
<div className="flex flex-col sm:flex-row sm:gap-4">
<ScrollOption
title={t('settings:interface.threadScrollFlowTitle')}
hint={t('settings:interface.threadScrollFlowHint')}
placeholder={placeholder}
isSelected={isFlowSelected}
onSelect={() => setThreadScrollBehavior(THREAD_SCROLL_BEHAVIOR.FLOW)}
preview={<FlowScrollPreview placeholder={placeholder} />}
/>
<ScrollOption
title={t('settings:interface.threadScrollStickyTitle')}
hint={t('settings:interface.threadScrollStickyHint')}
placeholder={placeholder}
isSelected={isStickySelected}
onSelect={() => setThreadScrollBehavior(THREAD_SCROLL_BEHAVIOR.STICKY)}
preview={<StickyScrollPreview placeholder={placeholder} />}
/>
</div>
)
}
type ScrollOptionProps = {
title: string
hint: string
placeholder: string
isSelected: boolean
onSelect: () => void
preview?: ReactNode
}
function ScrollOption({
title,
hint,
placeholder,
isSelected,
onSelect,
preview,
}: ScrollOptionProps) {
return (
<button
className={cn(
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer text-left transition-colors duration-200',
isSelected && 'border-accent'
)}
onClick={onSelect}
type="button"
>
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">{title}</span>
{isSelected && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
</div>
<div className="px-6 py-5 space-y-2">
{preview ?? (
<>
<div className="flex flex-col gap-2">
<PlaceholderLine />
<PlaceholderLine width="92%" />
<PlaceholderLine width="76%" />
</div>
<div className="bg-main-view-fg/10 border border-main-view-fg/15 h-9 px-4 rounded-md flex items-center text-xs text-main-view-fg/55">
<span className="line-clamp-1">{placeholder}</span>
</div>
</>
)}
<div className="text-[11px] text-main-view-fg/55 leading-snug border border-dashed border-main-view-fg/15 rounded-md px-3 py-2 bg-main-view-fg/7">
{hint}
</div>
</div>
</button>
)
}
function PlaceholderLine({ width = '100%' }: { width?: string }) {
return (
<div className="h-2 rounded-full bg-main-view-fg/15" style={{ width }} />
)
}
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<string[]>([])
const [userMessageVisible, setUserMessageVisible] = useState(false)
const [streamStage, setStreamStage] = useState<'idle' | 'streaming' | 'complete'>('idle')
const timersRef = useRef<number[]>([])
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(
<AssistantStreamBubble
key="stream"
progress={streamProgress}
state={streamStage}
/>
)
}
stack.unshift(
<UserBubble
key="user"
text={sentMessage}
visible={userMessageVisible}
/>
)
if (historyMessages.length > 0) {
stack.push(
<HistoryContainer key="history">
{historyMessages.map((msg) => (
<HistoryBubble
key={msg.id}
widths={msg.widths}
exiting={exitingHistoryIds.includes(msg.id)}
/>
))}
</HistoryContainer>
)
}
return stack
}, [
exitingHistoryIds,
historyMessages,
sentMessage,
streamProgress,
streamStage,
userMessageVisible,
])
return (
<div className="space-y-3 select-none">
<div className="rounded-md border border-main-view-fg/10 bg-main-view-fg/5 p-3 h-[152px] overflow-hidden">
<div className="flex h-full flex-col justify-start gap-2">
{messageStack}
</div>
</div>
<div className="bg-main-view-fg/10 border border-main-view-fg/15 h-9 px-4 rounded-md flex items-center text-xs text-main-view-fg/60 transition-all duration-500">
{step === 1 ? (
<div className="flex items-center gap-1 text-main-view-fg/80 font-medium truncate">
<span className="truncate">{typedText}</span>
<span className="inline-block w-[2px] h-4 bg-main-view-fg/60 animate-pulse" />
</div>
) : (
<span className="line-clamp-1">{placeholder}</span>
)}
</div>
</div>
)
}
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<string[]>([])
const exitingIdsRef = useRef(exitingIds)
const timersRef = useRef<number[]>([])
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 (
<div className="space-y-3 select-none">
<div className="rounded-md border border-main-view-fg/10 bg-main-view-fg/5 p-3 h-[152px] overflow-hidden">
<div className="flex h-full flex-col justify-end gap-2">
{messages.map((message) => {
if (message.type === 'user') {
return (
<div
key={message.id}
className={cn(
'rounded-md border border-main-view-fg/20 bg-main-view px-3 py-2 text-xs font-medium text-main-view-fg shadow-sm transition-all duration-500',
typeof message.widths[0] === 'string' && message.widths[0] === FLOW_TYPING_TEXT
? 'opacity-100 translate-y-0'
: 'opacity-80 translate-y-0'
)}
>
{message.widths[0]}
</div>
)
}
return (
<HistoryBubble
key={message.id}
widths={message.widths}
exiting={exitingIds.includes(message.id)}
collapse={false}
/>
)
})}
</div>
</div>
<div className="bg-main-view-fg/10 border border-main-view-fg/15 h-9 px-4 rounded-md flex items-center text-xs text-main-view-fg/60 transition-all duration-500">
{step === 1 ? (
<div className="flex items-center gap-1 text-main-view-fg/80 font-medium truncate">
<span className="truncate">{typedText}</span>
<span className="inline-block w-[2px] h-4 bg-main-view-fg/60 animate-pulse" />
</div>
) : (
<span className="line-clamp-1">{placeholder}</span>
)}
</div>
</div>
)
}
function HistoryContainer({ children }: { children: ReactNode }) {
return <>{children}</>
}
function HistoryBubble({
widths,
exiting,
collapse = true,
}: {
widths: readonly string[]
exiting: boolean
collapse?: boolean
}) {
return (
<div
className={cn(
'overflow-hidden rounded-md border border-main-view-fg/10 bg-main-view-fg/10 p-3 space-y-2 transition-all duration-500 ease-in-out',
exiting &&
(collapse
? 'max-h-0 opacity-0 -translate-y-3 p-0 border-transparent'
: 'opacity-0')
)}
>
{widths.map((width, idx) => (
<div
key={idx}
className="h-2 rounded-full bg-main-view-fg/15"
style={{ width }}
/>
))}
</div>
)
}
function UserBubble({ text, visible }: { text: string; visible: boolean }) {
return (
<div
className={cn(
'overflow-hidden rounded-md border border-main-view-fg/20 bg-main-view px-3 text-xs font-medium text-main-view-fg shadow-sm transition-all duration-500 ease-out',
visible
? 'opacity-100 translate-y-0 max-h-[64px] py-2'
: 'pointer-events-none opacity-0 -translate-y-3 max-h-0 py-0 border-transparent'
)}
>
{text}
</div>
)
}
function AssistantStreamBubble({
progress,
state,
}: {
progress: number
state: 'idle' | 'streaming' | 'complete'
}) {
const isVisible = state !== 'idle'
const isComplete = state === 'complete'
return (
<div
className={cn(
'overflow-hidden rounded-md border border-main-view-fg/10 bg-main-view-fg/10 p-3 space-y-2 transition-all duration-500 ease-out',
isVisible
? 'opacity-100 translate-y-0 max-h-[120px]'
: 'pointer-events-none opacity-0 -translate-y-3 max-h-0 p-0 border-transparent'
)}
>
<div className="h-2 rounded-full bg-main-view-fg/25 overflow-hidden">
<div
className="h-full rounded-full bg-main-view-fg/60 transition-all duration-200 ease-out"
style={{ width: `${isComplete ? 100 : Math.min(progress, 100)}%` }}
/>
</div>
<div className="flex gap-2">
<div className="h-2 w-20 rounded-full bg-main-view-fg/20" />
<div className="h-2 w-16 rounded-full bg-main-view-fg/15" />
</div>
<div className="h-2 w-32 rounded-full bg-main-view-fg/15" />
</div>
)
}

View File

@ -110,7 +110,7 @@ describe('SettingsMenu', () => {
render(<SettingsMenu />) render(<SettingsMenu />)
expect(screen.getByText('common:general')).toBeInTheDocument() 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:privacy')).toBeInTheDocument()
expect(screen.getByText('common:modelProviders')).toBeInTheDocument() expect(screen.getByText('common:modelProviders')).toBeInTheDocument()
// Platform-specific features tested separately // Platform-specific features tested separately

View File

@ -40,6 +40,7 @@ describe('useGeneralSetting', () => {
useGeneralSetting.setState({ useGeneralSetting.setState({
currentLanguage: 'en', currentLanguage: 'en',
spellCheckChatInput: true, spellCheckChatInput: true,
tokenCounterCompact: true,
huggingfaceToken: undefined, huggingfaceToken: undefined,
}) })

View File

@ -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 { renderHook, act } from '@testing-library/react'
import { useAppearance } from '../useAppearance' import { useInterfaceSettings } from '../useInterfaceSettings'
import { THREAD_SCROLL_BEHAVIOR } from '@/constants/threadScroll'
// Mock constants // Mock constants
vi.mock('@/constants/localStorage', () => ({ vi.mock('@/constants/localStorage', () => ({
localStorageKey: { 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_TAURI', { value: false, writable: true })
Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true }) Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true })
describe('useAppearance', () => { describe('useInterfaceSettings', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('should initialize with default values', () => { it('should initialize with default values', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
expect(result.current.fontSize).toBe('15px') expect(result.current.fontSize).toBe('15px')
expect(result.current.chatWidth).toBe('compact') expect(result.current.chatWidth).toBe('compact')
@ -51,10 +52,12 @@ describe('useAppearance', () => {
b: 25, b: 25,
a: 1, a: 1,
}) })
expect(result.current.threadScrollBehavior).toBe(THREAD_SCROLL_BEHAVIOR.FLOW)
expect(typeof result.current.setThreadScrollBehavior).toBe('function')
}) })
it('should update font size', () => { it('should update font size', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
act(() => { act(() => {
result.current.setFontSize('18px') result.current.setFontSize('18px')
@ -64,7 +67,7 @@ describe('useAppearance', () => {
}) })
it('should update chat width', () => { it('should update chat width', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
act(() => { act(() => {
result.current.setChatWidth('full') result.current.setChatWidth('full')
@ -73,8 +76,34 @@ describe('useAppearance', () => {
expect(result.current.chatWidth).toBe('full') 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', () => { it('should update app background color', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const newColor = { r: 100, g: 100, b: 100, a: 1 } const newColor = { r: 100, g: 100, b: 100, a: 1 }
act(() => { act(() => {
@ -85,7 +114,7 @@ describe('useAppearance', () => {
}) })
it('should update main view background color', () => { 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 } const newColor = { r: 200, g: 200, b: 200, a: 1 }
act(() => { act(() => {
@ -96,7 +125,7 @@ describe('useAppearance', () => {
}) })
it('should update primary background color', () => { it('should update primary background color', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const newColor = { r: 50, g: 100, b: 150, a: 1 } const newColor = { r: 50, g: 100, b: 150, a: 1 }
act(() => { act(() => {
@ -107,7 +136,7 @@ describe('useAppearance', () => {
}) })
it('should update accent background color', () => { it('should update accent background color', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const newColor = { r: 255, g: 100, b: 50, a: 1 } const newColor = { r: 255, g: 100, b: 50, a: 1 }
act(() => { act(() => {
@ -118,7 +147,7 @@ describe('useAppearance', () => {
}) })
it('should update destructive background color', () => { it('should update destructive background color', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const newColor = { r: 255, g: 0, b: 0, a: 1 } const newColor = { r: 255, g: 0, b: 0, a: 1 }
act(() => { act(() => {
@ -128,8 +157,8 @@ describe('useAppearance', () => {
expect(result.current.appDestructiveBgColor).toEqual(newColor) expect(result.current.appDestructiveBgColor).toEqual(newColor)
}) })
it('should reset appearance to defaults', () => { it('should reset interface settings to defaults', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
// Change some values first // Change some values first
act(() => { act(() => {
@ -140,11 +169,11 @@ describe('useAppearance', () => {
// Reset // Reset
act(() => { act(() => {
result.current.resetAppearance() result.current.resetInterface()
}) })
expect(result.current.fontSize).toBe('15px') 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.chatWidth).toBe('full')
expect(result.current.appBgColor).toEqual({ expect(result.current.appBgColor).toEqual({
r: 255, r: 255,
@ -160,14 +189,14 @@ describe('useAppearance', () => {
Object.defineProperty(global, 'IS_WEB_APP', { value: false }) Object.defineProperty(global, 'IS_WEB_APP', { value: false })
Object.defineProperty(global, 'IS_WINDOWS', { value: true }) Object.defineProperty(global, 'IS_WINDOWS', { value: true })
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
expect(result.current.appBgColor.a).toBe(1) expect(result.current.appBgColor.a).toBe(1)
}) })
}) })
describe('Reset appearance functionality', () => { describe('Reset interface functionality', () => {
beforeEach(() => { beforeEach(() => {
// Mock document.documentElement.style.setProperty // Mock document.documentElement.style.setProperty
Object.defineProperty(document.documentElement, 'style', { Object.defineProperty(document.documentElement, 'style', {
@ -178,11 +207,11 @@ describe('useAppearance', () => {
}) })
}) })
it('should reset CSS variables when resetAppearance is called', () => { it('should reset CSS variables when resetInterface is called', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
act(() => { act(() => {
result.current.resetAppearance() result.current.resetInterface()
}) })
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith( expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
@ -212,7 +241,7 @@ describe('useAppearance', () => {
writable: true, writable: true,
}) })
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const testColor = { r: 128, g: 64, b: 192, a: 0.8 } const testColor = { r: 128, g: 64, b: 192, a: 0.8 }
act(() => { act(() => {
@ -224,7 +253,7 @@ describe('useAppearance', () => {
}) })
it('should handle transparent colors', () => { it('should handle transparent colors', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const transparentColor = { r: 100, g: 100, b: 100, a: 0 } const transparentColor = { r: 100, g: 100, b: 100, a: 0 }
act(() => { act(() => {
@ -249,7 +278,7 @@ describe('useAppearance', () => {
writable: true, writable: true,
}) })
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
const testColor = { r: 128, g: 64, b: 192, a: 0.5 } const testColor = { r: 128, g: 64, b: 192, a: 0.5 }
act(() => { act(() => {
@ -267,7 +296,7 @@ describe('useAppearance', () => {
describe('Edge cases', () => { describe('Edge cases', () => {
it('should handle invalid color values gracefully', () => { 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 } const invalidColor = { r: -10, g: 300, b: 128, a: 2 }
act(() => { act(() => {
@ -280,7 +309,7 @@ describe('useAppearance', () => {
describe('Type checking', () => { describe('Type checking', () => {
it('should only accept valid font sizes', () => { it('should only accept valid font sizes', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
// These should work // These should work
act(() => { act(() => {
@ -305,7 +334,7 @@ describe('useAppearance', () => {
}) })
it('should only accept valid chat widths', () => { it('should only accept valid chat widths', () => {
const { result } = renderHook(() => useAppearance()) const { result } = renderHook(() => useInterfaceSettings())
act(() => { act(() => {
result.current.setChatWidth('full') result.current.setChatWidth('full')

View File

@ -2,8 +2,7 @@ import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware' import { persist, createJSONStorage } from 'zustand/middleware'
import { localStorageKey } from '@/constants/localStorage' import { localStorageKey } from '@/constants/localStorage'
import { ExtensionManager } from '@/lib/extension' import { ExtensionManager } from '@/lib/extension'
type GeneralSettingState = {
type LeftPanelStoreState = {
currentLanguage: Language currentLanguage: Language
spellCheckChatInput: boolean spellCheckChatInput: boolean
tokenCounterCompact: boolean tokenCounterCompact: boolean
@ -14,7 +13,7 @@ type LeftPanelStoreState = {
setCurrentLanguage: (value: Language) => void setCurrentLanguage: (value: Language) => void
} }
export const useGeneralSetting = create<LeftPanelStoreState>()( export const useGeneralSetting = create<GeneralSettingState>()(
persist( persist(
(set) => ({ (set) => ({
currentLanguage: 'en', currentLanguage: 'en',
@ -50,3 +49,5 @@ export const useGeneralSetting = create<LeftPanelStoreState>()(
} }
) )
) )

View File

@ -7,11 +7,17 @@ import { useTheme } from './useTheme'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getServiceHub } from '@/hooks/useServiceHub' import { getServiceHub } from '@/hooks/useServiceHub'
import { supportsBlurEffects } from '@/utils/blurSupport' 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 FontSize = '14px' | '15px' | '16px' | '18px'
export type ChatWidth = 'full' | 'compact' export type ChatWidth = 'full' | 'compact'
interface AppearanceState { interface InterfaceSettingsState {
chatWidth: ChatWidth chatWidth: ChatWidth
fontSize: FontSize fontSize: FontSize
appBgColor: RgbaColor appBgColor: RgbaColor
@ -24,6 +30,7 @@ interface AppearanceState {
appAccentTextColor: string appAccentTextColor: string
appDestructiveTextColor: string appDestructiveTextColor: string
appLeftPanelTextColor: string appLeftPanelTextColor: string
threadScrollBehavior: ThreadScrollBehavior
setChatWidth: (size: ChatWidth) => void setChatWidth: (size: ChatWidth) => void
setFontSize: (size: FontSize) => void setFontSize: (size: FontSize) => void
setAppBgColor: (color: RgbaColor) => void setAppBgColor: (color: RgbaColor) => void
@ -31,9 +38,26 @@ interface AppearanceState {
setAppPrimaryBgColor: (color: RgbaColor) => void setAppPrimaryBgColor: (color: RgbaColor) => void
setAppAccentBgColor: (color: RgbaColor) => void setAppAccentBgColor: (color: RgbaColor) => void
setAppDestructiveBgColor: (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) => const getBrightness = ({ r, g, b }: RgbaColor) =>
(r * 299 + g * 587 + b * 114) / 1000 (r * 299 + g * 587 + b * 114) / 1000
@ -62,7 +86,7 @@ const getAlphaValue = () => {
return 0.4 return 0.4
} }
// Default appearance settings // Default interface settings
const defaultFontSize: FontSize = '15px' const defaultFontSize: FontSize = '15px'
const defaultAppBgColor: RgbaColor = { const defaultAppBgColor: RgbaColor = {
r: 25, r: 25,
@ -154,6 +178,162 @@ export const getDefaultTextColor = (isDark: boolean): string => {
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor 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<string, unknown>
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<string, unknown>
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<InterfaceSettingsState>(() => ({
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 // Hook to check if alpha slider should be shown
export const useBlurSupport = () => { export const useBlurSupport = () => {
const [supportsBlur, setSupportsBlur] = useState( const [supportsBlur, setSupportsBlur] = useState(
@ -207,24 +387,13 @@ export const useBlurSupport = () => {
return IS_TAURI && (IS_MACOS || supportsBlur) return IS_TAURI && (IS_MACOS || supportsBlur)
} }
export const useAppearance = create<AppearanceState>()( export const useInterfaceSettings = create<InterfaceSettingsState>()(
persist( persist(
(set) => { (set) => {
const defaultState = createDefaultInterfaceValues()
return { return {
chatWidth: 'compact', ...defaultState,
fontSize: defaultFontSize, resetInterface: () => {
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: () => {
const { isDark } = useTheme.getState() const { isDark } = useTheme.getState()
// Reset font size // Reset font size
@ -341,20 +510,28 @@ export const useAppearance = create<AppearanceState>()(
) )
// Update state // Update state
set({ set({
fontSize: defaultFontSize, fontSize: defaultFontSize,
appBgColor: defaultBg, appBgColor: defaultBg,
appMainViewBgColor: defaultMainView, appMainViewBgColor: defaultMainView,
appPrimaryBgColor: defaultPrimary, appPrimaryBgColor: defaultPrimary,
appAccentBgColor: defaultAccent, appAccentBgColor: defaultAccent,
appLeftPanelTextColor: defaultTextColor, appLeftPanelTextColor: defaultTextColor,
appMainViewTextColor: defaultTextColor, appMainViewTextColor: defaultTextColor,
appPrimaryTextColor: '#FFF', appPrimaryTextColor: '#FFF',
appAccentTextColor: '#FFF', appAccentTextColor: '#FFF',
appDestructiveBgColor: defaultDestructive, appDestructiveBgColor: defaultDestructive,
appDestructiveTextColor: '#FFF', appDestructiveTextColor: '#FFF',
}) threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR,
}, })
},
setThreadScrollBehavior: (value: ThreadScrollBehavior) =>
set({
threadScrollBehavior: isThreadScrollBehavior(value)
? value
: DEFAULT_THREAD_SCROLL_BEHAVIOR,
}),
setChatWidth: (value: ChatWidth) => { setChatWidth: (value: ChatWidth) => {
set({ chatWidth: value }) set({ chatWidth: value })
@ -638,8 +815,8 @@ export const useAppearance = create<AppearanceState>()(
} }
}, },
{ {
name: localStorageKey.settingAppearance, name: localStorageKey.settingInterface,
storage: createJSONStorage(() => localStorage), storage: interfaceStorage,
// Apply settings when hydrating from storage // Apply settings when hydrating from storage
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
if (state) { if (state) {
@ -653,7 +830,7 @@ export const useAppearance = create<AppearanceState>()(
const { isDark } = useTheme.getState() const { isDark } = useTheme.getState()
// Just use the stored color as-is during rehydration // 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 const finalColor = state.appBgColor
let finalColorMainView = state.appMainViewBgColor let finalColorMainView = state.appMainViewBgColor

View File

@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppState } from './useAppState' import { useAppState } from './useAppState'
import { useMessages } from './useMessages' 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 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 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 [hasScrollbar, setHasScrollbar] = useState(false)
const lastScrollTopRef = useRef(0) const lastScrollTopRef = useRef(0)
const lastAssistantMessageRef = useRef<HTMLElement | null>(null) const lastAssistantMessageRef = useRef<HTMLElement | null>(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 messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0)
const lastMessageRole = useMessages((state) => { const lastMessageRole = useMessages((state) => {
@ -43,7 +52,8 @@ export const useThreadScrolling = (
}, [scrollContainerRef]) }, [scrollContainerRef])
const showScrollToBottomBtn = !isAtBottom && hasScrollbar const showScrollToBottomBtn =
!isAtBottom && hasScrollbar && !(isStickyScroll && isStickyScrollLocked)
const scrollToBottom = useCallback((smooth = false) => { const scrollToBottom = useCallback((smooth = false) => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@ -103,9 +113,49 @@ export const useThreadScrolling = (
} }
}, [checkScrollState, scrollToBottom]) }, [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) const prevCountRef = useRef(messageCount)
useEffect(() => { useEffect(() => {
if (!isFlowScroll) {
prevCountRef.current = messageCount
return
}
const prevCount = prevCountRef.current const prevCount = prevCountRef.current
const becameLonger = messageCount > prevCount const becameLonger = messageCount > prevCount
const isUserMessage = lastMessageRole === 'user' const isUserMessage = lastMessageRole === 'user'
@ -146,12 +196,17 @@ export const useThreadScrolling = (
} }
prevCountRef.current = messageCount prevCountRef.current = messageCount
}, [messageCount, lastMessageRole]) }, [isFlowScroll, lastMessageRole, messageCount])
useEffect(() => { useEffect(() => {
const previouslyStreaming = wasStreamingRef.current const previouslyStreaming = wasStreamingRef.current
const currentlyStreaming = !!streamingContent && streamingContent.thread_id === threadId const currentlyStreaming = !!streamingContent && streamingContent.thread_id === threadId
if (!isFlowScroll) {
wasStreamingRef.current = currentlyStreaming
return
}
const streamingStarted = !previouslyStreaming && currentlyStreaming const streamingStarted = !previouslyStreaming && currentlyStreaming
const streamingEnded = previouslyStreaming && !currentlyStreaming const streamingEnded = previouslyStreaming && !currentlyStreaming
const hasPaddingToAdjust = originalPaddingRef.current > 0 const hasPaddingToAdjust = originalPaddingRef.current > 0
@ -197,7 +252,31 @@ export const useThreadScrolling = (
} }
wasStreamingRef.current = currentlyStreaming 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(() => { useEffect(() => {
userIntendedPositionRef.current = null userIntendedPositionRef.current = null
@ -207,14 +286,18 @@ export const useThreadScrolling = (
prevCountRef.current = messageCount prevCountRef.current = messageCount
scrollToBottom(false) scrollToBottom(false)
checkScrollState() checkScrollState()
stickyScrollStreamingRef.current = false
setIsStickyScrollLocked(false)
// Only reset when switching threads; keep deps limited intentionally.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId]) }, [threadId])
return useMemo( return useMemo(
() => ({ () => ({
showScrollToBottomBtn, showScrollToBottomBtn,
scrollToBottom, scrollToBottom,
paddingHeight paddingHeight,
}), }),
[showScrollToBottomBtn, scrollToBottom, paddingHeight] [paddingHeight, scrollToBottom, showScrollToBottomBtn]
) )
} }

View File

@ -8,7 +8,7 @@
"general": "Allgemein", "general": "Allgemein",
"settings": "Einstellungen", "settings": "Einstellungen",
"modelProviders": "Modell Anbieter", "modelProviders": "Modell Anbieter",
"appearance": "Erscheinung", "interface": "Erscheinung",
"privacy": "Privatsphäre", "privacy": "Privatsphäre",
"keyboardShortcuts": "Shortcuts", "keyboardShortcuts": "Shortcuts",
"newChat": "Neuer Chat", "newChat": "Neuer Chat",

View File

@ -78,7 +78,7 @@
"goToSettings": "Gehe zu den Einstellungen", "goToSettings": "Gehe zu den Einstellungen",
"goToSettingsDesc": "Einstellungen öffnen." "goToSettingsDesc": "Einstellungen öffnen."
}, },
"appearance": { "interface": {
"title": "Erscheinungsbild", "title": "Erscheinungsbild",
"theme": "Theme", "theme": "Theme",
"themeDesc": "Dem Betriebssystem anpassen.", "themeDesc": "Dem Betriebssystem anpassen.",
@ -96,8 +96,8 @@
"destructiveDesc": "Lege die Farbe für destruktive Aktionen fest.", "destructiveDesc": "Lege die Farbe für destruktive Aktionen fest.",
"resetToDefault": "Auf Werkseinstellungen zurücksetzen", "resetToDefault": "Auf Werkseinstellungen zurücksetzen",
"resetToDefaultDesc": "Setzt alle Darstellungseinstellungen auf die Standardeinstellungen zurück.", "resetToDefaultDesc": "Setzt alle Darstellungseinstellungen auf die Standardeinstellungen zurück.",
"resetAppearanceSuccess": "Erscheinungsbild erfolgreich zurückgesetzt", "resetInterfaceSuccess": "Erscheinungsbild erfolgreich zurückgesetzt",
"resetAppearanceSuccessDesc": "Alle Darstellungseinstellungen wurden auf die Standardeinstellungen zurückgesetzt.", "resetInterfaceSuccessDesc": "Alle Darstellungseinstellungen wurden auf die Standardeinstellungen zurückgesetzt.",
"chatWidth": "Chat Breite", "chatWidth": "Chat Breite",
"chatWidthDesc": "Passe die Breite der Chatansicht an.", "chatWidthDesc": "Passe die Breite der Chatansicht an.",
"tokenCounterCompact": "Kompakter Token-Zähler", "tokenCounterCompact": "Kompakter Token-Zähler",

View File

@ -9,7 +9,7 @@
"general": "General", "general": "General",
"settings": "Settings", "settings": "Settings",
"modelProviders": "Model Providers", "modelProviders": "Model Providers",
"appearance": "Appearance", "interface": "Interface",
"privacy": "Privacy", "privacy": "Privacy",
"keyboardShortcuts": "Shortcuts", "keyboardShortcuts": "Shortcuts",
"newChat": "New Chat", "newChat": "New Chat",

View File

@ -78,8 +78,8 @@
"goToSettings": "Go to Settings", "goToSettings": "Go to Settings",
"goToSettingsDesc": "Open settings." "goToSettingsDesc": "Open settings."
}, },
"appearance": { "interface": {
"title": "Appearance", "title": "Interface",
"theme": "Theme", "theme": "Theme",
"themeDesc": "Match the OS theme.", "themeDesc": "Match the OS theme.",
"fontSize": "Font Size", "fontSize": "Font Size",
@ -89,17 +89,25 @@
"appMainView": "App Main View", "appMainView": "App Main View",
"appMainViewDesc": "Set the main content area's background color.", "appMainViewDesc": "Set the main content area's background color.",
"primary": "Primary", "primary": "Primary",
"primaryDesc": "Set the primary color for UI components.", "primaryDesc": "Set the primary color for interface accents.",
"accent": "Accent", "accent": "Accent",
"accentDesc": "Set the accent color for UI highlights.", "accentDesc": "Select the accent color used across the UI.",
"destructive": "Destructive", "destructive": "Destructive",
"destructiveDesc": "Set the color for destructive actions.", "destructiveDesc": "Set the highlight color for destructive actions.",
"resetToDefault": "Reset to Default", "resetToDefault": "Reset to Default",
"resetToDefaultDesc": "Reset all appearance settings to default.", "resetToDefaultDesc": "Reset all interface settings to default.",
"resetAppearanceSuccess": "Appearance reset successfully", "resetInterfaceSuccess": "Interface reset successfully",
"resetAppearanceSuccessDesc": "All appearance settings have been restored to default.", "resetInterfaceSuccessDesc": "All interface settings have been restored to default.",
"chatWidth": "Chat Width", "chatWidth": "Chat Layout Width",
"chatWidthDesc": "Customize the width of the chat view.", "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", "tokenCounterCompact": "Compact Token Counter",
"tokenCounterCompactDesc": "Show token counter inside chat input. When disabled, token counter appears below the input.", "tokenCounterCompactDesc": "Show token counter inside chat input. When disabled, token counter appears below the input.",
"codeBlockTitle": "Code Block", "codeBlockTitle": "Code Block",

View File

@ -8,7 +8,7 @@
"general": "Umum", "general": "Umum",
"settings": "Pengaturan", "settings": "Pengaturan",
"modelProviders": "Penyedia Model", "modelProviders": "Penyedia Model",
"appearance": "Tampilan", "interface": "Tampilan",
"privacy": "Privasi", "privacy": "Privasi",
"keyboardShortcuts": "Pintasan", "keyboardShortcuts": "Pintasan",
"newChat": "Obrolan Baru", "newChat": "Obrolan Baru",

View File

@ -78,7 +78,7 @@
"goToSettings": "Buka Pengaturan", "goToSettings": "Buka Pengaturan",
"goToSettingsDesc": "Buka pengaturan." "goToSettingsDesc": "Buka pengaturan."
}, },
"appearance": { "interface": {
"title": "Tampilan", "title": "Tampilan",
"theme": "Tema", "theme": "Tema",
"themeDesc": "Sesuaikan dengan tema OS.", "themeDesc": "Sesuaikan dengan tema OS.",
@ -96,8 +96,8 @@
"destructiveDesc": "Atur warna untuk tindakan yang merusak.", "destructiveDesc": "Atur warna untuk tindakan yang merusak.",
"resetToDefault": "Setel Ulang ke Default", "resetToDefault": "Setel Ulang ke Default",
"resetToDefaultDesc": "Setel ulang semua pengaturan tampilan ke default.", "resetToDefaultDesc": "Setel ulang semua pengaturan tampilan ke default.",
"resetAppearanceSuccess": "Tampilan berhasil diatur ulang", "resetInterfaceSuccess": "Tampilan berhasil diatur ulang",
"resetAppearanceSuccessDesc": "Semua pengaturan tampilan telah dikembalikan ke default.", "resetInterfaceSuccessDesc": "Semua pengaturan tampilan telah dikembalikan ke default.",
"chatWidth": "Lebar Obrolan", "chatWidth": "Lebar Obrolan",
"chatWidthDesc": "Sesuaikan lebar tampilan obrolan.", "chatWidthDesc": "Sesuaikan lebar tampilan obrolan.",
"codeBlockTitle": "Blok Kode", "codeBlockTitle": "Blok Kode",

View File

@ -8,7 +8,7 @@
"general": "全般", "general": "全般",
"settings": "設定", "settings": "設定",
"modelProviders": "モデルプロバイダー", "modelProviders": "モデルプロバイダー",
"appearance": "外観", "interface": "外観",
"privacy": "プライバシー", "privacy": "プライバシー",
"keyboardShortcuts": "ショートカット", "keyboardShortcuts": "ショートカット",
"newChat": "新しいチャット", "newChat": "新しいチャット",
@ -364,4 +364,4 @@
"description": "スレッドは「{{projectName}}」から正常に削除されました" "description": "スレッドは「{{projectName}}」から正常に削除されました"
} }
} }
} }

View File

@ -78,7 +78,7 @@
"goToSettings": "設定に移動", "goToSettings": "設定に移動",
"goToSettingsDesc": "設定を開きます。" "goToSettingsDesc": "設定を開きます。"
}, },
"appearance": { "interface": {
"title": "外観", "title": "外観",
"theme": "テーマ", "theme": "テーマ",
"themeDesc": "OSのテーマに合わせます。", "themeDesc": "OSのテーマに合わせます。",
@ -96,8 +96,8 @@
"destructiveDesc": "破壊的なアクションの色を設定します。", "destructiveDesc": "破壊的なアクションの色を設定します。",
"resetToDefault": "デフォルトにリセット", "resetToDefault": "デフォルトにリセット",
"resetToDefaultDesc": "すべての外観設定をデフォルトにリセットします。", "resetToDefaultDesc": "すべての外観設定をデフォルトにリセットします。",
"resetAppearanceSuccess": "外観が正常にリセットされました", "resetInterfaceSuccess": "外観が正常にリセットされました",
"resetAppearanceSuccessDesc": "すべての外観設定がデフォルトに復元されました。", "resetInterfaceSuccessDesc": "すべての外観設定がデフォルトに復元されました。",
"chatWidth": "チャットの幅", "chatWidth": "チャットの幅",
"chatWidthDesc": "チャットビューの幅をカスタマイズします。", "chatWidthDesc": "チャットビューの幅をカスタマイズします。",
"tokenCounterCompact": "コンパクトなトークンカウンター", "tokenCounterCompact": "コンパクトなトークンカウンター",
@ -274,4 +274,4 @@
}, },
"backendInstallSuccess": "バックエンドは正常にインストールされました", "backendInstallSuccess": "バックエンドは正常にインストールされました",
"backendInstallError": "バックエンドのインストールに失敗しました" "backendInstallError": "バックエンドのインストールに失敗しました"
} }

View File

@ -8,7 +8,7 @@
"general": "Ogólne", "general": "Ogólne",
"settings": "Ustawienia", "settings": "Ustawienia",
"modelProviders": "Dostawcy Modeli", "modelProviders": "Dostawcy Modeli",
"appearance": "Wygląd", "interface": "Wygląd",
"privacy": "Prywatność", "privacy": "Prywatność",
"keyboardShortcuts": "Skróty", "keyboardShortcuts": "Skróty",
"newChat": "Nowy Czat", "newChat": "Nowy Czat",

View File

@ -78,7 +78,7 @@
"goToSettings": "Przejdź do Ustawień", "goToSettings": "Przejdź do Ustawień",
"goToSettingsDesc": "Otwórz ustawienia." "goToSettingsDesc": "Otwórz ustawienia."
}, },
"appearance": { "interface": {
"title": "Wygląd", "title": "Wygląd",
"theme": "Schemat Kolorystyczny", "theme": "Schemat Kolorystyczny",
"themeDesc": "Dopasuj do schematu systemowego.", "themeDesc": "Dopasuj do schematu systemowego.",
@ -96,8 +96,8 @@
"destructiveDesc": "Ustaw kolor operacji destrukcyjnych.", "destructiveDesc": "Ustaw kolor operacji destrukcyjnych.",
"resetToDefault": "Przywróć Domyślne", "resetToDefault": "Przywróć Domyślne",
"resetToDefaultDesc": "Przywróć domyślne ustawienia wyglądu.", "resetToDefaultDesc": "Przywróć domyślne ustawienia wyglądu.",
"resetAppearanceSuccess": "Pomyślnie Przywrócono Ustawienia Wyglądu", "resetInterfaceSuccess": "Pomyślnie Przywrócono Ustawienia Wyglądu",
"resetAppearanceSuccessDesc": "Wszystkie ustawienia wyglądu zostały przywrócone do wartości domyślnych.", "resetInterfaceSuccessDesc": "Wszystkie ustawienia wyglądu zostały przywrócone do wartości domyślnych.",
"chatWidth": "Szerokość Czatu", "chatWidth": "Szerokość Czatu",
"chatWidthDesc": "Ustaw szerokość komponentu czatu.", "chatWidthDesc": "Ustaw szerokość komponentu czatu.",
"codeBlockTitle": "Blok Kodu", "codeBlockTitle": "Blok Kodu",

View File

@ -8,7 +8,7 @@
"general": "Chung", "general": "Chung",
"settings": "Cài đặt", "settings": "Cài đặt",
"modelProviders": "Nhà cung cấp Mô hình", "modelProviders": "Nhà cung cấp Mô hình",
"appearance": "Giao diện", "interface": "Giao diện",
"privacy": "Quyền riêng tư", "privacy": "Quyền riêng tư",
"keyboardShortcuts": "Phím tắt", "keyboardShortcuts": "Phím tắt",
"newChat": "Trò chuyện Mới", "newChat": "Trò chuyện Mới",

View File

@ -78,7 +78,7 @@
"goToSettings": "Đi tới Cài đặt", "goToSettings": "Đi tới Cài đặt",
"goToSettingsDesc": "Mở cài đặt." "goToSettingsDesc": "Mở cài đặt."
}, },
"appearance": { "interface": {
"title": "Giao diện", "title": "Giao diện",
"theme": "Chủ đề", "theme": "Chủ đề",
"themeDesc": "Khớp với chủ đề hệ điều hành.", "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.", "destructiveDesc": "Đặt màu cho các hành động hủy.",
"resetToDefault": "Đặt lại về mặc định", "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.", "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", "resetInterfaceSuccess": "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.", "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", "chatWidth": "Chiều rộng trò chuyện",
"chatWidthDesc": "Tùy chỉnh chiều rộng của chế độ xem 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", "tokenCounterCompact": "Bộ đếm token nhỏ gọn",

View File

@ -8,7 +8,7 @@
"general": "通用", "general": "通用",
"settings": "设置", "settings": "设置",
"modelProviders": "模型提供商", "modelProviders": "模型提供商",
"appearance": "外观", "interface": "外观",
"privacy": "隐私", "privacy": "隐私",
"keyboardShortcuts": "快捷键", "keyboardShortcuts": "快捷键",
"newChat": "新建聊天", "newChat": "新建聊天",

View File

@ -78,7 +78,7 @@
"goToSettings": "转到设置", "goToSettings": "转到设置",
"goToSettingsDesc": "打开设置。" "goToSettingsDesc": "打开设置。"
}, },
"appearance": { "interface": {
"title": "外观", "title": "外观",
"theme": "主题", "theme": "主题",
"themeDesc": "匹配操作系统主题。", "themeDesc": "匹配操作系统主题。",
@ -96,8 +96,8 @@
"destructiveDesc": "设置警示操作的颜色。", "destructiveDesc": "设置警示操作的颜色。",
"resetToDefault": "重置为默认值", "resetToDefault": "重置为默认值",
"resetToDefaultDesc": "将所有外观设置重置为默认值。", "resetToDefaultDesc": "将所有外观设置重置为默认值。",
"resetAppearanceSuccess": "外观重置成功", "resetInterfaceSuccess": "外观重置成功",
"resetAppearanceSuccessDesc": "所有外观设置已恢复为默认值。", "resetInterfaceSuccessDesc": "所有外观设置已恢复为默认值。",
"chatWidth": "聊天宽度", "chatWidth": "聊天宽度",
"chatWidthDesc": "自定义聊天视图的宽度。", "chatWidthDesc": "自定义聊天视图的宽度。",
"tokenCounterCompact": "紧凑令牌计数器", "tokenCounterCompact": "紧凑令牌计数器",

View File

@ -8,7 +8,7 @@
"general": "一般", "general": "一般",
"settings": "設定", "settings": "設定",
"modelProviders": "模型提供者", "modelProviders": "模型提供者",
"appearance": "外觀", "interface": "外觀",
"privacy": "隱私", "privacy": "隱私",
"keyboardShortcuts": "快捷鍵", "keyboardShortcuts": "快捷鍵",
"newChat": "新聊天", "newChat": "新聊天",

View File

@ -78,7 +78,7 @@
"goToSettings": "前往設定", "goToSettings": "前往設定",
"goToSettingsDesc": "開啟設定。" "goToSettingsDesc": "開啟設定。"
}, },
"appearance": { "interface": {
"title": "外觀", "title": "外觀",
"theme": "主題", "theme": "主題",
"themeDesc": "符合作業系統主題。", "themeDesc": "符合作業系統主題。",
@ -96,8 +96,8 @@
"destructiveDesc": "設定毀滅性操作的顏色。", "destructiveDesc": "設定毀滅性操作的顏色。",
"resetToDefault": "重設為預設值", "resetToDefault": "重設為預設值",
"resetToDefaultDesc": "將所有外觀設定重設為預設值。", "resetToDefaultDesc": "將所有外觀設定重設為預設值。",
"resetAppearanceSuccess": "外觀重設成功", "resetInterfaceSuccess": "外觀重設成功",
"resetAppearanceSuccessDesc": "所有外觀設定已還原為預設值。", "resetInterfaceSuccessDesc": "所有外觀設定已還原為預設值。",
"chatWidth": "聊天寬度", "chatWidth": "聊天寬度",
"chatWidthDesc": "自訂聊天檢視的寬度。", "chatWidthDesc": "自訂聊天檢視的寬度。",
"codeBlockTitle": "程式碼區塊", "codeBlockTitle": "程式碼區塊",

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance' import { useInterfaceSettings, useBlurSupport } from '@/hooks/useInterfaceSettings'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { import {
isDefaultColor, isDefaultColor,
@ -8,13 +8,13 @@ import {
isDefaultColorPrimary, isDefaultColorPrimary,
isDefaultColorAccent, isDefaultColorAccent,
isDefaultColorDestructive, 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 * This component should be mounted at the root level of the application
*/ */
export function AppearanceProvider() { export function InterfaceProvider() {
const { const {
fontSize, fontSize,
appBgColor, appBgColor,
@ -27,11 +27,11 @@ export function AppearanceProvider() {
appAccentTextColor, appAccentTextColor,
appDestructiveBgColor, appDestructiveBgColor,
appDestructiveTextColor, appDestructiveTextColor,
} = useAppearance() } = useInterfaceSettings()
const { isDark } = useTheme() const { isDark } = useTheme()
const showAlphaSlider = useBlurSupport() 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 // This ensures that when navigating to routes (like logs), the theme is properly applied
useEffect(() => { useEffect(() => {
const { const {
@ -39,7 +39,7 @@ export function AppearanceProvider() {
setAppMainViewBgColor, setAppMainViewBgColor,
appBgColor, appBgColor,
appMainViewBgColor, appMainViewBgColor,
} = useAppearance.getState() } = useInterfaceSettings.getState()
// Re-trigger setters to ensure CSS variables are applied with correct theme // Re-trigger setters to ensure CSS variables are applied with correct theme
setAppBgColor(appBgColor) setAppBgColor(appBgColor)
@ -48,12 +48,12 @@ export function AppearanceProvider() {
// Update colors when blur support changes (important for Windows/Linux) // Update colors when blur support changes (important for Windows/Linux)
useEffect(() => { useEffect(() => {
const { setAppBgColor, appBgColor } = useAppearance.getState() const { setAppBgColor, appBgColor } = useInterfaceSettings.getState()
// Re-apply color to update alpha based on blur support // Re-apply color to update alpha based on blur support
setAppBgColor(appBgColor) setAppBgColor(appBgColor)
}, [showAlphaSlider]) }, [showAlphaSlider])
// Apply appearance settings on mount and when they change // Apply interface settings on mount and when they change
useEffect(() => { useEffect(() => {
// Apply font size // Apply font size
document.documentElement.style.setProperty('--font-size-base', fontSize) document.documentElement.style.setProperty('--font-size-base', fontSize)
@ -203,9 +203,9 @@ export function AppearanceProvider() {
showAlphaSlider, showAlphaSlider,
]) ])
// Update appearance when theme changes // Update interface styling when theme changes
useEffect(() => { useEffect(() => {
// Get the current appearance state // Get the current interface state
const { const {
appBgColor, appBgColor,
appMainViewBgColor, appMainViewBgColor,
@ -217,7 +217,7 @@ export function AppearanceProvider() {
setAppPrimaryBgColor, setAppPrimaryBgColor,
setAppAccentBgColor, setAppAccentBgColor,
setAppDestructiveBgColor, setAppDestructiveBgColor,
} = useAppearance.getState() } = useInterfaceSettings.getState()
// Force re-apply all colors when theme changes to ensure correct dark/light defaults // 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 // This is especially important on Windows where the theme might not be properly

View File

@ -22,12 +22,12 @@ import { Route as SettingsShortcutsImport } from './routes/settings/shortcuts'
import { Route as SettingsPrivacyImport } from './routes/settings/privacy' import { Route as SettingsPrivacyImport } from './routes/settings/privacy'
import { Route as SettingsMcpServersImport } from './routes/settings/mcp-servers' import { Route as SettingsMcpServersImport } from './routes/settings/mcp-servers'
import { Route as SettingsLocalApiServerImport } from './routes/settings/local-api-server' 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 SettingsHttpsProxyImport } from './routes/settings/https-proxy'
import { Route as SettingsHardwareImport } from './routes/settings/hardware' import { Route as SettingsHardwareImport } from './routes/settings/hardware'
import { Route as SettingsGeneralImport } from './routes/settings/general' import { Route as SettingsGeneralImport } from './routes/settings/general'
import { Route as SettingsExtensionsImport } from './routes/settings/extensions' import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
import { Route as SettingsAttachmentsImport } from './routes/settings/attachments' 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 ProjectProjectIdImport } from './routes/project/$projectId'
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
import { Route as HubModelIdImport } from './routes/hub/$modelId' import { Route as HubModelIdImport } from './routes/hub/$modelId'
@ -103,6 +103,12 @@ const SettingsLocalApiServerRoute = SettingsLocalApiServerImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const SettingsInterfaceRoute = SettingsInterfaceImport.update({
id: '/settings/interface',
path: '/settings/interface',
getParentRoute: () => rootRoute,
} as any)
const SettingsHttpsProxyRoute = SettingsHttpsProxyImport.update({ const SettingsHttpsProxyRoute = SettingsHttpsProxyImport.update({
id: '/settings/https-proxy', id: '/settings/https-proxy',
path: '/settings/https-proxy', path: '/settings/https-proxy',
@ -133,12 +139,6 @@ const SettingsAttachmentsRoute = SettingsAttachmentsImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const SettingsAppearanceRoute = SettingsAppearanceImport.update({
id: '/settings/appearance',
path: '/settings/appearance',
getParentRoute: () => rootRoute,
} as any)
const ProjectProjectIdRoute = ProjectProjectIdImport.update({ const ProjectProjectIdRoute = ProjectProjectIdImport.update({
id: '/project/$projectId', id: '/project/$projectId',
path: '/project/$projectId', path: '/project/$projectId',
@ -229,13 +229,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectProjectIdImport preLoaderRoute: typeof ProjectProjectIdImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/settings/appearance': {
id: '/settings/appearance'
path: '/settings/appearance'
fullPath: '/settings/appearance'
preLoaderRoute: typeof SettingsAppearanceImport
parentRoute: typeof rootRoute
}
'/settings/attachments': { '/settings/attachments': {
id: '/settings/attachments' id: '/settings/attachments'
path: '/settings/attachments' path: '/settings/attachments'
@ -271,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsHttpsProxyImport preLoaderRoute: typeof SettingsHttpsProxyImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/settings/interface': {
id: '/settings/interface'
path: '/settings/interface'
fullPath: '/settings/interface'
preLoaderRoute: typeof SettingsInterfaceImport
parentRoute: typeof rootRoute
}
'/settings/local-api-server': { '/settings/local-api-server': {
id: '/settings/local-api-server' id: '/settings/local-api-server'
path: '/settings/local-api-server' path: '/settings/local-api-server'
@ -354,12 +354,12 @@ export interface FileRoutesByFullPath {
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/attachments': typeof SettingsAttachmentsRoute '/settings/attachments': typeof SettingsAttachmentsRoute
'/settings/extensions': typeof SettingsExtensionsRoute '/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute '/settings/general': typeof SettingsGeneralRoute
'/settings/hardware': typeof SettingsHardwareRoute '/settings/hardware': typeof SettingsHardwareRoute
'/settings/https-proxy': typeof SettingsHttpsProxyRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute '/settings/privacy': typeof SettingsPrivacyRoute
@ -380,12 +380,12 @@ export interface FileRoutesByTo {
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/attachments': typeof SettingsAttachmentsRoute '/settings/attachments': typeof SettingsAttachmentsRoute
'/settings/extensions': typeof SettingsExtensionsRoute '/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute '/settings/general': typeof SettingsGeneralRoute
'/settings/hardware': typeof SettingsHardwareRoute '/settings/hardware': typeof SettingsHardwareRoute
'/settings/https-proxy': typeof SettingsHttpsProxyRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute '/settings/privacy': typeof SettingsPrivacyRoute
@ -407,12 +407,12 @@ export interface FileRoutesById {
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
'/settings/appearance': typeof SettingsAppearanceRoute
'/settings/attachments': typeof SettingsAttachmentsRoute '/settings/attachments': typeof SettingsAttachmentsRoute
'/settings/extensions': typeof SettingsExtensionsRoute '/settings/extensions': typeof SettingsExtensionsRoute
'/settings/general': typeof SettingsGeneralRoute '/settings/general': typeof SettingsGeneralRoute
'/settings/hardware': typeof SettingsHardwareRoute '/settings/hardware': typeof SettingsHardwareRoute
'/settings/https-proxy': typeof SettingsHttpsProxyRoute '/settings/https-proxy': typeof SettingsHttpsProxyRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/local-api-server': typeof SettingsLocalApiServerRoute '/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute '/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute '/settings/privacy': typeof SettingsPrivacyRoute
@ -435,12 +435,12 @@ export interface FileRouteTypes {
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
| '/settings/appearance'
| '/settings/attachments' | '/settings/attachments'
| '/settings/extensions' | '/settings/extensions'
| '/settings/general' | '/settings/general'
| '/settings/hardware' | '/settings/hardware'
| '/settings/https-proxy' | '/settings/https-proxy'
| '/settings/interface'
| '/settings/local-api-server' | '/settings/local-api-server'
| '/settings/mcp-servers' | '/settings/mcp-servers'
| '/settings/privacy' | '/settings/privacy'
@ -460,12 +460,12 @@ export interface FileRouteTypes {
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
| '/settings/appearance'
| '/settings/attachments' | '/settings/attachments'
| '/settings/extensions' | '/settings/extensions'
| '/settings/general' | '/settings/general'
| '/settings/hardware' | '/settings/hardware'
| '/settings/https-proxy' | '/settings/https-proxy'
| '/settings/interface'
| '/settings/local-api-server' | '/settings/local-api-server'
| '/settings/mcp-servers' | '/settings/mcp-servers'
| '/settings/privacy' | '/settings/privacy'
@ -485,12 +485,12 @@ export interface FileRouteTypes {
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
| '/settings/appearance'
| '/settings/attachments' | '/settings/attachments'
| '/settings/extensions' | '/settings/extensions'
| '/settings/general' | '/settings/general'
| '/settings/hardware' | '/settings/hardware'
| '/settings/https-proxy' | '/settings/https-proxy'
| '/settings/interface'
| '/settings/local-api-server' | '/settings/local-api-server'
| '/settings/mcp-servers' | '/settings/mcp-servers'
| '/settings/privacy' | '/settings/privacy'
@ -512,12 +512,12 @@ export interface RootRouteChildren {
HubModelIdRoute: typeof HubModelIdRoute HubModelIdRoute: typeof HubModelIdRoute
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
ProjectProjectIdRoute: typeof ProjectProjectIdRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute
SettingsAppearanceRoute: typeof SettingsAppearanceRoute
SettingsAttachmentsRoute: typeof SettingsAttachmentsRoute SettingsAttachmentsRoute: typeof SettingsAttachmentsRoute
SettingsExtensionsRoute: typeof SettingsExtensionsRoute SettingsExtensionsRoute: typeof SettingsExtensionsRoute
SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsGeneralRoute: typeof SettingsGeneralRoute
SettingsHardwareRoute: typeof SettingsHardwareRoute SettingsHardwareRoute: typeof SettingsHardwareRoute
SettingsHttpsProxyRoute: typeof SettingsHttpsProxyRoute SettingsHttpsProxyRoute: typeof SettingsHttpsProxyRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
SettingsLocalApiServerRoute: typeof SettingsLocalApiServerRoute SettingsLocalApiServerRoute: typeof SettingsLocalApiServerRoute
SettingsMcpServersRoute: typeof SettingsMcpServersRoute SettingsMcpServersRoute: typeof SettingsMcpServersRoute
SettingsPrivacyRoute: typeof SettingsPrivacyRoute SettingsPrivacyRoute: typeof SettingsPrivacyRoute
@ -538,12 +538,12 @@ const rootRouteChildren: RootRouteChildren = {
HubModelIdRoute: HubModelIdRoute, HubModelIdRoute: HubModelIdRoute,
LocalApiServerLogsRoute: LocalApiServerLogsRoute, LocalApiServerLogsRoute: LocalApiServerLogsRoute,
ProjectProjectIdRoute: ProjectProjectIdRoute, ProjectProjectIdRoute: ProjectProjectIdRoute,
SettingsAppearanceRoute: SettingsAppearanceRoute,
SettingsAttachmentsRoute: SettingsAttachmentsRoute, SettingsAttachmentsRoute: SettingsAttachmentsRoute,
SettingsExtensionsRoute: SettingsExtensionsRoute, SettingsExtensionsRoute: SettingsExtensionsRoute,
SettingsGeneralRoute: SettingsGeneralRoute, SettingsGeneralRoute: SettingsGeneralRoute,
SettingsHardwareRoute: SettingsHardwareRoute, SettingsHardwareRoute: SettingsHardwareRoute,
SettingsHttpsProxyRoute: SettingsHttpsProxyRoute, SettingsHttpsProxyRoute: SettingsHttpsProxyRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute,
SettingsLocalApiServerRoute: SettingsLocalApiServerRoute, SettingsLocalApiServerRoute: SettingsLocalApiServerRoute,
SettingsMcpServersRoute: SettingsMcpServersRoute, SettingsMcpServersRoute: SettingsMcpServersRoute,
SettingsPrivacyRoute: SettingsPrivacyRoute, SettingsPrivacyRoute: SettingsPrivacyRoute,
@ -573,12 +573,12 @@ export const routeTree = rootRoute
"/hub/$modelId", "/hub/$modelId",
"/local-api-server/logs", "/local-api-server/logs",
"/project/$projectId", "/project/$projectId",
"/settings/appearance",
"/settings/attachments", "/settings/attachments",
"/settings/extensions", "/settings/extensions",
"/settings/general", "/settings/general",
"/settings/hardware", "/settings/hardware",
"/settings/https-proxy", "/settings/https-proxy",
"/settings/interface",
"/settings/local-api-server", "/settings/local-api-server",
"/settings/mcp-servers", "/settings/mcp-servers",
"/settings/privacy", "/settings/privacy",
@ -612,9 +612,6 @@ export const routeTree = rootRoute
"/project/$projectId": { "/project/$projectId": {
"filePath": "project/$projectId.tsx" "filePath": "project/$projectId.tsx"
}, },
"/settings/appearance": {
"filePath": "settings/appearance.tsx"
},
"/settings/attachments": { "/settings/attachments": {
"filePath": "settings/attachments.tsx" "filePath": "settings/attachments.tsx"
}, },
@ -630,6 +627,9 @@ export const routeTree = rootRoute
"/settings/https-proxy": { "/settings/https-proxy": {
"filePath": "settings/https-proxy.tsx" "filePath": "settings/https-proxy.tsx"
}, },
"/settings/interface": {
"filePath": "settings/interface.tsx"
},
"/settings/local-api-server": { "/settings/local-api-server": {
"filePath": "settings/local-api-server.tsx" "filePath": "settings/local-api-server.tsx"
}, },

View File

@ -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 { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import LeftPanel from '@/containers/LeftPanel' import LeftPanel from '@/containers/LeftPanel'
import DialogAppUpdater from '@/containers/dialogs/AppUpdater' import DialogAppUpdater from '@/containers/dialogs/AppUpdater'
import BackendUpdater from '@/containers/dialogs/BackendUpdater' import BackendUpdater from '@/containers/dialogs/BackendUpdater'
import { Fragment } from 'react/jsx-runtime' import { Fragment } from 'react/jsx-runtime'
import { AppearanceProvider } from '@/providers/AppearanceProvider' import { InterfaceProvider } from '@/providers/InterfaceProvider'
import { ThemeProvider } from '@/providers/ThemeProvider' import { ThemeProvider } from '@/providers/ThemeProvider'
import { KeyboardShortcutsProvider } from '@/providers/KeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/providers/KeyboardShortcuts'
import { DataProvider } from '@/providers/DataProvider' import { DataProvider } from '@/providers/DataProvider'
@ -212,7 +212,7 @@ function RootLayout() {
<Fragment> <Fragment>
<ServiceHubProvider> <ServiceHubProvider>
<ThemeProvider /> <ThemeProvider />
<AppearanceProvider /> <InterfaceProvider />
<ToasterProvider /> <ToasterProvider />
<TranslationProvider> <TranslationProvider>
<ExtensionProvider> <ExtensionProvider>

View File

@ -1,4 +1,4 @@
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useThreadManagement } from '@/hooks/useThreadManagement' import { useThreadManagement } from '@/hooks/useThreadManagement'
@ -15,7 +15,7 @@ import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform/types' import { PlatformFeature } from '@/lib/platform/types'
import { IconMessage } from '@tabler/icons-react' import { IconMessage } from '@tabler/icons-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAppearance } from '@/hooks/useAppearance' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
export const Route = createFileRoute('/project/$projectId')({ export const Route = createFileRoute('/project/$projectId')({
@ -36,7 +36,7 @@ function ProjectPageContent() {
const { getFolderById } = useThreadManagement() const { getFolderById } = useThreadManagement()
const threads = useThreads((state) => state.threads) const threads = useThreads((state) => state.threads)
const chatWidth = useAppearance((state) => state.chatWidth) const chatWidth = useInterfaceSettings((state) => state.chatWidth)
const isSmallScreen = useSmallScreen() const isSmallScreen = useSmallScreen()
// Find the project // Find the project

View File

@ -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 { render, screen, fireEvent } from '@testing-library/react'
import { Route as AppearanceRoute } from '../appearance' import { Route as InterfaceRoute } from '../interface'
// Mock all the dependencies // Mock all the dependencies
vi.mock('@/containers/SettingsMenu', () => ({ vi.mock('@/containers/SettingsMenu', () => ({
@ -61,6 +61,12 @@ vi.mock('@/containers/ChatWidthSwitcher', () => ({
ChatWidthSwitcher: () => <div data-testid="chat-width-switcher">Chat Width Switcher</div>, ChatWidthSwitcher: () => <div data-testid="chat-width-switcher">Chat Width Switcher</div>,
})) }))
vi.mock('@/containers/ThreadScrollBehaviorSwitcher', () => ({
ThreadScrollBehaviorSwitcher: () => (
<div data-testid="thread-scroll-switcher">Thread Scroll Switcher</div>
),
}))
vi.mock('@/containers/CodeBlockStyleSwitcher', () => ({ vi.mock('@/containers/CodeBlockStyleSwitcher', () => ({
default: () => <div data-testid="code-block-style-switcher">Code Block Style Switcher</div>, default: () => <div data-testid="code-block-style-switcher">Code Block Style Switcher</div>,
})) }))
@ -73,9 +79,9 @@ vi.mock('@/containers/CodeBlockExample', () => ({
CodeBlockExample: () => <div data-testid="code-block-example">Code Block Example</div>, CodeBlockExample: () => <div data-testid="code-block-example">Code Block Example</div>,
})) }))
vi.mock('@/hooks/useAppearance', () => ({ vi.mock('@/hooks/useInterfaceSettings', () => ({
useAppearance: () => ({ useInterfaceSettings: () => ({
resetAppearance: vi.fn(), resetInterface: vi.fn(),
}), }),
})) }))
@ -108,7 +114,7 @@ vi.mock('sonner', () => ({
vi.mock('@/constants/routes', () => ({ vi.mock('@/constants/routes', () => ({
route: { route: {
settings: { 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(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('should render the appearance settings page', () => { it('should render the interface settings page', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
expect(screen.getByTestId('header-page')).toBeInTheDocument() expect(screen.getByTestId('header-page')).toBeInTheDocument()
@ -134,8 +140,8 @@ describe('Appearance Settings Route', () => {
expect(screen.getByText('common:settings')).toBeInTheDocument() expect(screen.getByText('common:settings')).toBeInTheDocument()
}) })
it('should render appearance controls', () => { it('should render interface controls', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
expect(screen.getByTestId('theme-switcher')).toBeInTheDocument() expect(screen.getByTestId('theme-switcher')).toBeInTheDocument()
@ -148,14 +154,15 @@ describe('Appearance Settings Route', () => {
}) })
it('should render chat width controls', () => { it('should render chat width controls', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
expect(screen.getByTestId('chat-width-switcher')).toBeInTheDocument() expect(screen.getByTestId('chat-width-switcher')).toBeInTheDocument()
expect(screen.getByTestId('thread-scroll-switcher')).toBeInTheDocument()
}) })
it('should render code block controls', () => { it('should render code block controls', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
expect(screen.getByTestId('code-block-style-switcher')).toBeInTheDocument() expect(screen.getByTestId('code-block-style-switcher')).toBeInTheDocument()
@ -163,8 +170,8 @@ describe('Appearance Settings Route', () => {
expect(screen.getByTestId('line-numbers-switcher')).toBeInTheDocument() expect(screen.getByTestId('line-numbers-switcher')).toBeInTheDocument()
}) })
it('should render reset appearance button', () => { it('should render reset interface button', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const resetButtons = screen.getAllByTestId('button') const resetButtons = screen.getAllByTestId('button')
@ -172,7 +179,7 @@ describe('Appearance Settings Route', () => {
}) })
it('should render reset buttons', () => { it('should render reset buttons', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const resetButtons = screen.getAllByTestId('button') const resetButtons = screen.getAllByTestId('button')
@ -185,7 +192,7 @@ describe('Appearance Settings Route', () => {
}) })
it('should render reset functionality', () => { it('should render reset functionality', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const resetButtons = screen.getAllByTestId('button') const resetButtons = screen.getAllByTestId('button')
@ -199,7 +206,7 @@ describe('Appearance Settings Route', () => {
}) })
it('should render all card items with proper structure', () => { it('should render all card items with proper structure', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const cardItems = screen.getAllByTestId('card-item') const cardItems = screen.getAllByTestId('card-item')
@ -211,7 +218,7 @@ describe('Appearance Settings Route', () => {
}) })
it('should have proper responsive layout classes', () => { it('should have proper responsive layout classes', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const cardItems = screen.getAllByTestId('card-item') const cardItems = screen.getAllByTestId('card-item')
@ -226,7 +233,7 @@ describe('Appearance Settings Route', () => {
}) })
it('should render main layout structure', () => { it('should render main layout structure', () => {
const Component = AppearanceRoute.component as React.ComponentType const Component = InterfaceRoute.component as React.ComponentType
render(<Component />) render(<Component />)
const headerPage = screen.getByTestId('header-page') const headerPage = screen.getByTestId('header-page')

View File

@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import SettingsMenu from '@/containers/SettingsMenu' import SettingsMenu from '@/containers/SettingsMenu'
import HeaderPage from '@/containers/HeaderPage' import HeaderPage from '@/containers/HeaderPage'
@ -11,7 +11,7 @@ import { FontSizeSwitcher } from '@/containers/FontSizeSwitcher'
import { ColorPickerAppPrimaryColor } from '@/containers/ColorPickerAppPrimaryColor' import { ColorPickerAppPrimaryColor } from '@/containers/ColorPickerAppPrimaryColor'
import { ColorPickerAppAccentColor } from '@/containers/ColorPickerAppAccentColor' import { ColorPickerAppAccentColor } from '@/containers/ColorPickerAppAccentColor'
import { ColorPickerAppDestructiveColor } from '@/containers/ColorPickerAppDestructiveColor' import { ColorPickerAppDestructiveColor } from '@/containers/ColorPickerAppDestructiveColor'
import { useAppearance } from '@/hooks/useAppearance' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { useCodeblock } from '@/hooks/useCodeblock' import { useCodeblock } from '@/hooks/useCodeblock'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CodeBlockStyleSwitcher from '@/containers/CodeBlockStyleSwitcher' import CodeBlockStyleSwitcher from '@/containers/CodeBlockStyleSwitcher'
@ -20,15 +20,16 @@ import { CodeBlockExample } from '@/containers/CodeBlockExample'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ChatWidthSwitcher } from '@/containers/ChatWidthSwitcher' import { ChatWidthSwitcher } from '@/containers/ChatWidthSwitcher'
import { TokenCounterCompactSwitcher } from '@/containers/TokenCounterCompactSwitcher' import { TokenCounterCompactSwitcher } from '@/containers/TokenCounterCompactSwitcher'
import { ThreadScrollBehaviorSwitcher } from '@/containers/ThreadScrollBehaviorSwitcher'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.appearance as any)({ export const Route = createFileRoute(route.settings.interface as any)({
component: Appareances, component: InterfaceSettings,
}) })
function Appareances() { function InterfaceSettings() {
const { t } = useTranslation() const { t } = useTranslation()
const { resetAppearance } = useAppearance() const { resetInterface } = useInterfaceSettings()
const { resetCodeBlockStyle } = useCodeblock() const { resetCodeBlockStyle } = useCodeblock()
return ( return (
@ -40,64 +41,64 @@ function Appareances() {
<SettingsMenu /> <SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto"> <div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full"> <div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
{/* Appearance */} {/* Interface */}
<Card title={t('settings:appearance.title')}> <Card title={t('settings:interface.title')}>
<CardItem <CardItem
title={t('settings:appearance.theme')} title={t('settings:interface.theme')}
description={t('settings:appearance.themeDesc')} description={t('settings:interface.themeDesc')}
actions={<ThemeSwitcher />} actions={<ThemeSwitcher />}
/> />
<CardItem <CardItem
title={t('settings:appearance.fontSize')} title={t('settings:interface.fontSize')}
description={t('settings:appearance.fontSizeDesc')} description={t('settings:interface.fontSizeDesc')}
actions={<FontSizeSwitcher />} actions={<FontSizeSwitcher />}
/> />
<CardItem <CardItem
title={t('settings:appearance.windowBackground')} title={t('settings:interface.windowBackground')}
description={t('settings:appearance.windowBackgroundDesc')} description={t('settings:interface.windowBackgroundDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2" className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppBgColor />} actions={<ColorPickerAppBgColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.appMainView')} title={t('settings:interface.appMainView')}
description={t('settings:appearance.appMainViewDesc')} description={t('settings:interface.appMainViewDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2" className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppMainView />} actions={<ColorPickerAppMainView />}
/> />
<CardItem <CardItem
title={t('settings:appearance.primary')} title={t('settings:interface.primary')}
description={t('settings:appearance.primaryDesc')} description={t('settings:interface.primaryDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2" className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppPrimaryColor />} actions={<ColorPickerAppPrimaryColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.accent')} title={t('settings:interface.accent')}
description={t('settings:appearance.accentDesc')} description={t('settings:interface.accentDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2" className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppAccentColor />} actions={<ColorPickerAppAccentColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.destructive')} title={t('settings:interface.destructive')}
description={t('settings:appearance.destructiveDesc')} description={t('settings:interface.destructiveDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2" className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppDestructiveColor />} actions={<ColorPickerAppDestructiveColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.resetToDefault')} title={t('settings:interface.resetToDefault')}
description={t('settings:appearance.resetToDefaultDesc')} description={t('settings:interface.resetToDefaultDesc')}
actions={ actions={
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => { onClick={() => {
resetAppearance() resetInterface()
toast.success( toast.success(
t('settings:appearance.resetAppearanceSuccess'), t('settings:interface.resetInterfaceSuccess'),
{ {
id: 'reset-appearance', id: 'reset-interface',
description: t( description: t(
'settings:appearance.resetAppearanceSuccessDesc' 'settings:interface.resetInterfaceSuccessDesc'
), ),
} }
) )
@ -112,33 +113,42 @@ function Appareances() {
{/* Chat Message */} {/* Chat Message */}
<Card> <Card>
<CardItem <CardItem
title={t('settings:appearance.chatWidth')} title={t('settings:interface.chatWidth')}
description={t('settings:appearance.chatWidthDesc')} description={t('settings:interface.chatWidthDesc')}
/> />
<ChatWidthSwitcher /> <ChatWidthSwitcher />
<CardItem <CardItem
title={t('settings:appearance.tokenCounterCompact')} title={t('settings:interface.tokenCounterCompact')}
description={t('settings:appearance.tokenCounterCompactDesc')} description={t('settings:interface.tokenCounterCompactDesc')}
actions={<TokenCounterCompactSwitcher />} actions={<TokenCounterCompactSwitcher />}
/> />
</Card> </Card>
{/* Scroll Behavior */}
<Card>
<CardItem
title={t('settings:interface.threadScrollTitle')}
description={t('settings:interface.threadScrollDesc')}
/>
<ThreadScrollBehaviorSwitcher />
</Card>
{/* Codeblock */} {/* Codeblock */}
<Card> <Card>
<CardItem <CardItem
title={t('settings:appearance.codeBlockTitle')} title={t('settings:interface.codeBlockTitle')}
description={t('settings:appearance.codeBlockDesc')} description={t('settings:interface.codeBlockDesc')}
actions={<CodeBlockStyleSwitcher />} actions={<CodeBlockStyleSwitcher />}
/> />
<CodeBlockExample /> <CodeBlockExample />
<CardItem <CardItem
title={t('settings:appearance.showLineNumbers')} title={t('settings:interface.showLineNumbers')}
description={t('settings:appearance.showLineNumbersDesc')} description={t('settings:interface.showLineNumbersDesc')}
actions={<LineNumbersSwitcher />} actions={<LineNumbersSwitcher />}
/> />
<CardItem <CardItem
title={t('settings:appearance.resetCodeBlockStyle')} title={t('settings:interface.resetCodeBlockStyle')}
description={t('settings:appearance.resetCodeBlockStyleDesc')} description={t('settings:interface.resetCodeBlockStyleDesc')}
actions={ actions={
<Button <Button
variant="destructive" variant="destructive"
@ -146,11 +156,11 @@ function Appareances() {
onClick={() => { onClick={() => {
resetCodeBlockStyle() resetCodeBlockStyle()
toast.success( toast.success(
t('settings:appearance.resetCodeBlockSuccess'), t('settings:interface.resetCodeBlockSuccess'),
{ {
id: 'code-block-style', id: 'code-block-style',
description: t( description: t(
'settings:appearance.resetCodeBlockSuccessDesc' 'settings:interface.resetCodeBlockSuccessDesc'
), ),
} }
) )

View File

@ -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 { createFileRoute, useParams, redirect, useNavigate } from '@tanstack/react-router'
import cloneDeep from 'lodash.clonedeep' import cloneDeep from 'lodash.clonedeep'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -16,7 +16,7 @@ import { useMessages } from '@/hooks/useMessages'
import { useServiceHub } from '@/hooks/useServiceHub' import { useServiceHub } from '@/hooks/useServiceHub'
import DropdownAssistant from '@/containers/DropdownAssistant' import DropdownAssistant from '@/containers/DropdownAssistant'
import { useAssistant } from '@/hooks/useAssistant' import { useAssistant } from '@/hooks/useAssistant'
import { useAppearance } from '@/hooks/useAppearance' import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { ContentType, ThreadMessage } from '@janhq/core' import { ContentType, ThreadMessage } from '@janhq/core'
import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery' import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery'
import { useTools } from '@/hooks/useTools' import { useTools } from '@/hooks/useTools'
@ -86,7 +86,7 @@ function ThreadDetail() {
const assistants = useAssistant((state) => state.assistants) const assistants = useAssistant((state) => state.assistants)
const setMessages = useMessages((state) => state.setMessages) const setMessages = useMessages((state) => state.setMessages)
const chatWidth = useAppearance((state) => state.chatWidth) const chatWidth = useInterfaceSettings((state) => state.chatWidth)
const isSmallScreen = useSmallScreen() const isSmallScreen = useSmallScreen()
const isMobile = useMobileScreen() const isMobile = useMobileScreen()
useTools() useTools()