Compare commits

...

6 Commits

Author SHA1 Message Date
dinhlongviolin1
ea520b433b scroll sticky can exit + fix re render issue 2025-10-31 00:26:57 +07:00
dinhlongviolin1
b2825ac1f6 fix test 2025-10-30 23:26:25 +07:00
dinhlongviolin1
207c057304 fix lint 2025-10-30 01:12:55 +07:00
Dinh Long Nguyen
62b3f41bed
Merge branch 'dev' into feat/multiple-scroll-behavior 2025-10-30 01:07:59 +07:00
Dinh Long Nguyen
accd8fbde3
Merge branch 'dev' into feat/multiple-scroll-behavior 2025-10-30 01:06:30 +07:00
dinhlongviolin1
7ed5ec0cc3 support 2 type of scroll behavior with proper settings 2025-10-30 00:59:50 +07:00
47 changed files with 1308 additions and 287 deletions

View File

@ -3,7 +3,7 @@
## A. Initial update / migration Data check
Before testing, set-up the following in the old version to make sure that we can see the data is properly migrated:
- [ ] Changing appearance / theme to something that is obviously different from default set-up
- [ ] Changing Interface / theme to something that is obviously different from default set-up
- [ ] Ensure there are a few chat threads
- [ ] Ensure there are a few favourites / star threads
- [ ] Ensure there are 2 model downloaded
@ -23,7 +23,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Can resume chat in threads with the previous context
- [ ] Assistants
- Settings:
- [ ] Appearance
- [ ] Interface
- [ ] MCP Servers
- [ ] Local API Server
- [ ] HTTPS Proxy
@ -56,7 +56,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Ensure `Community` links work and point to the correct website
- [ ] Ensure the `Check for Updates` function detect the correct latest version
- [ ] [ENG] Create a folder with un-standard character as title (e.g. Chinese character) => change the `App data` location to that folder => test that model is still able to load and run properly.
#### In `Appearance`:
#### In `Interface`:
- [ ] Toggle between different `Theme` options to check that they change accordingly and that all elements of the UI are legible with the right contrast:
- [ ] Light
- [ ] Dark
@ -73,7 +73,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Ensure that when this value is changed, there is no broken UI caused by it
- [ ] Code Block
- [ ] Show Line Numbers
- [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values
- [ENG] Ensure that when click on `Reset` in the `Interface` section, it reset back to the default values
- [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values
#### In `Model Providers`:
@ -205,7 +205,7 @@ Ensure that the following section information show up for hardware
- [ ] Model option (except if the model / model provider has been deleted or disabled)
- [ ] User can send message with different type of text content (e.g text, emoji, ...)
- [ ] When request model to generate a markdown table, the table is correctly formatted as returned from the model.
- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Appearance -> Code Block` setting.
- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Interface -> Code Block` setting.
- [ ] Users can edit their old message and and user can regenerate the answer based on the new message
- [ ] User can click `Copy` to copy the model response
- [ ] User can click `Delete` to delete either the user message or the model response.
@ -231,7 +231,7 @@ In `Settings -> General`:
- [ ] All threads deleted
- [ ] All Assistant deleted except for default Jan Assistant
- [ ] `App Data` location is reset back to default path
- [ ] Appearance reset
- [ ] Interface reset
- [ ] Model Providers information all reset
- [ ] Llama.cpp setting reset
- [ ] API keys cleared
@ -261,4 +261,4 @@ In `Settings -> General`:
# II. After release
- [ ] Check that the App Updater works and user can update to the latest release without any problem
- [ ] App restarts after the user finished an update
- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version
- [ ] 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
### Visual Appearance
### Visual Interface
Customize Jan's look at **Settings** > **Appearance**:
Customize Jan's look at **Settings** > **Interface**:
- **Theme**: Choose light or dark mode
- **Colors**: Pick your preferred color scheme
- **Code highlighting**: Adjust syntax colors for programming discussions
![Appearance](./_assets/settings-04.png)
![Interface](./_assets/settings-04.png)
### Writing Assistance

View File

@ -83,11 +83,11 @@ Monitor and manage system resources at **Settings > Hardware**:
## Preferences
### Appearance & Theme
### Interface & Theme
Control the visual theme of Jan's interface with any color combo you'd like. You can also control the color use in the code blocks.
<br/>
![Appearance](./_assets/settings-04.png)
![Interface](./_assets/settings-04.png)
<br/>
### Spell Check

View File

@ -3,7 +3,7 @@
## A. Initial update / migration Data check
Before testing, set-up the following in the old version to make sure that we can see the data is properly migrated:
- [ ] Changing appearance / theme to something that is obviously different from default set-up
- [ ] Changing Interface / theme to something that is obviously different from default set-up
- [ ] Ensure there are a few chat threads
- [ ] Ensure there are a few favourites / star threads
- [ ] Ensure there are 2 model downloaded
@ -23,7 +23,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Can resume chat in threads with the previous context
- [ ] Assistants
- Settings:
- [ ] Appearance
- [ ] Interface
- [ ] MCP Servers
- [ ] Local API Server
- [ ] HTTPS Proxy
@ -56,7 +56,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Ensure `Community` links work and point to the correct website
- [ ] Ensure the `Check for Updates` function detect the correct latest version
- [ ] [ENG] Create a folder with un-standard character as title (e.g. Chinese character) => change the `App data` location to that folder => test that model is still able to load and run properly.
#### In `Appearance`:
#### In `Interface`:
- [ ] Toggle between different `Theme` options to check that they change accordingly and that all elements of the UI are legible with the right contrast:
- [ ] Light
- [ ] Dark
@ -74,7 +74,7 @@ Before testing, set-up the following in the old version to make sure that we can
- [ ] Code Block
- [ ] Show Line Numbers
- [ ] [0.7.0] Compact Token Counter will show token counter in side chat input when toggle, if not it will show a small token counter below the chat input
- [ ] [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values
- [ ] [ENG] Ensure that when click on `Reset` in the `Interface` section, it reset back to the default values
- [ ] [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values
#### In `Model Providers`:
@ -228,7 +228,7 @@ Ensure that the following section information show up for hardware
- [ ] Model option (except if the model / model provider has been deleted or disabled)
- [ ] User can send message with different type of text content (e.g text, emoji, ...)
- [ ] When request model to generate a markdown table, the table is correctly formatted as returned from the model.
- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Appearance -> Code Block` setting.
- [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Interface -> Code Block` setting.
- [ ] [0.7.0] LaTeX formulas now render correctly in chat. Both inline \(...\) and block \[...\] formats are supported. Code blocks and HTML tags are not affected
- [ ] Users can edit their old message and user can regenerate the answer based on the new message
- [ ] User can click `Copy` to copy the model response
@ -269,7 +269,7 @@ In `Settings -> General`:
- [ ] All threads deleted
- [ ] All Assistant deleted except for default Jan Assistant
- [ ] `App Data` location is reset back to default path
- [ ] Appearance reset
- [ ] Interface reset
- [ ] Model Providers information all reset
- [ ] Llama.cpp setting reset
- [ ] API keys cleared
@ -299,4 +299,4 @@ In `Settings -> General`:
# II. After release
- [ ] Check that the App Updater works and user can update to the latest release without any problem
- [ ] App restarts after the user finished an update
- [ ] Repeat section `A. Initial update / migration Data check` above to verify that update is done correctly on live version
- [ ] 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',
modelProvider: 'model-provider',
modelSources: 'model-sources',
settingAppearance: 'setting-appearance',
settingInterface: 'setting-interface',
settingGeneral: 'setting-general',
settingCodeBlock: 'setting-code-block',
settingLocalApiServer: 'setting-local-api-server',

View File

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

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 { useAppearance } from '@/hooks/useAppearance'
import { Skeleton } from '@/components/ui/skeleton'
import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { cn } from '@/lib/utils'
import { IconCircleCheckFilled } from '@tabler/icons-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
export function ChatWidthSwitcher() {
const { chatWidth, setChatWidth } = useAppearance()
const { chatWidth, setChatWidth } = useInterfaceSettings()
const { t } = useTranslation()
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,577 @@
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 createFlowHistoryFrames = () =>
Array.from(FLOW_HISTORY_FRAMES, (frame) => ({ ...frame }))
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[] }>
>(createFlowHistoryFrames)
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(createFlowHistoryFrames())
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[] }>
>(
() =>
createFlowHistoryFrames().map((frame) => ({
...frame,
type: 'assistant' as const,
}))
)
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(
createFlowHistoryFrames().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 />)
expect(screen.getByText('common:general')).toBeInTheDocument()
expect(screen.getByText('common:appearance')).toBeInTheDocument()
expect(screen.getByText('common:interface')).toBeInTheDocument()
expect(screen.getByText('common:privacy')).toBeInTheDocument()
expect(screen.getByText('common:modelProviders')).toBeInTheDocument()
// Platform-specific features tested separately

View File

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

View File

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

View File

@ -7,11 +7,16 @@ import { useTheme } from './useTheme'
import { useEffect, useState } from 'react'
import { getServiceHub } from '@/hooks/useServiceHub'
import { supportsBlurEffects } from '@/utils/blurSupport'
import {
DEFAULT_THREAD_SCROLL_BEHAVIOR,
ThreadScrollBehavior,
isThreadScrollBehavior,
} from '@/constants/threadScroll'
export type FontSize = '14px' | '15px' | '16px' | '18px'
export type ChatWidth = 'full' | 'compact'
interface AppearanceState {
interface InterfaceSettingsState {
chatWidth: ChatWidth
fontSize: FontSize
appBgColor: RgbaColor
@ -24,6 +29,7 @@ interface AppearanceState {
appAccentTextColor: string
appDestructiveTextColor: string
appLeftPanelTextColor: string
threadScrollBehavior: ThreadScrollBehavior
setChatWidth: (size: ChatWidth) => void
setFontSize: (size: FontSize) => void
setAppBgColor: (color: RgbaColor) => void
@ -31,9 +37,26 @@ interface AppearanceState {
setAppPrimaryBgColor: (color: RgbaColor) => void
setAppAccentBgColor: (color: RgbaColor) => void
setAppDestructiveBgColor: (color: RgbaColor) => void
resetAppearance: () => void
setThreadScrollBehavior: (value: ThreadScrollBehavior) => void
resetInterface: () => void
}
const LEGACY_INTERFACE_STORAGE_KEY = 'setting-appearance' as const
const GENERAL_SETTINGS_STORAGE_KEY = localStorageKey.settingGeneral
type InterfaceSettingsPersistedSlice = Omit<
InterfaceSettingsState,
| 'resetInterface'
| 'setChatWidth'
| 'setFontSize'
| 'setAppBgColor'
| 'setAppMainViewBgColor'
| 'setAppPrimaryBgColor'
| 'setAppAccentBgColor'
| 'setAppDestructiveBgColor'
| 'setThreadScrollBehavior'
>
const getBrightness = ({ r, g, b }: RgbaColor) =>
(r * 299 + g * 587 + b * 114) / 1000
@ -62,7 +85,7 @@ const getAlphaValue = () => {
return 0.4
}
// Default appearance settings
// Default interface settings
const defaultFontSize: FontSize = '15px'
const defaultAppBgColor: RgbaColor = {
r: 25,
@ -154,6 +177,169 @@ export const getDefaultTextColor = (isDark: boolean): string => {
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
}
const getIsDarkTheme = (): boolean => {
try {
return !!useTheme.getState().isDark
} catch {
return false
}
}
const copyColor = (color: RgbaColor): RgbaColor => ({
r: color.r,
g: color.g,
b: color.b,
a: color.a,
})
const createDefaultInterfaceValues = (): InterfaceSettingsPersistedSlice => {
const isDark = getIsDarkTheme()
const defaultTextColor = getDefaultTextColor(isDark)
return {
chatWidth: 'compact',
fontSize: defaultFontSize,
appBgColor: copyColor(defaultAppBgColor),
appMainViewBgColor: copyColor(defaultAppMainViewBgColor),
appPrimaryBgColor: copyColor(defaultAppPrimaryBgColor),
appAccentBgColor: copyColor(defaultAppAccentBgColor),
appDestructiveBgColor: copyColor(defaultAppDestructiveBgColor),
appLeftPanelTextColor: defaultTextColor,
appMainViewTextColor: defaultTextColor,
appPrimaryTextColor: defaultTextColor,
appAccentTextColor: defaultTextColor,
appDestructiveTextColor: '#FFF',
threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR,
}
}
const buildDefaultPersistedSnapshot = () =>
JSON.stringify({ state: createDefaultInterfaceValues(), version: 0 })
const validatePersistedSnapshot = (value: string): string | null => {
try {
const parsed = JSON.parse(value) as {
state?: Record<string, unknown>
version?: unknown
}
if (parsed && typeof parsed === 'object' && parsed.state) {
const state = parsed.state
if (
!isThreadScrollBehavior(
state.threadScrollBehavior as ThreadScrollBehavior
)
) {
const draft = {
...parsed,
state: {
...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 rest = { ...parsed.state }
delete rest.threadScrollBehavior
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
export const useBlurSupport = () => {
const [supportsBlur, setSupportsBlur] = useState(
@ -207,24 +393,13 @@ export const useBlurSupport = () => {
return IS_TAURI && (IS_MACOS || supportsBlur)
}
export const useAppearance = create<AppearanceState>()(
export const useInterfaceSettings = create<InterfaceSettingsState>()(
persist(
(set) => {
const defaultState = createDefaultInterfaceValues()
return {
chatWidth: 'compact',
fontSize: defaultFontSize,
appBgColor: defaultAppBgColor,
appMainViewBgColor: defaultAppMainViewBgColor,
appPrimaryBgColor: defaultAppPrimaryBgColor,
appAccentBgColor: defaultAppAccentBgColor,
appDestructiveBgColor: defaultAppDestructiveBgColor,
appLeftPanelTextColor: getDefaultTextColor(useTheme.getState().isDark),
appMainViewTextColor: getDefaultTextColor(useTheme.getState().isDark),
appPrimaryTextColor: getDefaultTextColor(useTheme.getState().isDark),
appAccentTextColor: getDefaultTextColor(useTheme.getState().isDark),
appDestructiveTextColor: '#FFF',
resetAppearance: () => {
...defaultState,
resetInterface: () => {
const { isDark } = useTheme.getState()
// Reset font size
@ -341,20 +516,28 @@ export const useAppearance = create<AppearanceState>()(
)
// Update state
set({
fontSize: defaultFontSize,
appBgColor: defaultBg,
appMainViewBgColor: defaultMainView,
appPrimaryBgColor: defaultPrimary,
appAccentBgColor: defaultAccent,
set({
fontSize: defaultFontSize,
appBgColor: defaultBg,
appMainViewBgColor: defaultMainView,
appPrimaryBgColor: defaultPrimary,
appAccentBgColor: defaultAccent,
appLeftPanelTextColor: defaultTextColor,
appMainViewTextColor: defaultTextColor,
appPrimaryTextColor: '#FFF',
appAccentTextColor: '#FFF',
appDestructiveBgColor: defaultDestructive,
appDestructiveTextColor: '#FFF',
})
},
appMainViewTextColor: defaultTextColor,
appPrimaryTextColor: '#FFF',
appAccentTextColor: '#FFF',
appDestructiveBgColor: defaultDestructive,
appDestructiveTextColor: '#FFF',
threadScrollBehavior: DEFAULT_THREAD_SCROLL_BEHAVIOR,
})
},
setThreadScrollBehavior: (value: ThreadScrollBehavior) =>
set({
threadScrollBehavior: isThreadScrollBehavior(value)
? value
: DEFAULT_THREAD_SCROLL_BEHAVIOR,
}),
setChatWidth: (value: ChatWidth) => {
set({ chatWidth: value })
@ -638,8 +821,8 @@ export const useAppearance = create<AppearanceState>()(
}
},
{
name: localStorageKey.settingAppearance,
storage: createJSONStorage(() => localStorage),
name: localStorageKey.settingInterface,
storage: interfaceStorage,
// Apply settings when hydrating from storage
onRehydrateStorage: () => (state) => {
if (state) {
@ -653,7 +836,7 @@ export const useAppearance = create<AppearanceState>()(
const { isDark } = useTheme.getState()
// Just use the stored color as-is during rehydration
// The AppearanceProvider will handle alpha normalization after blur detection
// The InterfaceProvider will handle alpha normalization after blur detection
const finalColor = state.appBgColor
let finalColorMainView = state.appMainViewBgColor

View File

@ -40,14 +40,25 @@ export const useMessages = create<MessageState>()((set, get) => ({
assistant: selectedAssistant,
},
}
set((state) => ({
messages: {
...state.messages,
[message.thread_id]: [
...(state.messages[message.thread_id] || []),
newMessage,
],
},
}))
getServiceHub().messages().createMessage(newMessage).then((createdMessage) => {
set((state) => ({
messages: {
...state.messages,
[message.thread_id]: [
...(state.messages[message.thread_id] || []),
createdMessage,
],
[message.thread_id]:
state.messages[message.thread_id]?.map((existing) =>
existing.id === newMessage.id ? createdMessage : existing
) ?? [createdMessage],
},
}))
})

View File

@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppState } from './useAppState'
import { useMessages } from './useMessages'
import { useInterfaceSettings } from './useInterfaceSettings'
import { THREAD_SCROLL_BEHAVIOR } from '@/constants/threadScroll'
const VIEWPORT_PADDING = 40 // Offset from viewport bottom for user message positioning
const MAX_DOM_RETRY_ATTEMPTS = 5 // Maximum attempts to find DOM elements before giving up
@ -18,6 +20,16 @@ export const useThreadScrolling = (
const [hasScrollbar, setHasScrollbar] = useState(false)
const lastScrollTopRef = useRef(0)
const lastAssistantMessageRef = useRef<HTMLElement | null>(null)
const userForcedUnfollowRef = useRef(false)
const stickyStreamingActiveRef = useRef(false)
const stickyReleaseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const wasAtBottomRef = useRef(true)
const threadScrollBehavior = useInterfaceSettings(
(state) => state.threadScrollBehavior
)
const isFlowScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.FLOW
const isStickyScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.STICKY
const [isStickyScrollFollowing, setIsStickyScrollFollowing] = useState(true)
const messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0)
const lastMessageRole = useMessages((state) => {
@ -43,33 +55,92 @@ export const useThreadScrolling = (
}, [scrollContainerRef])
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
const showScrollToBottomBtn =
!isAtBottom && hasScrollbar && (!isStickyScroll || !isStickyScrollFollowing)
const scrollToBottom = useCallback((smooth = false) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
...(smooth ? { behavior: 'smooth' } : {}),
})
const clearStickyReleaseTimeout = useCallback(() => {
if (stickyReleaseTimeoutRef.current) {
clearTimeout(stickyReleaseTimeoutRef.current)
stickyReleaseTimeoutRef.current = null
}
}, [scrollContainerRef])
}, [])
const scrollToBottom = useCallback(
(smooth = false) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
...(smooth ? { behavior: 'smooth' } : {}),
})
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const { scrollTop, scrollHeight, clientHeight } = target
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
const hasScroll = scrollHeight > clientHeight
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
if (streamingContent && !isBottom) {
userIntendedPositionRef.current = scrollTop
if (isStickyScroll) {
clearStickyReleaseTimeout()
userForcedUnfollowRef.current = false
setIsStickyScrollFollowing(true)
}
}
}
setIsAtBottom(isBottom)
setHasScrollbar(hasScroll)
lastScrollTopRef.current = scrollTop
}, [streamingContent])
},
[clearStickyReleaseTimeout, isStickyScroll, scrollContainerRef]
)
const handleScroll = useCallback(
(e: Event) => {
const target = e.target as HTMLDivElement
const { scrollTop, scrollHeight, clientHeight } = target
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
const hasScroll = scrollHeight > clientHeight
const previousScrollTop = lastScrollTopRef.current
const delta = scrollTop - previousScrollTop
const wasAtBottom = wasAtBottomRef.current
if (Math.abs(delta) > 10) {
if (streamingContent && !isBottom) {
userIntendedPositionRef.current = scrollTop
}
if (isStickyScroll) {
if (!isBottom && delta < 0) {
clearStickyReleaseTimeout()
userForcedUnfollowRef.current = true
setIsStickyScrollFollowing((prev) => {
if (!prev) return prev
return false
})
}
}
}
if (isStickyScroll) {
if (!isBottom && delta < -1) {
clearStickyReleaseTimeout()
userForcedUnfollowRef.current = true
setIsStickyScrollFollowing((prev) => {
if (!prev) return prev
return false
})
} else if (isBottom && (!wasAtBottom || !isStickyScrollFollowing)) {
clearStickyReleaseTimeout()
userForcedUnfollowRef.current = false
setIsStickyScrollFollowing((prev) => {
if (prev) return prev
return true
})
}
}
setIsAtBottom(isBottom)
setHasScrollbar(hasScroll)
lastScrollTopRef.current = scrollTop
wasAtBottomRef.current = isBottom
},
[
clearStickyReleaseTimeout,
isStickyScroll,
isStickyScrollFollowing,
streamingContent,
]
)
useEffect(() => {
const scrollContainer = scrollContainerRef.current
@ -103,9 +174,12 @@ export const useThreadScrolling = (
}
}, [checkScrollState, scrollToBottom])
const prevCountRef = useRef(messageCount)
useEffect(() => {
if (!isFlowScroll) {
prevCountRef.current = messageCount
return
}
const prevCount = prevCountRef.current
const becameLonger = messageCount > prevCount
const isUserMessage = lastMessageRole === 'user'
@ -146,12 +220,17 @@ export const useThreadScrolling = (
}
prevCountRef.current = messageCount
}, [messageCount, lastMessageRole])
}, [isFlowScroll, lastMessageRole, messageCount])
useEffect(() => {
const previouslyStreaming = wasStreamingRef.current
const currentlyStreaming = !!streamingContent && streamingContent.thread_id === threadId
if (!isFlowScroll) {
wasStreamingRef.current = currentlyStreaming
return
}
const streamingStarted = !previouslyStreaming && currentlyStreaming
const streamingEnded = previouslyStreaming && !currentlyStreaming
const hasPaddingToAdjust = originalPaddingRef.current > 0
@ -197,7 +276,75 @@ export const useThreadScrolling = (
}
wasStreamingRef.current = currentlyStreaming
}, [streamingContent, threadId])
}, [getDOMElements, isFlowScroll, streamingContent, threadId])
useEffect(() => {
if (isFlowScroll) return
setPaddingHeight(0)
originalPaddingRef.current = 0
}, [isFlowScroll])
useEffect(() => {
if (!isStickyScroll) {
stickyStreamingActiveRef.current = false
clearStickyReleaseTimeout()
return
}
const isCurrentThreadStreaming =
!!streamingContent && streamingContent.thread_id === threadId
if (isCurrentThreadStreaming) {
if (!stickyStreamingActiveRef.current) {
stickyStreamingActiveRef.current = true
}
clearStickyReleaseTimeout()
if (!userForcedUnfollowRef.current) {
setIsStickyScrollFollowing((prev) => {
if (prev) return prev
return true
})
}
} else if (stickyStreamingActiveRef.current) {
stickyStreamingActiveRef.current = false
clearStickyReleaseTimeout()
if (isStickyScrollFollowing) {
stickyReleaseTimeoutRef.current = setTimeout(() => {
stickyReleaseTimeoutRef.current = null
setIsStickyScrollFollowing((prev) => {
if (!prev) return prev
return false
})
}, 1000)
}
}
}, [
clearStickyReleaseTimeout,
isStickyScroll,
isStickyScrollFollowing,
streamingContent,
threadId,
])
useEffect(() => {
if (!isStickyScroll) return
setIsStickyScrollFollowing(true)
scrollToBottom(false)
}, [isStickyScroll, scrollToBottom, threadId])
useEffect(() => {
if (!isStickyScroll) return
if (!isStickyScrollFollowing) return
if (messageCount === 0) return
scrollToBottom(true)
}, [isStickyScroll, isStickyScrollFollowing, messageCount, scrollToBottom])
useEffect(() => {
if (!isStickyScroll) return
if (!isStickyScrollFollowing) return
if (streamingContent?.thread_id !== threadId) return
scrollToBottom(false)
}, [isStickyScroll, isStickyScrollFollowing, scrollToBottom, streamingContent, threadId])
useEffect(() => {
userIntendedPositionRef.current = null
@ -207,14 +354,28 @@ export const useThreadScrolling = (
prevCountRef.current = messageCount
scrollToBottom(false)
checkScrollState()
setIsStickyScrollFollowing(true)
clearStickyReleaseTimeout()
stickyStreamingActiveRef.current = false
userForcedUnfollowRef.current = false
wasAtBottomRef.current = true
// Only reset when switching threads; keep deps limited intentionally.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId])
useEffect(
() => () => {
clearStickyReleaseTimeout()
},
[clearStickyReleaseTimeout]
)
return useMemo(
() => ({
showScrollToBottomBtn,
scrollToBottom,
paddingHeight
paddingHeight,
}),
[showScrollToBottomBtn, scrollToBottom, paddingHeight]
[paddingHeight, scrollToBottom, showScrollToBottomBtn]
)
}

View File

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

View File

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

View File

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

View File

@ -78,8 +78,8 @@
"goToSettings": "Go to Settings",
"goToSettingsDesc": "Open settings."
},
"appearance": {
"title": "Appearance",
"interface": {
"title": "Interface",
"theme": "Theme",
"themeDesc": "Match the OS theme.",
"fontSize": "Font Size",
@ -89,17 +89,25 @@
"appMainView": "App Main View",
"appMainViewDesc": "Set the main content area's background color.",
"primary": "Primary",
"primaryDesc": "Set the primary color for UI components.",
"primaryDesc": "Set the primary color for interface accents.",
"accent": "Accent",
"accentDesc": "Set the accent color for UI highlights.",
"accentDesc": "Select the accent color used across the UI.",
"destructive": "Destructive",
"destructiveDesc": "Set the color for destructive actions.",
"destructiveDesc": "Set the highlight color for destructive actions.",
"resetToDefault": "Reset to Default",
"resetToDefaultDesc": "Reset all appearance settings to default.",
"resetAppearanceSuccess": "Appearance reset successfully",
"resetAppearanceSuccessDesc": "All appearance settings have been restored to default.",
"chatWidth": "Chat Width",
"chatWidthDesc": "Customize the width of the chat view.",
"resetToDefaultDesc": "Reset all interface settings to default.",
"resetInterfaceSuccess": "Interface reset successfully",
"resetInterfaceSuccessDesc": "All interface settings have been restored to default.",
"chatWidth": "Chat Layout Width",
"chatWidthDesc": "Pick a chat width preset that fits your workspace.",
"threadScrollTitle": "Thread Scroll Behavior",
"threadScrollDesc": "Choose how the chat viewport should react when new messages arrive.",
"threadScrollFlowTitle": "Flow scroll",
"threadScrollFlowHint": "Keeps the viewport anchored to the latest message you send.",
"threadScrollStickyTitle": "Sticky scroll",
"threadScrollStickyHint": "Automatically follows along as replies stream in real time.",
"tokenCounterCompact": "Compact Token Counter",
"tokenCounterCompactDesc": "Show token counter inside chat input. When disabled, token counter appears below the input.",
"codeBlockTitle": "Code Block",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,7 +78,7 @@
"goToSettings": "Đi tới Cài đặt",
"goToSettingsDesc": "Mở cài đặt."
},
"appearance": {
"interface": {
"title": "Giao diện",
"theme": "Chủ đề",
"themeDesc": "Khớp với chủ đề hệ điều hành.",
@ -96,8 +96,8 @@
"destructiveDesc": "Đặt màu cho các hành động hủy.",
"resetToDefault": "Đặt lại về mặc định",
"resetToDefaultDesc": "Đặt lại tất cả cài đặt giao diện về mặc định.",
"resetAppearanceSuccess": "Giao diện đã được đặt lại thành công",
"resetAppearanceSuccessDesc": "Tất cả cài đặt giao diện đã được khôi phục về mặc định.",
"resetInterfaceSuccess": "Giao diện đã được đặt lại thành công",
"resetInterfaceSuccessDesc": "Tất cả cài đặt giao diện đã được khôi phục về mặc định.",
"chatWidth": "Chiều rộng trò chuyện",
"chatWidthDesc": "Tùy chỉnh chiều rộng của chế độ xem trò chuyện.",
"tokenCounterCompact": "Bộ đếm token nhỏ gọn",

View File

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

View File

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

View File

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

View File

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

View File

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

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

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 LeftPanel from '@/containers/LeftPanel'
import DialogAppUpdater from '@/containers/dialogs/AppUpdater'
import BackendUpdater from '@/containers/dialogs/BackendUpdater'
import { Fragment } from 'react/jsx-runtime'
import { AppearanceProvider } from '@/providers/AppearanceProvider'
import { InterfaceProvider } from '@/providers/InterfaceProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { KeyboardShortcutsProvider } from '@/providers/KeyboardShortcuts'
import { DataProvider } from '@/providers/DataProvider'
@ -212,7 +212,7 @@ function RootLayout() {
<Fragment>
<ServiceHubProvider>
<ThemeProvider />
<AppearanceProvider />
<InterfaceProvider />
<ToasterProvider />
<TranslationProvider>
<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 { useThreadManagement } from '@/hooks/useThreadManagement'
@ -15,7 +15,7 @@ import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform/types'
import { IconMessage } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { useAppearance } from '@/hooks/useAppearance'
import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { useSmallScreen } from '@/hooks/useMediaQuery'
export const Route = createFileRoute('/project/$projectId')({
@ -36,7 +36,7 @@ function ProjectPageContent() {
const { getFolderById } = useThreadManagement()
const threads = useThreads((state) => state.threads)
const chatWidth = useAppearance((state) => state.chatWidth)
const chatWidth = useInterfaceSettings((state) => state.chatWidth)
const isSmallScreen = useSmallScreen()
// Find the project

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 { Route as AppearanceRoute } from '../appearance'
import { Route as InterfaceRoute } from '../interface'
// Mock all the dependencies
vi.mock('@/containers/SettingsMenu', () => ({
@ -61,6 +61,12 @@ vi.mock('@/containers/ChatWidthSwitcher', () => ({
ChatWidthSwitcher: () => <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', () => ({
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>,
}))
vi.mock('@/hooks/useAppearance', () => ({
useAppearance: () => ({
resetAppearance: vi.fn(),
vi.mock('@/hooks/useInterfaceSettings', () => ({
useInterfaceSettings: () => ({
resetInterface: vi.fn(),
}),
}))
@ -108,7 +114,7 @@ vi.mock('sonner', () => ({
vi.mock('@/constants/routes', () => ({
route: {
settings: {
appearance: '/settings/appearance',
interface: '/settings/interface',
},
},
}))
@ -120,13 +126,13 @@ vi.mock('@tanstack/react-router', () => ({
}),
}))
describe('Appearance Settings Route', () => {
describe('Interface Settings Route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the appearance settings page', () => {
const Component = AppearanceRoute.component as React.ComponentType
it('should render the interface settings page', () => {
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
expect(screen.getByTestId('header-page')).toBeInTheDocument()
@ -134,8 +140,8 @@ describe('Appearance Settings Route', () => {
expect(screen.getByText('common:settings')).toBeInTheDocument()
})
it('should render appearance controls', () => {
const Component = AppearanceRoute.component as React.ComponentType
it('should render interface controls', () => {
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
expect(screen.getByTestId('theme-switcher')).toBeInTheDocument()
@ -148,14 +154,15 @@ describe('Appearance Settings Route', () => {
})
it('should render chat width controls', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
expect(screen.getByTestId('chat-width-switcher')).toBeInTheDocument()
expect(screen.getByTestId('thread-scroll-switcher')).toBeInTheDocument()
})
it('should render code block controls', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
expect(screen.getByTestId('code-block-style-switcher')).toBeInTheDocument()
@ -163,8 +170,8 @@ describe('Appearance Settings Route', () => {
expect(screen.getByTestId('line-numbers-switcher')).toBeInTheDocument()
})
it('should render reset appearance button', () => {
const Component = AppearanceRoute.component as React.ComponentType
it('should render reset interface button', () => {
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
const resetButtons = screen.getAllByTestId('button')
@ -172,7 +179,7 @@ describe('Appearance Settings Route', () => {
})
it('should render reset buttons', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
const resetButtons = screen.getAllByTestId('button')
@ -185,7 +192,7 @@ describe('Appearance Settings Route', () => {
})
it('should render reset functionality', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
const resetButtons = screen.getAllByTestId('button')
@ -199,7 +206,7 @@ describe('Appearance Settings Route', () => {
})
it('should render all card items with proper structure', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
const cardItems = screen.getAllByTestId('card-item')
@ -211,7 +218,7 @@ describe('Appearance Settings Route', () => {
})
it('should have proper responsive layout classes', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
const cardItems = screen.getAllByTestId('card-item')
@ -226,7 +233,7 @@ describe('Appearance Settings Route', () => {
})
it('should render main layout structure', () => {
const Component = AppearanceRoute.component as React.ComponentType
const Component = InterfaceRoute.component as React.ComponentType
render(<Component />)
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 SettingsMenu from '@/containers/SettingsMenu'
import HeaderPage from '@/containers/HeaderPage'
@ -11,7 +11,7 @@ import { FontSizeSwitcher } from '@/containers/FontSizeSwitcher'
import { ColorPickerAppPrimaryColor } from '@/containers/ColorPickerAppPrimaryColor'
import { ColorPickerAppAccentColor } from '@/containers/ColorPickerAppAccentColor'
import { ColorPickerAppDestructiveColor } from '@/containers/ColorPickerAppDestructiveColor'
import { useAppearance } from '@/hooks/useAppearance'
import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { useCodeblock } from '@/hooks/useCodeblock'
import { Button } from '@/components/ui/button'
import CodeBlockStyleSwitcher from '@/containers/CodeBlockStyleSwitcher'
@ -20,15 +20,16 @@ import { CodeBlockExample } from '@/containers/CodeBlockExample'
import { toast } from 'sonner'
import { ChatWidthSwitcher } from '@/containers/ChatWidthSwitcher'
import { TokenCounterCompactSwitcher } from '@/containers/TokenCounterCompactSwitcher'
import { ThreadScrollBehaviorSwitcher } from '@/containers/ThreadScrollBehaviorSwitcher'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.appearance as any)({
component: Appareances,
export const Route = createFileRoute(route.settings.interface as any)({
component: InterfaceSettings,
})
function Appareances() {
function InterfaceSettings() {
const { t } = useTranslation()
const { resetAppearance } = useAppearance()
const { resetInterface } = useInterfaceSettings()
const { resetCodeBlockStyle } = useCodeblock()
return (
@ -40,64 +41,64 @@ function Appareances() {
<SettingsMenu />
<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">
{/* Appearance */}
<Card title={t('settings:appearance.title')}>
{/* Interface */}
<Card title={t('settings:interface.title')}>
<CardItem
title={t('settings:appearance.theme')}
description={t('settings:appearance.themeDesc')}
title={t('settings:interface.theme')}
description={t('settings:interface.themeDesc')}
actions={<ThemeSwitcher />}
/>
<CardItem
title={t('settings:appearance.fontSize')}
description={t('settings:appearance.fontSizeDesc')}
title={t('settings:interface.fontSize')}
description={t('settings:interface.fontSizeDesc')}
actions={<FontSizeSwitcher />}
/>
<CardItem
title={t('settings:appearance.windowBackground')}
description={t('settings:appearance.windowBackgroundDesc')}
title={t('settings:interface.windowBackground')}
description={t('settings:interface.windowBackgroundDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppBgColor />}
/>
<CardItem
title={t('settings:appearance.appMainView')}
description={t('settings:appearance.appMainViewDesc')}
title={t('settings:interface.appMainView')}
description={t('settings:interface.appMainViewDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppMainView />}
/>
<CardItem
title={t('settings:appearance.primary')}
description={t('settings:appearance.primaryDesc')}
title={t('settings:interface.primary')}
description={t('settings:interface.primaryDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppPrimaryColor />}
/>
<CardItem
title={t('settings:appearance.accent')}
description={t('settings:appearance.accentDesc')}
title={t('settings:interface.accent')}
description={t('settings:interface.accentDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppAccentColor />}
/>
<CardItem
title={t('settings:appearance.destructive')}
description={t('settings:appearance.destructiveDesc')}
title={t('settings:interface.destructive')}
description={t('settings:interface.destructiveDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppDestructiveColor />}
/>
<CardItem
title={t('settings:appearance.resetToDefault')}
description={t('settings:appearance.resetToDefaultDesc')}
title={t('settings:interface.resetToDefault')}
description={t('settings:interface.resetToDefaultDesc')}
actions={
<Button
variant="destructive"
size="sm"
onClick={() => {
resetAppearance()
resetInterface()
toast.success(
t('settings:appearance.resetAppearanceSuccess'),
t('settings:interface.resetInterfaceSuccess'),
{
id: 'reset-appearance',
id: 'reset-interface',
description: t(
'settings:appearance.resetAppearanceSuccessDesc'
'settings:interface.resetInterfaceSuccessDesc'
),
}
)
@ -112,33 +113,42 @@ function Appareances() {
{/* Chat Message */}
<Card>
<CardItem
title={t('settings:appearance.chatWidth')}
description={t('settings:appearance.chatWidthDesc')}
title={t('settings:interface.chatWidth')}
description={t('settings:interface.chatWidthDesc')}
/>
<ChatWidthSwitcher />
<CardItem
title={t('settings:appearance.tokenCounterCompact')}
description={t('settings:appearance.tokenCounterCompactDesc')}
title={t('settings:interface.tokenCounterCompact')}
description={t('settings:interface.tokenCounterCompactDesc')}
actions={<TokenCounterCompactSwitcher />}
/>
</Card>
{/* Scroll Behavior */}
<Card>
<CardItem
title={t('settings:interface.threadScrollTitle')}
description={t('settings:interface.threadScrollDesc')}
/>
<ThreadScrollBehaviorSwitcher />
</Card>
{/* Codeblock */}
<Card>
<CardItem
title={t('settings:appearance.codeBlockTitle')}
description={t('settings:appearance.codeBlockDesc')}
title={t('settings:interface.codeBlockTitle')}
description={t('settings:interface.codeBlockDesc')}
actions={<CodeBlockStyleSwitcher />}
/>
<CodeBlockExample />
<CardItem
title={t('settings:appearance.showLineNumbers')}
description={t('settings:appearance.showLineNumbersDesc')}
title={t('settings:interface.showLineNumbers')}
description={t('settings:interface.showLineNumbersDesc')}
actions={<LineNumbersSwitcher />}
/>
<CardItem
title={t('settings:appearance.resetCodeBlockStyle')}
description={t('settings:appearance.resetCodeBlockStyleDesc')}
title={t('settings:interface.resetCodeBlockStyle')}
description={t('settings:interface.resetCodeBlockStyleDesc')}
actions={
<Button
variant="destructive"
@ -146,11 +156,11 @@ function Appareances() {
onClick={() => {
resetCodeBlockStyle()
toast.success(
t('settings:appearance.resetCodeBlockSuccess'),
t('settings:interface.resetCodeBlockSuccess'),
{
id: 'code-block-style',
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 cloneDeep from 'lodash.clonedeep'
import { cn } from '@/lib/utils'
@ -16,7 +16,7 @@ import { useMessages } from '@/hooks/useMessages'
import { useServiceHub } from '@/hooks/useServiceHub'
import DropdownAssistant from '@/containers/DropdownAssistant'
import { useAssistant } from '@/hooks/useAssistant'
import { useAppearance } from '@/hooks/useAppearance'
import { useInterfaceSettings } from '@/hooks/useInterfaceSettings'
import { ContentType, ThreadMessage } from '@janhq/core'
import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery'
import { useTools } from '@/hooks/useTools'
@ -86,7 +86,7 @@ function ThreadDetail() {
const assistants = useAssistant((state) => state.assistants)
const setMessages = useMessages((state) => state.setMessages)
const chatWidth = useAppearance((state) => state.chatWidth)
const chatWidth = useInterfaceSettings((state) => state.chatWidth)
const isSmallScreen = useSmallScreen()
const isMobile = useMobileScreen()
useTools()