Skip to content

fix(frontend): unify local settings runtime state and remove sidebar layout from LocalSettings#1879

Merged
WillemJiang merged 3 commits into
bytedance:mainfrom
4nsonnn:fix/unify-local-settings-runtime-state
Apr 7, 2026
Merged

fix(frontend): unify local settings runtime state and remove sidebar layout from LocalSettings#1879
WillemJiang merged 3 commits into
bytedance:mainfrom
4nsonnn:fix/unify-local-settings-runtime-state

Conversation

@4nsonnn
Copy link
Copy Markdown
Contributor

@4nsonnn 4nsonnn commented Apr 5, 2026

Fixes #1878

Summary

This PR refactors the frontend settings runtime model around a shared external store and removes sidebar layout state from LocalSettings.

Why

Before this change, useLocalSettings() and useThreadSettings() each kept their own React state copy after reading from localStorage. That meant there was no single runtime source of truth for settings inside a tab.

At the same time, LocalSettings still contained layout.sidebar_collapsed, even though:

  • the value affects the initial /workspace layout
  • that kind of state is better handled through an SSR-readable cookie
  • the sidebar component already persists its state to sidebar_state

So the sidebar change here is not a separate feature. It is part of tightening the settings boundary while refactoring the settings module to eliminate runtime drift.

What changed

  • added a module-level settings store in frontend/src/core/settings/store.ts
  • migrated useLocalSettings() and useThreadSettings() to useSyncExternalStore
  • kept local.ts focused on persistence helpers and pure derivation
  • removed layout.sidebar_collapsed from LocalSettings
  • switched frontend/src/app/workspace/layout.tsx to read sidebar_state from cookies on the server
  • extracted a small client-only React Query provider so the workspace layout can remain a server component

Behavior after this change

  • notification and context remain in deerflow.local-settings
  • context.model_name continues to support thread-level override via deerflow.thread-model.${threadId}
  • sidebar state is no longer part of LocalSettings
  • workspace sidebar initial state now comes from the existing sidebar_state cookie

Notes

  • this PR does not add a one-time migration from the historical layout.sidebar_collapsed localStorage field into the cookie
  • if an existing user only has the old localStorage value and no cookie yet, the sidebar falls back to the default-open behavior until the next client-side toggle writes the cookie

Testing

  • ran pnpm check
  • verified notification and context settings still persist correctly
  • verified same-tab settings consumers update from one shared runtime source
  • verified thread settings still apply per-thread model override semantics
  • verified sidebar collapsed/expanded state persists across refreshes via sidebar_state

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 5, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the frontend settings runtime to use a single in-tab external store (via useSyncExternalStore) and removes sidebar layout state from LocalSettings by switching the workspace layout to initialize sidebar state from the existing sidebar_state cookie on the server.

Changes:

  • Introduces a module-level settings store and migrates useLocalSettings() / useThreadSettings() to useSyncExternalStore.
  • Removes layout.sidebar_collapsed from LocalSettings and keeps local.ts focused on persistence/derivation helpers.
  • Updates /workspace layout to be a server component that reads sidebar_state from cookies and wraps client providers via a small extracted Query Client provider.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/src/core/settings/store.ts Adds shared external store, storage-event reconciliation, and update helpers for base + per-thread model override.
frontend/src/core/settings/local.ts Removes sidebar state from LocalSettings, exports storage keys, and refactors thread model override helper.
frontend/src/core/settings/hooks.ts Replaces local React state with useSyncExternalStore subscriptions for local/thread settings.
frontend/src/core/settings/index.ts Narrows public exports to the hooks and LocalSettings type.
frontend/src/components/query-client-provider.tsx Extracts a client-only React Query provider to keep workspace layout server-rendered.
frontend/src/app/workspace/layout.tsx Converts to async server layout, initializes sidebar state from the sidebar_state cookie, and uses extracted QueryClientProvider.

Comment thread frontend/src/core/settings/store.ts Outdated
Comment on lines +136 to +149
const currentThreadSettings = applyThreadModelOverride(
baseSettings,
getThreadModelSnapshot(threadId),
);
const nextThreadSettings = mergeSettingsSection(
currentThreadSettings,
key,
value,
);

baseSettings = nextThreadSettings;
saveLocalSettings(baseSettings);

const threadModelName = nextThreadSettings.context.model_name;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateThreadSettings() computes currentThreadSettings by applying the per-thread model override to baseSettings before merging. That means any later merge (even for non-context keys) will carry the thread override into the object that gets persisted, effectively turning a thread-only override into the new base settings. Consider merging updates into baseSettings directly and treating the thread model override as a separate persisted value (only applied when deriving snapshots).

Suggested change
const currentThreadSettings = applyThreadModelOverride(
baseSettings,
getThreadModelSnapshot(threadId),
);
const nextThreadSettings = mergeSettingsSection(
currentThreadSettings,
key,
value,
);
baseSettings = nextThreadSettings;
saveLocalSettings(baseSettings);
const threadModelName = nextThreadSettings.context.model_name;
const currentThreadModelName = getThreadModelSnapshot(threadId);
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
baseSettings = nextBaseSettings;
saveLocalSettings(baseSettings);
let threadModelName = currentThreadModelName;
if (key === "context" && "model_name" in value) {
threadModelName = value.model_name;
}

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/core/settings/store.ts Outdated
Comment on lines +136 to +151
const currentThreadSettings = applyThreadModelOverride(
baseSettings,
getThreadModelSnapshot(threadId),
);
const nextThreadSettings = mergeSettingsSection(
currentThreadSettings,
key,
value,
);

baseSettings = nextThreadSettings;
saveLocalSettings(baseSettings);

const threadModelName = nextThreadSettings.context.model_name;
threadModelNames.set(threadId, threadModelName);
saveThreadModelName(threadId, threadModelName);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

baseSettings is being replaced with nextThreadSettings (which may include the thread override) and then written to deerflow.local-settings. This can unintentionally change the global/default context.model_name when a thread has an override (e.g. updating a different setting section). A safer approach is to persist base settings without the override, and persist the override via saveThreadModelName() only when context.model_name changes.

Suggested change
const currentThreadSettings = applyThreadModelOverride(
baseSettings,
getThreadModelSnapshot(threadId),
);
const nextThreadSettings = mergeSettingsSection(
currentThreadSettings,
key,
value,
);
baseSettings = nextThreadSettings;
saveLocalSettings(baseSettings);
const threadModelName = nextThreadSettings.context.model_name;
threadModelNames.set(threadId, threadModelName);
saveThreadModelName(threadId, threadModelName);
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
baseSettings = nextBaseSettings;
saveLocalSettings(baseSettings);
if (
key === "context" &&
Object.prototype.hasOwnProperty.call(value, "model_name")
) {
const threadModelName = value.model_name;
threadModelNames.set(threadId, threadModelName);
saveThreadModelName(threadId, threadModelName);
}

Copilot uses AI. Check for mistakes.
@WillemJiang WillemJiang added the question Further information is requested label Apr 6, 2026
@WillemJiang
Copy link
Copy Markdown
Collaborator

@4nsonnn thanks for your contribution. Please take some time to address Copilot review comments.

@4nsonnn 4nsonnn force-pushed the fix/unify-local-settings-runtime-state branch from 452ac5d to 41ab116 Compare April 6, 2026 10:59
@4nsonnn
Copy link
Copy Markdown
Contributor Author

4nsonnn commented Apr 6, 2026

@4nsonnn thanks for your contribution. Please take some time to address Copilot review comments.

@WillemJiang Thanks for the reminder. I’ve addressed all the Copilot review comments in my latest push.

@WillemJiang
Copy link
Copy Markdown
Collaborator

@4nsonnn
Could you explain remove the sidebar layout from LocalSettings? Maybe you can paste some pictures of the frontend UI before and after applying this PR. I just want to make sure this change doesn't introduce regression of the frontend.

@4nsonnn
Copy link
Copy Markdown
Contributor Author

4nsonnn commented Apr 6, 2026

@4nsonnn Could you explain remove the sidebar layout from LocalSettings? Maybe you can paste some pictures of the frontend UI before and after applying this PR. I just want to make sure this change doesn't introduce regression of the frontend.

@WillemJiang Thanks for calling this out. Here’s why I removed sidebar_collapsed from LocalSettings, along with GIFs below showing the before/after behavior.

I removed layout.sidebar_collapsed from LocalSettings because LocalSettings is stored in localStorage, while the sidebar state affects the initial /workspace layout.

With the previous implementation, the layout had to render on the client first, then read localStorage after mount, and then sync the sidebar state. That meant the persisted sidebar state was not available during SSR, and it could introduce a first-paint layout flicker.

In this PR, the sidebar state is persisted in the existing sidebar_state cookie instead. This allows workspace/layout.tsx to read the state on the server and pass the correct initial value to SidebarProvider, so the first render already matches the persisted sidebar state.

So the intent of this change is not to alter the sidebar UI or interaction model. The visible layout, toggle behavior, and shortcut behavior remain the same. The main change is the persistence mechanism and the removal of the client-side sync step for the initial render.

Instead of static screenshots, I’m attaching short GIFs for before/after behavior, because the steady-state UI is intentionally the same in this PR. The GIFs make it easier to verify that the layout itself does not regress, and that the main difference is the removal of the refresh flicker on the initial render.

Before:
before

After:
after

@WillemJiang WillemJiang merged commit 1193ac6 into bytedance:main Apr 7, 2026
4 checks passed
MarkHoch pushed a commit to MarkHoch/deer-flow that referenced this pull request Apr 16, 2026
…layout from LocalSettings (bytedance#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
orbisai0security pushed a commit to orbisai0security/deer-flow that referenced this pull request Apr 27, 2026
…layout from LocalSettings (bytedance#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] useLocalSettings() / useThreadSettings() keep isolated local state and mix in SSR-sensitive sidebar state

4 participants