mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
fix(frontend): unify local settings runtime state and remove sidebar layout from LocalSettings (#1879)
* fix(frontend): resolve layout flickering by migrating workspace sidebar state to cookie * fix(frontend): unify local settings runtime state to fix state drift * fix(frontend): only persist thread model on explicit context model updates
This commit is contained in:
parent
ab41de2961
commit
1193ac64dc
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProvider
|
||||
className="h-screen"
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
20
frontend/src/components/query-client-provider.tsx
Normal file
20
frontend/src/components/query-client-provider.tsx
Normal file
@ -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 (
|
||||
<TanStackQueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</TanStackQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@ -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<LocalSettings[keyof LocalSettings]>,
|
||||
) => void;
|
||||
|
||||
function useSettingsState(
|
||||
getSettings: () => LocalSettings,
|
||||
saveSettings: (settings: LocalSettings) => void,
|
||||
): [LocalSettings, LocalSettingsSetter] {
|
||||
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setState(getSettings());
|
||||
setMounted(true);
|
||||
}, [getSettings]);
|
||||
|
||||
const setter = useCallback<LocalSettingsSetter>(
|
||||
(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<LocalSettingsSetter>((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<LocalSettingsSetter>(
|
||||
(key, value) => {
|
||||
updateThreadSettings(threadId, key, value);
|
||||
},
|
||||
[threadId],
|
||||
);
|
||||
|
||||
return [settings, setSettings];
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./hooks";
|
||||
export * from "./local";
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export type { LocalSettings } from "./local";
|
||||
|
||||
@ -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>): LocalSettings {
|
||||
@ -50,10 +44,6 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): 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);
|
||||
}
|
||||
|
||||
150
frontend/src/core/settings/store.ts
Normal file
150
frontend/src/core/settings/store.ts
Normal file
@ -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 = <K extends keyof LocalSettings>(
|
||||
key: K,
|
||||
value: Partial<LocalSettings[K]>,
|
||||
) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const threadModelNames = new Map<string, string | undefined>();
|
||||
|
||||
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<K extends keyof LocalSettings>(
|
||||
settings: LocalSettings,
|
||||
key: K,
|
||||
value: Partial<LocalSettings[K]>,
|
||||
): 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<K extends keyof LocalSettings>(
|
||||
threadId: string,
|
||||
key: K,
|
||||
value: Partial<LocalSettings[K]>,
|
||||
) {
|
||||
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<LocalSettings["context"]>;
|
||||
const threadModelName = contextValue.model_name;
|
||||
threadModelNames.set(threadId, threadModelName);
|
||||
saveThreadModelName(threadId, threadModelName);
|
||||
}
|
||||
|
||||
emitChange();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user