diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 417c933d4..4c1dd2036 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,42 +1,30 @@ -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { cookies } from "next/headers"; import { Toaster } from "sonner"; +import { QueryClientProvider } from "@/components/query-client-provider"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { CommandPalette } from "@/components/workspace/command-palette"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; -import { getLocalSettings, useLocalSettings } from "@/core/settings"; -const queryClient = new QueryClient(); +function parseSidebarOpenCookie( + value: string | undefined, +): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} -export default function WorkspaceLayout({ +export default async function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const [settings, setSettings] = useLocalSettings(); - const [open, setOpen] = useState(false); // SSR default: open (matches server render) - useLayoutEffect(() => { - // Runs synchronously before first paint on the client — no visual flash - setOpen(!getLocalSettings().layout.sidebar_collapsed); - }, []); - useEffect(() => { - setOpen(!settings.layout.sidebar_collapsed); - }, [settings.layout.sidebar_collapsed]); - const handleOpenChange = useCallback( - (open: boolean) => { - setOpen(open); - setSettings("layout", { sidebar_collapsed: !open }); - }, - [setSettings], + const cookieStore = await cookies(); + const initialSidebarOpen = parseSidebarOpenCookie( + cookieStore.get("sidebar_state")?.value, ); + return ( - - + + {children} diff --git a/frontend/src/components/query-client-provider.tsx b/frontend/src/components/query-client-provider.tsx new file mode 100644 index 000000000..d10dd23f1 --- /dev/null +++ b/frontend/src/components/query-client-provider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { + QueryClient, + QueryClientProvider as TanStackQueryClientProvider, +} from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +export function QueryClientProvider({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/frontend/src/core/settings/hooks.ts b/frontend/src/core/settings/hooks.ts index f5ec0d2a6..75a59ec93 100644 --- a/frontend/src/core/settings/hooks.ts +++ b/frontend/src/core/settings/hooks.ts @@ -1,64 +1,59 @@ -import { useCallback, useLayoutEffect, useState } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; import { DEFAULT_LOCAL_SETTINGS, - getLocalSettings, - getThreadLocalSettings, - saveLocalSettings, - saveThreadLocalSettings, + applyThreadModelOverride, type LocalSettings, } from "./local"; - -type LocalSettingsSetter = ( - key: keyof LocalSettings, - value: Partial, -) => void; - -function useSettingsState( - getSettings: () => LocalSettings, - saveSettings: (settings: LocalSettings) => void, -): [LocalSettings, LocalSettingsSetter] { - const [state, setState] = useState(DEFAULT_LOCAL_SETTINGS); - - const [mounted, setMounted] = useState(false); - useLayoutEffect(() => { - setState(getSettings()); - setMounted(true); - }, [getSettings]); - - const setter = useCallback( - (key, value) => { - if (!mounted) return; - setState((prev) => { - const newState: LocalSettings = { - ...prev, - [key]: { - ...prev[key], - ...value, - }, - }; - saveSettings(newState); - return newState; - }); - }, - [mounted, saveSettings], - ); - - return [state, setter]; -} +import { + getBaseSettingsSnapshot, + getThreadModelSnapshot, + subscribe, + updateLocalSettings, + updateThreadSettings, + type LocalSettingsSetter, +} from "./store"; export function useLocalSettings(): [LocalSettings, LocalSettingsSetter] { - return useSettingsState(getLocalSettings, saveLocalSettings); + const settings = useSyncExternalStore( + subscribe, + getBaseSettingsSnapshot, + () => DEFAULT_LOCAL_SETTINGS, + ); + + const setSettings = useCallback((key, value) => { + updateLocalSettings(key, value); + }, []); + + return [settings, setSettings]; } export function useThreadSettings( threadId: string, ): [LocalSettings, LocalSettingsSetter] { - return useSettingsState( - useCallback(() => getThreadLocalSettings(threadId), [threadId]), - useCallback( - (settings: LocalSettings) => saveThreadLocalSettings(threadId, settings), - [threadId], - ), + const baseSettings = useSyncExternalStore( + subscribe, + getBaseSettingsSnapshot, + () => DEFAULT_LOCAL_SETTINGS, ); + + const threadModelName = useSyncExternalStore( + subscribe, + () => getThreadModelSnapshot(threadId), + () => undefined, + ); + + const settings = useMemo( + () => applyThreadModelOverride(baseSettings, threadModelName), + [baseSettings, threadModelName], + ); + + const setSettings = useCallback( + (key, value) => { + updateThreadSettings(threadId, key, value); + }, + [threadId], + ); + + return [settings, setSettings]; } diff --git a/frontend/src/core/settings/index.ts b/frontend/src/core/settings/index.ts index 3148fe6be..43cd76a94 100644 --- a/frontend/src/core/settings/index.ts +++ b/frontend/src/core/settings/index.ts @@ -1,2 +1,2 @@ -export * from "./hooks"; -export * from "./local"; +export { useLocalSettings, useThreadSettings } from "./hooks"; +export type { LocalSettings } from "./local"; diff --git a/frontend/src/core/settings/local.ts b/frontend/src/core/settings/local.ts index 562ed7046..bc76e8fa8 100644 --- a/frontend/src/core/settings/local.ts +++ b/frontend/src/core/settings/local.ts @@ -9,13 +9,10 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { mode: undefined, reasoning_effort: undefined, }, - layout: { - sidebar_collapsed: false, - }, }; -const LOCAL_SETTINGS_KEY = "deerflow.local-settings"; -const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model."; +export const LOCAL_SETTINGS_KEY = "deerflow.local-settings"; +export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model."; function isBrowser(): boolean { return typeof window !== "undefined"; @@ -38,9 +35,6 @@ export interface LocalSettings { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; reasoning_effort?: "minimal" | "low" | "medium" | "high"; }; - layout: { - sidebar_collapsed: boolean; - }; } function mergeLocalSettings(settings?: Partial): LocalSettings { @@ -50,10 +44,6 @@ function mergeLocalSettings(settings?: Partial): LocalSettings { ...DEFAULT_LOCAL_SETTINGS.context, ...settings?.context, }, - layout: { - ...DEFAULT_LOCAL_SETTINGS.layout, - ...settings?.layout, - }, notification: { ...DEFAULT_LOCAL_SETTINGS.notification, ...settings?.notification, @@ -87,11 +77,10 @@ export function saveThreadModelName( localStorage.setItem(key, modelName); } -function applyThreadModelOverride( +export function applyThreadModelOverride( settings: LocalSettings, - threadId?: string, + threadModelName: string | undefined, ): LocalSettings { - const threadModelName = threadId ? getThreadModelName(threadId) : undefined; if (!threadModelName) { return settings; } @@ -118,21 +107,9 @@ export function getLocalSettings(): LocalSettings { return DEFAULT_LOCAL_SETTINGS; } -export function getThreadLocalSettings(threadId: string): LocalSettings { - return applyThreadModelOverride(getLocalSettings(), threadId); -} - export function saveLocalSettings(settings: LocalSettings) { if (!isBrowser()) { return; } localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings)); } - -export function saveThreadLocalSettings( - threadId: string, - settings: LocalSettings, -) { - saveLocalSettings(settings); - saveThreadModelName(threadId, settings.context.model_name); -} diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts new file mode 100644 index 000000000..86e85aa58 --- /dev/null +++ b/frontend/src/core/settings/store.ts @@ -0,0 +1,150 @@ +import { + DEFAULT_LOCAL_SETTINGS, + LOCAL_SETTINGS_KEY, + THREAD_MODEL_KEY_PREFIX, + getLocalSettings, + getThreadModelName, + saveLocalSettings, + saveThreadModelName, + type LocalSettings, +} from "./local"; + +type Listener = () => void; + +export type LocalSettingsSetter = ( + key: K, + value: Partial, +) => void; + +const listeners = new Set(); +const threadModelNames = new Map(); + +let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS; +let baseSettingsLoaded = false; +let storageListenerRegistered = false; + +function emitChange() { + for (const listener of listeners) { + listener(); + } +} + +function ensureBaseSettingsLoaded() { + if (baseSettingsLoaded || typeof window === "undefined") { + return; + } + + baseSettings = getLocalSettings(); + baseSettingsLoaded = true; +} + +function ensureStorageListenerRegistered() { + if (storageListenerRegistered || typeof window === "undefined") { + return; + } + + window.addEventListener("storage", handleStorage); + storageListenerRegistered = true; +} + +function mergeSettingsSection( + settings: LocalSettings, + key: K, + value: Partial, +): LocalSettings { + return { + ...settings, + [key]: { + ...settings[key], + ...value, + }, + } as LocalSettings; +} + +function handleStorage(event: StorageEvent) { + if (event.storageArea && event.storageArea !== localStorage) { + return; + } + + ensureBaseSettingsLoaded(); + + if (event.key === null) { + baseSettings = getLocalSettings(); + threadModelNames.clear(); + emitChange(); + return; + } + + if (event.key === LOCAL_SETTINGS_KEY) { + baseSettings = getLocalSettings(); + emitChange(); + return; + } + + if (!event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) { + return; + } + + const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length); + threadModelNames.set(threadId, getThreadModelName(threadId)); + emitChange(); +} + +export function subscribe(listener: Listener): () => void { + ensureBaseSettingsLoaded(); + ensureStorageListenerRegistered(); + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function getBaseSettingsSnapshot(): LocalSettings { + ensureBaseSettingsLoaded(); + return baseSettings; +} + +export function getThreadModelSnapshot(threadId: string): string | undefined { + ensureBaseSettingsLoaded(); + + if (!threadModelNames.has(threadId)) { + threadModelNames.set(threadId, getThreadModelName(threadId)); + } + + return threadModelNames.get(threadId); +} + +export const updateLocalSettings: LocalSettingsSetter = (key, value) => { + ensureBaseSettingsLoaded(); + ensureStorageListenerRegistered(); + + baseSettings = mergeSettingsSection(baseSettings, key, value); + saveLocalSettings(baseSettings); + emitChange(); +}; + +export function updateThreadSettings( + threadId: string, + key: K, + value: Partial, +) { + ensureBaseSettingsLoaded(); + ensureStorageListenerRegistered(); + + const nextBaseSettings = mergeSettingsSection(baseSettings, key, value); + baseSettings = nextBaseSettings; + saveLocalSettings(baseSettings); + + if ( + key === "context" && + Object.prototype.hasOwnProperty.call(value, "model_name") + ) { + const contextValue = value as Partial; + const threadModelName = contextValue.model_name; + threadModelNames.set(threadId, threadModelName); + saveThreadModelName(threadId, threadModelName); + } + + emitChange(); +}