Compare commits
6 Commits
dev
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea520b433b | ||
|
|
b2825ac1f6 | ||
|
|
207c057304 | ||
|
|
62b3f41bed | ||
|
|
accd8fbde3 | ||
|
|
7ed5ec0cc3 |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
### Writing Assistance
|
||||
|
||||
|
||||
@ -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/>
|
||||

|
||||

|
||||
<br/>
|
||||
|
||||
### Spell Check
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
33
web-app/src/constants/threadScroll.ts
Normal file
33
web-app/src/constants/threadScroll.ts
Normal 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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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[] = [
|
||||
{
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
577
web-app/src/containers/ThreadScrollBehaviorSwitcher.tsx
Normal file
577
web-app/src/containers/ThreadScrollBehaviorSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -40,6 +40,7 @@ describe('useGeneralSetting', () => {
|
||||
useGeneralSetting.setState({
|
||||
currentLanguage: 'en',
|
||||
spellCheckChatInput: true,
|
||||
tokenCounterCompact: true,
|
||||
huggingfaceToken: undefined,
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
@ -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>()(
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"general": "Allgemein",
|
||||
"settings": "Einstellungen",
|
||||
"modelProviders": "Modell Anbieter",
|
||||
"appearance": "Erscheinung",
|
||||
"interface": "Erscheinung",
|
||||
"privacy": "Privatsphäre",
|
||||
"keyboardShortcuts": "Shortcuts",
|
||||
"newChat": "Neuer Chat",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"general": "General",
|
||||
"settings": "Settings",
|
||||
"modelProviders": "Model Providers",
|
||||
"appearance": "Appearance",
|
||||
"interface": "Interface",
|
||||
"privacy": "Privacy",
|
||||
"keyboardShortcuts": "Shortcuts",
|
||||
"newChat": "New Chat",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"general": "Umum",
|
||||
"settings": "Pengaturan",
|
||||
"modelProviders": "Penyedia Model",
|
||||
"appearance": "Tampilan",
|
||||
"interface": "Tampilan",
|
||||
"privacy": "Privasi",
|
||||
"keyboardShortcuts": "Pintasan",
|
||||
"newChat": "Obrolan Baru",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"general": "全般",
|
||||
"settings": "設定",
|
||||
"modelProviders": "モデルプロバイダー",
|
||||
"appearance": "外観",
|
||||
"interface": "外観",
|
||||
"privacy": "プライバシー",
|
||||
"keyboardShortcuts": "ショートカット",
|
||||
"newChat": "新しいチャット",
|
||||
@ -364,4 +364,4 @@
|
||||
"description": "スレッドは「{{projectName}}」から正常に削除されました"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "バックエンドのインストールに失敗しました"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"general": "通用",
|
||||
"settings": "设置",
|
||||
"modelProviders": "模型提供商",
|
||||
"appearance": "外观",
|
||||
"interface": "外观",
|
||||
"privacy": "隐私",
|
||||
"keyboardShortcuts": "快捷键",
|
||||
"newChat": "新建聊天",
|
||||
|
||||
@ -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": "紧凑令牌计数器",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"general": "一般",
|
||||
"settings": "設定",
|
||||
"modelProviders": "模型提供者",
|
||||
"appearance": "外觀",
|
||||
"interface": "外觀",
|
||||
"privacy": "隱私",
|
||||
"keyboardShortcuts": "快捷鍵",
|
||||
"newChat": "新聊天",
|
||||
|
||||
@ -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": "程式碼區塊",
|
||||
|
||||
@ -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
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
@ -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'
|
||||
),
|
||||
}
|
||||
)
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user