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 { cookies } from "next/headers";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
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,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const cookieStore = await cookies();
|
||||||
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
|
const initialSidebarOpen = parseSidebarOpenCookie(
|
||||||
useLayoutEffect(() => {
|
cookieStore.get("sidebar_state")?.value,
|
||||||
// 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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider>
|
||||||
<SidebarProvider
|
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||||
className="h-screen"
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
>
|
|
||||||
<WorkspaceSidebar />
|
<WorkspaceSidebar />
|
||||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||||
</SidebarProvider>
|
</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 {
|
import {
|
||||||
DEFAULT_LOCAL_SETTINGS,
|
DEFAULT_LOCAL_SETTINGS,
|
||||||
getLocalSettings,
|
applyThreadModelOverride,
|
||||||
getThreadLocalSettings,
|
|
||||||
saveLocalSettings,
|
|
||||||
saveThreadLocalSettings,
|
|
||||||
type LocalSettings,
|
type LocalSettings,
|
||||||
} from "./local";
|
} from "./local";
|
||||||
|
import {
|
||||||
type LocalSettingsSetter = (
|
getBaseSettingsSnapshot,
|
||||||
key: keyof LocalSettings,
|
getThreadModelSnapshot,
|
||||||
value: Partial<LocalSettings[keyof LocalSettings]>,
|
subscribe,
|
||||||
) => void;
|
updateLocalSettings,
|
||||||
|
updateThreadSettings,
|
||||||
function useSettingsState(
|
type LocalSettingsSetter,
|
||||||
getSettings: () => LocalSettings,
|
} from "./store";
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLocalSettings(): [LocalSettings, LocalSettingsSetter] {
|
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(
|
export function useThreadSettings(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
): [LocalSettings, LocalSettingsSetter] {
|
): [LocalSettings, LocalSettingsSetter] {
|
||||||
return useSettingsState(
|
const baseSettings = useSyncExternalStore(
|
||||||
useCallback(() => getThreadLocalSettings(threadId), [threadId]),
|
subscribe,
|
||||||
useCallback(
|
getBaseSettingsSnapshot,
|
||||||
(settings: LocalSettings) => saveThreadLocalSettings(threadId, settings),
|
() => DEFAULT_LOCAL_SETTINGS,
|
||||||
[threadId],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 { useLocalSettings, useThreadSettings } from "./hooks";
|
||||||
export * from "./local";
|
export type { LocalSettings } from "./local";
|
||||||
|
|||||||
@ -9,13 +9,10 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
mode: undefined,
|
mode: undefined,
|
||||||
reasoning_effort: undefined,
|
reasoning_effort: undefined,
|
||||||
},
|
},
|
||||||
layout: {
|
|
||||||
sidebar_collapsed: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||||
const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||||
|
|
||||||
function isBrowser(): boolean {
|
function isBrowser(): boolean {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
@ -38,9 +35,6 @@ export interface LocalSettings {
|
|||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
};
|
};
|
||||||
layout: {
|
|
||||||
sidebar_collapsed: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
||||||
@ -50,10 +44,6 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
|||||||
...DEFAULT_LOCAL_SETTINGS.context,
|
...DEFAULT_LOCAL_SETTINGS.context,
|
||||||
...settings?.context,
|
...settings?.context,
|
||||||
},
|
},
|
||||||
layout: {
|
|
||||||
...DEFAULT_LOCAL_SETTINGS.layout,
|
|
||||||
...settings?.layout,
|
|
||||||
},
|
|
||||||
notification: {
|
notification: {
|
||||||
...DEFAULT_LOCAL_SETTINGS.notification,
|
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||||
...settings?.notification,
|
...settings?.notification,
|
||||||
@ -87,11 +77,10 @@ export function saveThreadModelName(
|
|||||||
localStorage.setItem(key, modelName);
|
localStorage.setItem(key, modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyThreadModelOverride(
|
export function applyThreadModelOverride(
|
||||||
settings: LocalSettings,
|
settings: LocalSettings,
|
||||||
threadId?: string,
|
threadModelName: string | undefined,
|
||||||
): LocalSettings {
|
): LocalSettings {
|
||||||
const threadModelName = threadId ? getThreadModelName(threadId) : undefined;
|
|
||||||
if (!threadModelName) {
|
if (!threadModelName) {
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@ -118,21 +107,9 @@ export function getLocalSettings(): LocalSettings {
|
|||||||
return DEFAULT_LOCAL_SETTINGS;
|
return DEFAULT_LOCAL_SETTINGS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThreadLocalSettings(threadId: string): LocalSettings {
|
|
||||||
return applyThreadModelOverride(getLocalSettings(), threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveLocalSettings(settings: LocalSettings) {
|
export function saveLocalSettings(settings: LocalSettings) {
|
||||||
if (!isBrowser()) {
|
if (!isBrowser()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
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