fix(frontend): unify local settings runtime state and remove sidebar layout from LocalSettings#1879
Conversation
There was a problem hiding this comment.
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()touseSyncExternalStore. - Removes
layout.sidebar_collapsedfromLocalSettingsand keepslocal.tsfocused on persistence/derivation helpers. - Updates
/workspacelayout to be a server component that readssidebar_statefrom 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. |
| const currentThreadSettings = applyThreadModelOverride( | ||
| baseSettings, | ||
| getThreadModelSnapshot(threadId), | ||
| ); | ||
| const nextThreadSettings = mergeSettingsSection( | ||
| currentThreadSettings, | ||
| key, | ||
| value, | ||
| ); | ||
|
|
||
| baseSettings = nextThreadSettings; | ||
| saveLocalSettings(baseSettings); | ||
|
|
||
| const threadModelName = nextThreadSettings.context.model_name; |
There was a problem hiding this comment.
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).
| 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; | |
| } |
| 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); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
|
@4nsonnn thanks for your contribution. Please take some time to address Copilot review comments. |
452ac5d to
41ab116
Compare
@WillemJiang Thanks for the reminder. I’ve addressed all the Copilot review comments in my latest push. |
|
@4nsonnn |
@WillemJiang Thanks for calling this out. Here’s why I removed I removed With the previous implementation, the layout had to render on the client first, then read In this PR, the sidebar state is persisted in the existing 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. |
…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
…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


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()anduseThreadSettings()each kept their own React state copy after reading fromlocalStorage. That meant there was no single runtime source of truth for settings inside a tab.At the same time,
LocalSettingsstill containedlayout.sidebar_collapsed, even though:/workspacelayoutsidebar_stateSo 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
frontend/src/core/settings/store.tsuseLocalSettings()anduseThreadSettings()touseSyncExternalStorelocal.tsfocused on persistence helpers and pure derivationlayout.sidebar_collapsedfromLocalSettingsfrontend/src/app/workspace/layout.tsxto readsidebar_statefrom cookies on the serverBehavior after this change
notificationandcontextremain indeerflow.local-settingscontext.model_namecontinues to support thread-level override viadeerflow.thread-model.${threadId}LocalSettingssidebar_statecookieNotes
layout.sidebar_collapsedlocalStorage field into the cookieTesting
pnpm checksidebar_state