Skip to content

fix(webui): persist theme to server so it survives restarts/browsers - #634

Open
HerryOS wants to merge 1 commit into
AgentFlocks:devfrom
HerryOS:fix/webui-theme-persistence
Open

fix(webui): persist theme to server so it survives restarts/browsers#634
HerryOS wants to merge 1 commit into
AgentFlocks:devfrom
HerryOS:fix/webui-theme-persistence

Conversation

@HerryOS

@HerryOS HerryOS commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

Theme settings (light/dark) did not persist across restarts. The theme was stored only in browser localStorage (key: flocks_theme), which is scoped to a single origin and lost when:

  • The browser clears site data
  • The user switches devices/browsers
  • The app is served from a different port/origin

Unlike display name and favicon — which are persisted server-side via /api/config/ui — theme had no server-side durability.

Changes

Server-side

  • Added theme field (Optional[Literal["light", "dark"]]) to UIConfig model in flocks/config/config.py
  • Exposed theme through the existing UIDisplayResponse and UIConfigUpdateRequest in /api/config/ui endpoints
  • PATCH /api/config/ui now accepts and persists theme
  • GET /api/config/ui-display now returns theme

Client-side

  • ThemeProvider fetches the server-stored theme on mount and uses it as the source of truth (localStorage falls back when the server is unreachable)
  • Every theme change is persisted to both localStorage (fast/sync, for index.html anti-flash) and the server API (durable across origins)
  • Fixed getInitialTheme() to respect prefers-color-scheme: dark as a fallback, matching the index.html inline script behavior (eliminates flash of wrong theme on first load)
  • Fixed index.html inline script to also remove the dark class in the else branch, guarding against BFCache restoring stale DOM state
  • Added try/catch around all localStorage operations so restricted storage contexts don't crash the app

Tests

  • Updated ThemeContext.test.tsx with 10 tests covering server persistence, system preference fallback, localStorage unavailability, and server failure
  • Added uiConfigApi mocks to Settings and WebUIContractWorkspaceHost tests

Test Results

✓ ThemeContext.test.tsx — 10/10 passed
✓ Settings/index.test.tsx — 11/11 passed  
✓ WebUIContractWorkspaceHost/index.test.tsx — 3/3 passed

🤖 Generated with Claude Code

Theme was stored only in browser localStorage (key: flocks_theme), which
is scoped to a single origin and lost when the browser clears site data,
the user switches devices, or the app is served from a different port.
Unlike display-name and favicon which are persisted server-side via
/api/config/ui, theme had no server-side durability at all.

Changes:
- Server: add theme field to UIConfig model (config.py) and expose it
  through the existing UIDisplayResponse / UIConfigUpdateRequest in the
  /api/config/ui PATCH + /api/config/ui-display GET endpoints.

- Client: ThemeProvider fetches the server-stored theme on mount and
  uses it as the source of truth (localStorage falls back when the
  server is unreachable). Every theme change is persisted to both
  localStorage (fast, synchronous, used by index.html anti-flash script)
  and the server API (durable across origins/devices).

- Fix getInitialTheme() to respect prefers-color-scheme:dark as a
  fallback, matching the index.html inline script behavior and
  eliminating the flash of wrong theme on first load.

- Fix index.html inline script to also remove the dark class in the
  else branch, guarding against BFCache restoring stale DOM state.

- Add try/catch around all localStorage operations so that restricted
  storage contexts (private browsing, quota exceeded) don't crash the
  app and the theme silently falls back to the default.

- Update ThemeContext, Settings, and WebUIContractWorkspaceHost tests
  to mock uiConfigApi and cover the new server-persistence flows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@duguwanglong

Copy link
Copy Markdown
Contributor

[P1] Partial UI updates delete fields that were not provided
In config.py:L474-L489, omitted fields and fields explicitly set to null are both represented as None. As a result:
PATCH {"theme":"dark"} deletes displayName.
PATCH {"displayName":"Acme"} deletes theme.
ThemeProvider sends a theme-only PATCH after initialization, so merely opening the application can permanently remove a configured product name.
Use request.model_fields_set to distinguish omitted fields from explicit resets, or expose separate endpoints. Both targeted regression tests reproduced the data loss.

[P2] A delayed bootstrap request can overwrite a newer user selection
ThemeContext.tsx:L81-L88 applies the server theme unconditionally. If the user changes the theme while the initial GET request is still pending, the delayed response overwrites that newer selection and subsequently persists the stale value.
A regression test reproduced this: the user selected dark mode, but the UI reverted to light when the server response arrived. Track whether the user has already changed the theme, or use a request/version guard before applying the bootstrap result.

Additional integration notes:
PR #634 conflicts with the current branch in Settings/index.test.tsx. A three-way merge also duplicates ToolFailurePreference, so the branch should be rebased and unrelated carried-over changes removed.
The production WebUI build passes, and the PR’s 10 ThemeContext tests pass.
Two existing backend UI-response tests fail because they were not updated for the new theme: null response field.
The theme is stored in the instance-wide flocks.json. In multi-user deployments, users will therefore share and overwrite one another’s theme; please confirm that this is intentional.

@duguwanglong
duguwanglong self-requested a review August 3, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants