Compare commits

...
Author SHA1 Message Date
yyh 5c308c4be1 feat(web): persist detail sidebar state in cookies 2026-07-25 18:35:11 +08:00
17 changed files with 343 additions and 43 deletions
@@ -0,0 +1,69 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { Provider, useAtomValue } from 'jotai'
import { detailSidebarModeAtom } from '@/app/components/detail-sidebar/state'
const mocks = vi.hoisted(() => ({
getInitialDetailSidebarMode: vi.fn(),
}))
vi.mock('@/app/components/detail-sidebar/server', () => ({
getInitialDetailSidebarMode: mocks.getInitialDetailSidebarMode,
}))
vi.mock('@/app/components/base/zendesk', () => ({
default: () => null,
}))
vi.mock('@/app/components/header/maintenance-notice', () => ({
default: () => null,
}))
vi.mock('@/app/components/main-nav/layout', () => ({
default: ({ children, detailSidebar }: { children: ReactNode; detailSidebar: ReactNode }) => (
<>
{detailSidebar}
{children}
</>
),
}))
vi.mock('@/app/components/next-route-state', () => ({
NextRouteStateBridge: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('../global-mounts', () => ({
CommonLayoutGlobalMounts: () => null,
}))
vi.mock('../providers', () => ({
ConsoleContextProviders: ({ children }: { children: ReactNode }) => children,
ConsoleRuntimeProviders: ({ children }: { children: ReactNode }) => children,
}))
function DetailSidebarModeValue() {
return <aside aria-label="Detail sidebar">{useAtomValue(detailSidebarModeAtom)}</aside>
}
describe('CommonLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes detail sidebar state from the request snapshot', async () => {
mocks.getInitialDetailSidebarMode.mockResolvedValue('collapse')
const { default: CommonLayout } = await import('../layout')
const layout = await CommonLayout({
children: <main>Content</main>,
detailSidebar: <DetailSidebarModeValue />,
})
render(<Provider>{layout}</Provider>)
expect(screen.getByRole('complementary', { name: 'Detail sidebar' })).toHaveTextContent(
'collapse',
)
expect(screen.getByRole('main')).toHaveTextContent('Content')
expect(mocks.getInitialDetailSidebarMode).toHaveBeenCalledOnce()
})
})
+17 -11
View File
@@ -1,5 +1,7 @@
import * as React from 'react'
import Zendesk from '@/app/components/base/zendesk'
import { getInitialDetailSidebarMode } from '@/app/components/detail-sidebar/server'
import { DetailSidebarStateInitializer } from '@/app/components/detail-sidebar/state-initializer'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
@@ -13,19 +15,23 @@ export default async function Layout({
children: React.ReactNode
detailSidebar: React.ReactNode
}) {
const initialDetailSidebarMode = await getInitialDetailSidebarMode()
return (
<React.Fragment>
<ConsoleRuntimeProviders>
<NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<ConsoleContextProviders>
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
<CommonLayoutGlobalMounts />
</ConsoleContextProviders>
</div>
</NextRouteStateBridge>
</ConsoleRuntimeProviders>
<DetailSidebarStateInitializer initialMode={initialDetailSidebarMode}>
<ConsoleRuntimeProviders>
<NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<ConsoleContextProviders>
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
<CommonLayoutGlobalMounts />
</ConsoleContextProviders>
</div>
</NextRouteStateBridge>
</ConsoleRuntimeProviders>
</DetailSidebarStateInitializer>
<Zendesk />
</React.Fragment>
)
@@ -1,5 +1,7 @@
/* oxlint-disable typescript/no-explicit-any */
import { act, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/preference'
import { updateAppModelConfig } from '@/service/apps'
import { renderHook } from '@/test/console/render'
import { AppModeEnum, ModelModeType } from '@/types/app'
@@ -8,7 +10,6 @@ import { useConfiguration } from '../use-configuration'
const mockSetShowAccountSettingModal = vi.fn()
const mockSetShowAppConfigureFeaturesModal = vi.fn()
const mockSetDetailSidebarMode = vi.fn()
const mockHandleMultipleModelConfigsChange = vi.fn()
const mockFetchCollectionList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
@@ -101,10 +102,6 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/app/components/detail-sidebar/storage', () => ({
useSetDetailSidebarMode: () => mockSetDetailSidebarMode,
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: undefined,
@@ -195,6 +192,7 @@ vi.mock('@/utils/completion-params', () => ({
describe('useConfiguration', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
latestAdvancedPromptConfigOptions = undefined
mockTempStopState = []
mockCurrentModelFeatures = ['vision']
@@ -492,7 +490,7 @@ describe('useConfiguration', () => {
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
expect(mockFormattingChangedDispatcher).toHaveBeenCalled()
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled()
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({
assistant_prefix: 'bot',
@@ -28,7 +28,7 @@ import type { VisionSettings } from '@/types/app'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
@@ -38,7 +38,7 @@ import {
} from '@/app/components/app/configuration/debug/hooks'
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
import { detailSidebarModeAtom } from '@/app/components/detail-sidebar/state'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
ModelFeatureEnum,
@@ -138,7 +138,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})),
)
const setDetailSidebarMode = useSetDetailSidebarMode()
const setDetailSidebarMode = useSetAtom(detailSidebarModeAtom)
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
@@ -0,0 +1,27 @@
# Detail Sidebar
Owns the shared console detail sidebar shell, interaction state, and SSR-aware Cookie preference.
## Internal Modules
- `index`: Shared sidebar frame and expand, collapse, hover-preview, and shortcut interactions.
- `preference`: Cookie name, runtime validation, and the default sidebar mode.
- `server`: Request Cookie adapter that resolves the initial sidebar mode.
- `state`: Feature-level Jotai state, initialization command, and client-side Cookie persistence.
- `state-initializer`: Initializes the feature atom from the request snapshot during render.
- `hotkeys`: Shared keyboard shortcut contract.
- `toggle-button`: Sidebar toggle presentation.
## State Ownership
- The Common Layout request snapshot initializes the feature atom at its surface boundary.
- The feature atom is the client runtime source of truth.
- Public atom writes update Jotai synchronously and then attempt to persist the same mode to the Cookie.
- Store initialization never writes the Cookie, and Cookie changes outside this feature are not synchronized back at runtime.
## External Modules
- `app/components/header/env-nav`: Environment metadata shown in the expanded sidebar.
- `app/components/main-nav/components/account-section`: Shared account control.
- `app/components/main-nav/components/help-menu`: Shared help control.
- `context/version-state`: Current environment state.
@@ -1,7 +1,8 @@
import { act, fireEvent, screen } from '@testing-library/react'
import Cookies from 'js-cookie'
import { render } from '@/test/console/render'
import { DetailSidebarFrame } from '..'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
const { hotkeyRegistrations } = vi.hoisted(() => ({
hotkeyRegistrations: new Map<
@@ -80,7 +81,7 @@ function renderDetailSidebarFrame() {
describe('DetailSidebarFrame', () => {
beforeEach(() => {
localStorage.clear()
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
hotkeyRegistrations.clear()
mockConsoleState.current = {
langGeniusVersionInfo: {
@@ -123,7 +124,7 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'false')
expect(screen.queryByText('Environment tag')).not.toBeInTheDocument()
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
})
it('shows a floating preview on collapsed hover without changing persisted state', () => {
@@ -134,7 +135,7 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
})
it('persists expansion without width animation when the hovered preview toggle is clicked', () => {
@@ -147,6 +148,6 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('expand')
})
})
@@ -0,0 +1,11 @@
import { parseDetailSidebarMode } from '../preference'
describe('parseDetailSidebarMode', () => {
it.each(['expand', 'collapse'] as const)('accepts the %s mode', (mode) => {
expect(parseDetailSidebarMode(mode)).toBe(mode)
})
it.each([undefined, '', 'expanded', 'COLLAPSE'])('rejects %s', (raw) => {
expect(parseDetailSidebarMode(raw)).toBeUndefined()
})
})
@@ -0,0 +1,29 @@
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
import { getInitialDetailSidebarMode } from '../server'
const mocks = vi.hoisted(() => ({
getCookie: vi.fn(),
}))
vi.mock('@/next/headers', () => ({
cookies: async () => ({ get: mocks.getCookie }),
}))
describe('getInitialDetailSidebarMode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reads a valid mode from the request Cookie', async () => {
mocks.getCookie.mockReturnValue({ value: 'collapse' })
await expect(getInitialDetailSidebarMode()).resolves.toBe('collapse')
expect(mocks.getCookie).toHaveBeenCalledWith(DETAIL_SIDEBAR_COOKIE_NAME)
})
it.each([undefined, { value: 'invalid' }])('uses the default mode for %s', async (cookie) => {
mocks.getCookie.mockReturnValue(cookie)
await expect(getInitialDetailSidebarMode()).resolves.toBe('expand')
})
})
@@ -0,0 +1,43 @@
import type { DetailSidebarMode } from '../preference'
import { Provider, useAtomValue } from 'jotai'
import { act } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { detailSidebarModeAtom } from '../state'
import { DetailSidebarStateInitializer } from '../state-initializer'
function DetailSidebarModeValue() {
return <span>{useAtomValue(detailSidebarModeAtom)}</span>
}
function renderInitializer(initialMode: DetailSidebarMode) {
return (
<Provider>
<DetailSidebarStateInitializer initialMode={initialMode}>
<DetailSidebarModeValue />
</DetailSidebarStateInitializer>
</Provider>
)
}
describe('DetailSidebarStateInitializer', () => {
it('uses the same request snapshot for server rendering and client hydration', async () => {
const container = document.createElement('div')
container.innerHTML = renderToString(renderInitializer('collapse'))
document.body.append(container)
const recoverableErrors: unknown[] = []
expect(container).toHaveTextContent('collapse')
const root = hydrateRoot(container, renderInitializer('collapse'), {
onRecoverableError: (error) => recoverableErrors.push(error),
})
await act(async () => {})
expect(container).toHaveTextContent('collapse')
expect(recoverableErrors).toEqual([])
await act(async () => root.unmount())
container.remove()
})
})
@@ -0,0 +1,52 @@
import { createStore } from 'jotai'
import Cookies from 'js-cookie'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
import { detailSidebarModeAtom, initializeDetailSidebarModeAtom } from '../state'
describe('detailSidebarModeAtom', () => {
beforeEach(() => {
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('initializes the atom without writing the Cookie', () => {
const store = createStore()
const setCookie = vi.spyOn(Cookies, 'set')
store.set(initializeDetailSidebarModeAtom, 'collapse')
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
expect(setCookie).not.toHaveBeenCalled()
})
it('updates the atom first and persists the next value with the fixed attributes', () => {
const store = createStore()
const setCookie = vi.spyOn(Cookies, 'set').mockImplementation((_name, value) => {
expect(store.get(detailSidebarModeAtom)).toBe(value)
return undefined
})
store.set(detailSidebarModeAtom, (mode) => (mode === 'expand' ? 'collapse' : 'expand'))
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
expect(setCookie).toHaveBeenCalledWith(DETAIL_SIDEBAR_COOKIE_NAME, 'collapse', {
expires: 365,
path: '/',
sameSite: 'lax',
secure: false,
})
})
it('keeps the atom update when Cookie persistence throws', () => {
const store = createStore()
vi.spyOn(Cookies, 'set').mockImplementation(() => {
throw new Error('Cookie access unavailable')
})
expect(() => store.set(detailSidebarModeAtom, 'collapse')).not.toThrow()
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
})
})
+5 -6
View File
@@ -3,14 +3,14 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai'
import { useAtom, useAtomValue } from 'jotai'
import { useEffect, useRef, useState } from 'react'
import EnvNav from '@/app/components/header/env-nav'
import AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
import { useDetailSidebarMode } from './storage'
import { detailSidebarModeAtom } from './state'
type DetailSidebarRenderProps = {
expand: boolean
@@ -39,8 +39,7 @@ export function DetailSidebarFrame({
renderSection,
}: DetailSidebarFrameProps) {
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
const [detailNavigationMode, setDetailNavigationMode] = useAtom(detailSidebarModeAtom)
const detailNavigationExpanded = detailNavigationMode === 'expand'
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] =
@@ -64,7 +63,7 @@ export function DetailSidebarFrame({
setDetailNavigationTransitionDisabled(true)
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand('expand')
setDetailNavigationMode('expand')
detailNavigationTransitionTimerRef.current = setTimeout(() => {
setDetailNavigationTransitionDisabled(false)
}, 200)
@@ -73,7 +72,7 @@ export function DetailSidebarFrame({
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode)
setDetailNavigationMode(nextMode)
}
function openDetailNavigationHoverPreview() {
@@ -0,0 +1,8 @@
export type DetailSidebarMode = 'expand' | 'collapse'
export const DETAIL_SIDEBAR_COOKIE_NAME = 'console-detail-sidebar-mode'
export const DEFAULT_DETAIL_SIDEBAR_MODE: DetailSidebarMode = 'expand'
export function parseDetailSidebarMode(raw: string | undefined): DetailSidebarMode | undefined {
if (raw === 'expand' || raw === 'collapse') return raw
}
@@ -0,0 +1,15 @@
import { cookies } from '@/next/headers'
import {
DEFAULT_DETAIL_SIDEBAR_MODE,
DETAIL_SIDEBAR_COOKIE_NAME,
parseDetailSidebarMode,
} from './preference'
export async function getInitialDetailSidebarMode() {
const cookieStore = await cookies()
return (
parseDetailSidebarMode(cookieStore.get(DETAIL_SIDEBAR_COOKIE_NAME)?.value) ??
DEFAULT_DETAIL_SIDEBAR_MODE
)
}
@@ -0,0 +1,18 @@
'use client'
import type { ReactNode } from 'react'
import type { DetailSidebarMode } from './preference'
import { useHydrateAtoms } from 'jotai/react/utils'
import { initializeDetailSidebarModeAtom } from './state'
export function DetailSidebarStateInitializer({
children,
initialMode,
}: {
children: ReactNode
initialMode: DetailSidebarMode
}) {
useHydrateAtoms([[initializeDetailSidebarModeAtom, initialMode]])
return children
}
@@ -0,0 +1,36 @@
'use client'
import type { SetStateAction } from 'jotai'
import type { DetailSidebarMode } from './preference'
import { atom } from 'jotai'
import Cookies from 'js-cookie'
import { DEFAULT_DETAIL_SIDEBAR_MODE, DETAIL_SIDEBAR_COOKIE_NAME } from './preference'
const detailSidebarModeBaseAtom = atom(DEFAULT_DETAIL_SIDEBAR_MODE)
function persistDetailSidebarMode(mode: DetailSidebarMode) {
try {
Cookies.set(DETAIL_SIDEBAR_COOKIE_NAME, mode, {
expires: 365,
path: '/',
sameSite: 'lax',
secure: globalThis.location.protocol === 'https:',
})
} catch {
// Cookie persistence is best-effort; Jotai remains the runtime source of truth.
}
}
export const detailSidebarModeAtom = atom(
(get) => get(detailSidebarModeBaseAtom),
(get, set, update: SetStateAction<DetailSidebarMode>) => {
const nextMode = typeof update === 'function' ? update(get(detailSidebarModeBaseAtom)) : update
set(detailSidebarModeBaseAtom, nextMode)
persistDetailSidebarMode(nextMode)
},
)
export const initializeDetailSidebarModeAtom = atom(null, (_get, set, mode: DetailSidebarMode) => {
set(detailSidebarModeBaseAtom, mode)
})
@@ -1,10 +0,0 @@
import { createLocalStorageState } from 'foxact/create-local-storage-state'
type DetailSidebarMode = 'expand' | 'collapse'
export const DETAIL_SIDEBAR_STORAGE_KEY = 'app-detail-collapse-or-expand'
const [useDetailSidebarMode, _useDetailSidebarModeValue, useSetDetailSidebarMode] =
createLocalStorageState<DetailSidebarMode>(DETAIL_SIDEBAR_STORAGE_KEY, 'expand', { raw: true })
export { useDetailSidebarMode, useSetDetailSidebarMode }
@@ -16,7 +16,6 @@ import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { Plan } from '@/app/components/billing/type'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@@ -656,7 +655,6 @@ describe('MainNav', () => {
})
it('keeps the global navigation account section expanded on home routes', () => {
localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse')
mockPathname = '/'
renderMainNav()