feat(settings): persist account preferences across browsers (#5397)

* feat(settings): persist account preferences across browsers

* docs(settings): scope preference guidance to user persistence

* fix(settings): preserve SSR and fence custom-agent defaults

* test: include user persistence in scoped guidance inventory

* fix(settings): sync explicit edits and preserve local tab updates
This commit is contained in:
yang rui 2026-09-14 07:25:41 +08:00 committed by GitHub
parent bddcd68aa6
commit 7513f16e0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1604 additions and 55 deletions

View File

@ -524,6 +524,8 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`; durable background-task calls honor the same setting for HTTP/SSE servers as well.
MCP tool names are prefixed with `<server_name>_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers.
Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local.
Settings > Tools adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.

View File

@ -42,6 +42,7 @@ from app.gateway.routers import (
thread_runs,
threads,
uploads,
user_preferences,
)
from app.gateway.trace_middleware import TraceMiddleware
from deerflow.config import app_config as deerflow_app_config
@ -855,6 +856,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Auth API is mounted at /api/v1/auth
app.include_router(auth.router)
app.include_router(user_preferences.router)
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)

View File

@ -0,0 +1,60 @@
"""Session-authenticated, owner-scoped UI preferences."""
from typing import Annotated, Literal
from fastapi import APIRouter, Header, HTTPException, Request, Response
from pydantic import BaseModel, ConfigDict, StringConstraints, ValidationError
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
from app.gateway.deps import get_current_user_from_request
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.user.preferences import UserPreferencesRepository
router = APIRouter(prefix="/api/v1/auth/preferences", tags=["auth"])
class Preferences(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
notification_enabled: bool | None = None
model_name: Annotated[str, StringConstraints(max_length=200)] | None = None
mode: Literal["flash", "thinking", "pro", "ultra"] | None = None
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
async def _owner(request: Request, expected_user: str) -> str:
user = await get_current_user_from_request(request)
if getattr(request.state, "auth_source", None) != AUTH_SOURCE_SESSION:
raise HTTPException(403, "Preferences require an authenticated browser session")
owner = str(user.id)
if owner != expected_user:
raise HTTPException(409, "The signed-in account changed; reload this page")
return owner
def _repository() -> UserPreferencesRepository:
sessions = get_session_factory()
if sessions is None:
raise HTTPException(503, "Preference persistence is unavailable")
return UserPreferencesRepository(sessions)
@router.get("", response_model=Preferences)
async def get_preferences(request: Request, x_expected_user_id: str = Header(...)) -> Preferences:
owner = await _owner(request, x_expected_user_id)
stored = await _repository().get(owner)
# An invalid optional cosmetic setting must not hide the rest of the page.
valid = {}
for key, value in stored.items():
try:
Preferences.model_validate({key: value})
except ValidationError:
continue
valid[key] = value
return Preferences(**valid)
@router.patch("", status_code=204, response_class=Response)
async def patch_preferences(body: Preferences, request: Request, x_expected_user_id: str = Header(...)) -> Response:
owner = await _owner(request, x_expected_user_id)
await _repository().patch(owner, body.model_dump(exclude_unset=True))
return Response(status_code=204)

View File

@ -34,6 +34,32 @@ PATs require a configured database backend (SQLite/PostgreSQL) — on the
memory-only backend, Bearer credentials are rejected and PAT management routes
return `503`.
### Account Preferences
`GET /api/v1/auth/preferences` returns the signed-in browser user's four
preferences. `PATCH` updates only explicitly supplied fields and returns `204`.
Both require `X-Expected-User-Id` matching the session user; PATCH also requires
the normal `X-CSRF-Token` header. The expected ID is a stale-tab guard, not an
authorization credential. PAT, internal, and auth-disabled callers receive
`403`; a different session user receives `409`.
```json
{
"notification_enabled": false,
"model_name": "my-model",
"mode": "pro",
"reasoning_effort": "high"
}
```
All four fields accept `null` to restore the default. `mode` accepts `flash`,
`thinking`, `pro`, or `ultra`; `reasoning_effort` accepts `minimal`, `low`,
`medium`, or `high`; model names are at most 200 characters. Unknown fields and
invalid values return `422`. Missing preferences read as `null`. Separate-field
patches preserve each other's changes, and same-field writes are last-commit-wins.
Storage requires SQLite or PostgreSQL (`503` when unavailable). Browser
notification permission remains device-local and is not changed by this API.
### Personal Access Tokens
Base URL: `/api/v1/auth`

View File

@ -22,7 +22,9 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a
**Rolling forward compatibility**: the local chain is
`0018_oauth_identity_pg_partial``0019_projects`
`0020_threads_meta_project_id``0021_batch_acceptance`
`0019_thread_incarnations``0022_scheduled_occurrence_seq` (current head).
`0019_thread_incarnations``0022_scheduled_occurrence_seq`
`0023_user_preferences` (current head). The preference revision adds a separate
owner/key table with a cascading users foreign key and does not alter users.
The incarnation revision deliberately retains the exact id audited by the
rollback-floor binary; Alembic orders revisions by `down_revision`, not by the
numeric prefix.

View File

@ -0,0 +1,26 @@
"""Persist browser-safe user preferences independently by key."""
import sqlalchemy as sa
from alembic import op
revision = "0023_user_preferences"
down_revision = "0022_scheduled_occurrence_seq"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Legacy bootstrap and create_all-based recovery may already have the table.
if sa.inspect(op.get_bind()).has_table("user_preferences"):
return
op.create_table(
"user_preferences",
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column("key", sa.String(40), primary_key=True),
sa.Column("value", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if sa.inspect(op.get_bind()).has_table("user_preferences"):
op.drop_table("user_preferences")

View File

@ -0,0 +1,12 @@
# User persistence
`user_preferences` stores independent `(user_id, key)` rows in the shared SQL
database. PATCH upserts only supplied keys in one transaction; `null` resets a
field, disjoint edits commute, and same-field writes are last-commit-wins.
The Gateway's `GET/PATCH /api/v1/auth/preferences` allows only notification
enablement, the default model, conversation mode, and reasoning effort. It
requires a browser session plus `X-Expected-User-Id` matching that session (a
stale-tab guard, never an authorization source); PAT, internal, and auth-disabled
callers are rejected. Never persist arbitrary agent context or credentials
through this API. See `backend/docs/API.md` for the HTTP contract.

View File

@ -13,7 +13,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, Index, String, text
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@ -30,6 +30,15 @@ from deerflow.persistence.base import Base
OAUTH_IDENTITY_INDEX_NAME = "idx_users_oauth_identity"
class UserPreferenceRow(Base):
"""Independent keys allow concurrent clients to patch disjoint preferences."""
__tablename__ = "user_preferences"
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
key: Mapped[str] = mapped_column(String(40), primary_key=True)
value: Mapped[object] = mapped_column(JSON, nullable=True)
class UserRow(Base):
__tablename__ = "users"

View File

@ -0,0 +1,25 @@
"""Durable per-user preferences; updates touch only explicitly supplied keys."""
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from deerflow.persistence.user.model import UserPreferenceRow
class UserPreferencesRepository:
def __init__(self, sessions):
self.sessions = sessions
async def get(self, user_id: str) -> dict:
async with self.sessions() as session:
rows = (await session.execute(select(UserPreferenceRow).where(UserPreferenceRow.user_id == user_id))).scalars()
return {row.key: row.value for row in rows}
async def patch(self, user_id: str, values: dict) -> None:
async with self.sessions() as session, session.begin():
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
# Consistent key order also avoids opposite-order row-lock cycles.
for key, value in sorted(values.items()):
statement = insert(UserPreferenceRow).values(user_id=user_id, key=key, value=value)
await session.execute(statement.on_conflict_do_update(index_elements=["user_id", "key"], set_={"value": statement.excluded.value}))

View File

@ -25,6 +25,7 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/mcp/AGENTS.md",
"backend/packages/harness/deerflow/models/AGENTS.md",
"backend/packages/harness/deerflow/persistence/migrations/AGENTS.md",
"backend/packages/harness/deerflow/persistence/user/AGENTS.md",
"backend/packages/harness/deerflow/reflection/AGENTS.md",
"backend/packages/harness/deerflow/skills/AGENTS.md",
"backend/packages/harness/deerflow/subagents/AGENTS.md",

View File

@ -131,6 +131,20 @@ def test_me_lists_all_route_permissions_when_authorization_disabled(client):
assert res.json()["permissions"] == _ALL_PERMISSIONS
def test_account_preferences_use_registered_auth_and_csrf_middleware(client):
path = "/api/v1/auth/preferences"
assert client.get(path, headers={"X-Expected-User-Id": "anonymous"}).status_code == 401
user = _initialize_admin(client).json()
headers = {"X-Expected-User-Id": user["id"]}
assert client.get(path, headers=headers).status_code == 200
assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 403
headers["X-CSRF-Token"] = client.cookies.get("csrf_token")
assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 204
assert client.get(path, headers=headers).json()["mode"] == "pro"
headers["X-Expected-User-Id"] = "another-user"
assert client.patch(path, headers=headers, json={"mode": "ultra"}).status_code == 409
def test_me_reuses_middleware_resolved_permissions(client, monkeypatch):
"""One provider decision per registered permission — /me reads the
AuthContext AuthMiddleware already stamped instead of re-resolving."""

View File

@ -12,9 +12,10 @@ import pytest
import pytest_asyncio
import sqlalchemy as sa
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.bootstrap import _get_alembic_config, _get_head_revision
from deerflow.persistence.bootstrap import _MIGRATIONS_DIR, _get_alembic_config
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
@ -83,8 +84,10 @@ async def _schema(engine):
)
async def test_occurrence_revision_is_single_head():
assert _get_head_revision() == REVISION
async def test_occurrence_revision_is_in_single_head_chain():
script = ScriptDirectory(str(_MIGRATIONS_DIR))
assert len(script.get_heads()) == 1
assert REVISION in {revision.revision for revision in script.walk_revisions()}
async def test_upgrade_preserves_legacy_rows_and_allocates_from_one(migration_database):

View File

@ -0,0 +1,150 @@
"""Owner isolation and disjoint concurrent preference updates."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user.preferences import UserPreferencesRepository
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def preference_repo(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/preferences.db")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as session:
session.add_all([UserRow(id="alice", email="alice@example.com"), UserRow(id="bob", email="bob@example.com")])
await session.commit()
yield UserPreferencesRepository(sessions)
await engine.dispose()
@pytest.mark.anyio
async def test_preferences_survive_repository_recreation_and_remain_owner_scoped(preference_repo):
repo = preference_repo
assert await repo.get("alice") == {}
await repo.patch("alice", {"model_name": "model-a", "notification_enabled": False})
assert await UserPreferencesRepository(repo.sessions).get("alice") == {"model_name": "model-a", "notification_enabled": False}
assert await repo.get("bob") == {}
await repo.patch("alice", {"model_name": None})
assert await repo.get("alice") == {"model_name": None, "notification_enabled": False}
@pytest.mark.anyio
async def test_disjoint_updates_do_not_erase_each_other(preference_repo):
await asyncio.gather(
preference_repo.patch("alice", {"model_name": "new-model"}),
preference_repo.patch("alice", {"notification_enabled": False}),
)
assert await preference_repo.get("alice") == {"model_name": "new-model", "notification_enabled": False}
@pytest.fixture
async def api(preference_repo, monkeypatch):
from app.gateway.routers import user_preferences
app = FastAPI()
app.include_router(user_preferences.router)
identity = SimpleNamespace(id="alice", source="session")
@app.middleware("http")
async def authenticated_session(request, call_next):
if identity.id:
request.state.user = SimpleNamespace(id=identity.id)
request.state.auth_source = identity.source
return await call_next(request)
monkeypatch.setattr(user_preferences, "_repository", lambda: preference_repo)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
client.headers["X-Expected-User-Id"] = "alice"
yield client, identity
@pytest.mark.anyio
async def test_preferences_api_partial_patch_reset_and_owner_isolation(api):
client, identity = api
path = "/api/v1/auth/preferences"
assert (await client.patch(path, json={"notification_enabled": False, "mode": "pro"})).status_code == 204
assert (await client.patch(path, json={"model_name": "model-a"})).status_code == 204
assert (await client.get(path)).json() == {"notification_enabled": False, "model_name": "model-a", "mode": "pro", "reasoning_effort": None}
assert (await client.patch(path, json={"mode": None})).status_code == 204
identity.id = "bob"
# A stale tab with Alice's expected identity must not write using Bob's cookie.
assert (await client.patch(path, json={"mode": "ultra"})).status_code == 409
assert (await client.get(path)).status_code == 409
client.headers["X-Expected-User-Id"] = "bob"
assert (await client.get(path)).json() == {"notification_enabled": None, "model_name": None, "mode": None, "reasoning_effort": None}
identity.id = "alice"
client.headers["X-Expected-User-Id"] = "alice"
assert (await client.get(path)).json()["notification_enabled"] is False
assert (await client.get(path)).json()["mode"] is None
@pytest.mark.anyio
@pytest.mark.parametrize("body", [{"notification_enabled": "false"}, {"mode": "invalid"}, {"reasoning_effort": "max"}, {"model_name": "a" * 201}, {"context": {"github_token": "not-a-real-token"}}, {"user_id": "bob"}])
async def test_preferences_api_rejects_invalid_and_unrelated_fields(api, body):
client, _ = api
assert (await client.patch("/api/v1/auth/preferences", json=body)).status_code == 422
@pytest.mark.anyio
@pytest.mark.parametrize("source", ["pat", "internal", "auth_disabled"])
async def test_preferences_requires_browser_session(api, source):
client, identity = api
identity.source = source
assert (await client.get("/api/v1/auth/preferences")).status_code == 403
assert (await client.patch("/api/v1/auth/preferences", json={})).status_code == 403
@pytest.mark.anyio
async def test_preferences_requires_authentication_and_expected_identity(api):
client, identity = api
del client.headers["X-Expected-User-Id"]
assert (await client.get("/api/v1/auth/preferences")).status_code == 422
client.headers["X-Expected-User-Id"] = "alice"
identity.id = None
assert (await client.get("/api/v1/auth/preferences")).status_code == 401
@pytest.mark.anyio
async def test_preferences_read_discards_only_malformed_fields(api, preference_repo):
client, _ = api
await preference_repo.patch("alice", {"notification_enabled": False, "mode": "invalid", "unknown": "ignored"})
assert (await client.get("/api/v1/auth/preferences")).json() == {"notification_enabled": False, "model_name": None, "mode": None, "reasoning_effort": None}
def test_preferences_migration_preserves_existing_users_and_downgrades(tmp_path):
import importlib
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
revision = importlib.import_module("deerflow.persistence.migrations.versions.0023_user_preferences")
engine = create_engine(f"sqlite:///{tmp_path}/migration.db")
with engine.begin() as connection:
UserRow.__table__.create(connection)
connection.execute(UserRow.__table__.insert().values(id="alice", email="alice@example.com"))
with Operations.context(MigrationContext.configure(connection)):
revision.upgrade()
assert "user_preferences" in inspect(connection).get_table_names()
connection.execute(text("INSERT INTO user_preferences (user_id, key, value) VALUES ('alice', 'mode', '\"pro\"')"))
revision.upgrade()
assert connection.execute(text("SELECT value FROM user_preferences WHERE user_id = 'alice'")).scalar() == '"pro"'
revision.downgrade()
revision.downgrade()
assert "user_preferences" not in inspect(connection).get_table_names()
assert connection.execute(text("SELECT email FROM users WHERE id = 'alice'")).scalar() == "alice@example.com"
engine.dispose()

File diff suppressed because one or more lines are too long

View File

@ -50,6 +50,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings, useThreadSettings } from "@/core/settings";
import { resolveThreadContext } from "@/core/settings/store";
import {
useThreadMetadata,
useThreadStream,
@ -457,9 +458,11 @@ export default function AgentChatPage() {
isUploading ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>
setSettings("context", context)
}
onContextChange={(context, options) => {
if (options?.automatic)
resolveThreadContext(threadId, context);
else setSettings("context", context);
}}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}

View File

@ -9,6 +9,7 @@ import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-ba
import { SettingsDialogHost } from "@/components/workspace/settings";
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
function parseSidebarOpenCookie(
value: string | undefined,
@ -32,18 +33,20 @@ export async function WorkspaceContent({
return (
<QueryClientProvider>
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
{children}
</SidebarInset>
</SidebarProvider>
<CommandPalette />
<SettingsDialogHost />
<WorkspaceSettingsDeepLink />
<Toaster position="top-center" />
<UserPreferencesBoundary>
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
{children}
</SidebarInset>
</SidebarProvider>
<CommandPalette />
<SettingsDialogHost />
<WorkspaceSettingsDeepLink />
<Toaster position="top-center" />
</UserPreferencesBoundary>
</QueryClientProvider>
);
}

View File

@ -51,6 +51,7 @@ import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useProject } from "@/core/projects";
import { useLocalSettings, useThreadSettings } from "@/core/settings";
import { resolveThreadContext } from "@/core/settings/store";
import { createThread } from "@/core/threads/api";
import {
useBranchThread,
@ -546,9 +547,11 @@ export default function ChatPage() {
isUploading ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>
setSettings("context", context)
}
onContextChange={(context, options) => {
if (options?.automatic)
resolveThreadContext(threadId, context);
else setSettings("context", context);
}}
onGoalChange={setLocalGoal}
onPrepareThread={ensureProjectThread}
onSubmit={handleSubmit}

View File

@ -332,13 +332,18 @@ export function InputBox({
defaultModelName?: string | null;
initialValue?: string;
onContextChange?: (
context: Omit<
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
},
// Explicit selections contain only the fields changed by that action,
// never the whole thread-resolved context (which may override the account).
context: Partial<
Omit<
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}
>,
options?: { automatic: boolean },
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onGoalChange?: (goal: GoalState | null) => void;
@ -593,11 +598,14 @@ export function InputBox({
return;
}
onContextChange?.({
...context,
model_name: nextModelName,
mode: nextMode,
});
onContextChange?.(
{
...context,
model_name: nextModelName,
mode: nextMode,
},
{ automatic: true },
);
}, [context, models, defaultModelName, onContextChange]);
const selectedModel = useMemo(() => {
@ -850,11 +858,13 @@ export function InputBox({
if (!model) {
return;
}
const mode = getResolvedMode(
context.mode,
model.supports_thinking ?? false,
);
onContextChange?.({
...context,
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
...(mode !== context.mode ? { mode } : {}),
});
setModelDialogOpen(false);
},
@ -867,7 +877,6 @@ export function InputBox({
return;
}
onContextChange?.({
...context,
mode: getResolvedMode(mode, supportThinking),
reasoning_effort:
mode === "ultra"
@ -879,7 +888,7 @@ export function InputBox({
: "minimal",
});
},
[disabled, onContextChange, context, polishingInput, supportThinking],
[disabled, onContextChange, polishingInput, supportThinking],
);
const handleReasoningEffortSelect = useCallback(
@ -888,11 +897,10 @@ export function InputBox({
return;
}
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[disabled, onContextChange, context, polishingInput],
[disabled, onContextChange, polishingInput],
);
const handleGoalCommand = useCallback(
@ -1138,14 +1146,17 @@ export function InputBox({
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
});
onContextChange?.(
{
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
},
{ automatic: true },
);
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
Promise.resolve(submit()).then(resolve).catch(reject);

View File

@ -0,0 +1,86 @@
import { z } from "zod";
const fields = {
notification_enabled: z.boolean().nullable(),
model_name: z.string().max(200).nullable(),
mode: z.enum(["flash", "thinking", "pro", "ultra"]).nullable(),
reasoning_effort: z.enum(["minimal", "low", "medium", "high"]).nullable(),
};
const schema = z.object(fields).partial();
export type Preferences = z.infer<typeof schema>;
/** Keep valid individual preferences; never copy arbitrary runtime context. */
export function parsePreferences(value: unknown): Preferences {
if (!value || typeof value !== "object") return {};
const valid: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
const parsed = field.safeParse((value as Record<string, unknown>)[key]);
if (parsed.success) valid[key] = parsed.data;
}
return schema.parse(valid);
}
interface SyncIO {
read: () => Promise<Preferences>;
patch: (value: Preferences) => Promise<void>;
apply: (value: Preferences) => void;
savePending: (value: Preferences) => void;
saveConfirmed?: (value: Preferences) => void;
}
/** One instance belongs to one account and one tab, including its outbox. */
export class PreferencesSync {
private stopped = false;
private running?: Promise<void>;
constructor(
private readonly io: SyncIO,
private confirmed: Preferences,
private pending: Preferences,
) {
this.publish();
}
private publish() {
this.io.apply({ ...this.confirmed, ...this.pending });
}
edit(value: Preferences) {
if (this.stopped) return;
this.pending = { ...this.pending, ...value };
this.io.savePending(this.pending);
this.publish();
}
stop() {
this.stopped = true;
}
flush(): Promise<void> {
if (this.stopped) return Promise.resolve();
this.running ??= this.run().finally(() => {
this.running = undefined;
});
return this.running;
}
private async run() {
const remote = await this.io.read();
if (this.stopped) return;
this.confirmed = remote;
this.io.saveConfirmed?.(this.confirmed);
this.publish();
while (!this.stopped && Object.keys(this.pending).length) {
const sent = { ...this.pending };
await this.io.patch(sent);
if (this.stopped) return;
this.confirmed = { ...this.confirmed, ...sent };
this.io.saveConfirmed?.(this.confirmed);
for (const key of Object.keys(sent) as (keyof Preferences)[]) {
if (this.pending[key] === sent[key]) delete this.pending[key];
}
this.io.savePending(this.pending);
this.publish();
}
}
}

View File

@ -8,6 +8,7 @@ import {
saveThreadModelName,
type LocalSettings,
} from "./local";
import type { Preferences } from "./preferences-sync";
type Listener = () => void;
@ -22,6 +23,42 @@ const threadModelNames = new Map<string, string | undefined>();
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
let baseSettingsLoaded = false;
let storageListenerRegistered = false;
let preferenceEdit:
| ((before: LocalSettings, after: LocalSettings) => void)
| undefined;
/** Activate only after the workspace has identified the account. */
export function activatePreferences(
initial: Preferences,
edit: (before: LocalSettings, after: LocalSettings) => void,
) {
ensureBaseSettingsLoaded();
preferenceEdit = edit;
const apply = (value: Preferences) => {
if (preferenceEdit !== edit) return;
baseSettings = {
...baseSettings,
notification: { enabled: value.notification_enabled ?? true },
context: {
...baseSettings.context,
model_name: value.model_name ?? undefined,
mode: value.mode ?? undefined,
reasoning_effort: value.reasoning_effort ?? undefined,
},
};
emitChange();
};
apply(initial);
return {
apply,
stop: () => {
if (preferenceEdit === edit) {
apply({});
preferenceEdit = undefined;
}
},
};
}
function emitChange() {
for (const listener of listeners) {
@ -29,6 +66,25 @@ function emitChange() {
}
}
function persistLocalOnlySettings() {
if (!preferenceEdit) {
saveLocalSettings(baseSettings);
return;
}
// Account preferences never leak back into the legacy shared-origin key.
const legacy = getLocalSettings();
saveLocalSettings({
...baseSettings,
notification: legacy.notification,
context: {
...baseSettings.context,
model_name: legacy.context.model_name,
mode: legacy.context.mode,
reasoning_effort: legacy.context.reasoning_effort,
},
});
}
function ensureBaseSettingsLoaded() {
if (baseSettingsLoaded || typeof window === "undefined") {
return;
@ -73,6 +129,23 @@ function mergeSettingsSection<K extends keyof LocalSettings>(
};
}
function readSharedSettings(): LocalSettings {
const local = getLocalSettings();
if (!preferenceEdit) return local;
// Device-local fields still follow other tabs, including key removal and
// storage.clear(). The legacy key must never replace account preferences.
return {
...local,
notification: baseSettings.notification,
context: {
...local.context,
model_name: baseSettings.context.model_name,
mode: baseSettings.context.mode,
reasoning_effort: baseSettings.context.reasoning_effort,
},
};
}
function handleStorage(event: StorageEvent) {
if (event.storageArea && event.storageArea !== localStorage) {
return;
@ -81,14 +154,14 @@ function handleStorage(event: StorageEvent) {
ensureBaseSettingsLoaded();
if (event.key === null) {
baseSettings = getLocalSettings();
baseSettings = readSharedSettings();
threadModelNames.clear();
emitChange();
return;
}
if (event.key === LOCAL_SETTINGS_KEY) {
baseSettings = getLocalSettings();
baseSettings = readSharedSettings();
emitChange();
return;
}
@ -131,8 +204,10 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
const previous = baseSettings;
baseSettings = mergeSettingsSection(baseSettings, key, value);
saveLocalSettings(baseSettings);
persistLocalOnlySettings();
preferenceEdit?.(previous, baseSettings);
emitChange();
};
@ -144,9 +219,11 @@ export function updateThreadSettings<K extends keyof LocalSettings>(
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
const previous = baseSettings;
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
baseSettings = nextBaseSettings;
saveLocalSettings(baseSettings);
persistLocalOnlySettings();
preferenceEdit?.(previous, baseSettings);
if (
key === "context" &&
@ -160,3 +237,22 @@ export function updateThreadSettings<K extends keyof LocalSettings>(
emitChange();
}
/** Model availability/default resolution is not an explicit account edit. */
export function resolveThreadContext(
threadId: string,
context: Partial<LocalSettings["context"]>,
) {
if (!preferenceEdit) {
updateThreadSettings(threadId, "context", context);
return;
}
baseSettings = mergeSettingsSection(baseSettings, "context", context);
// Preserve explicit thread overrides, but do not create one from a temporary
// fallback: it would mask the account model when a slow GET finally arrives.
if (getThreadModelSnapshot(threadId) && "model_name" in context) {
threadModelNames.set(threadId, context.model_name);
saveThreadModelName(threadId, context.model_name);
}
emitChange();
}

View File

@ -0,0 +1,29 @@
"use client";
import { useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { useAuth } from "@/core/auth/AuthProvider";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { startUserPreferences } from "./user-preferences";
const useClientLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
export function UserPreferencesBoundary({ children }: { children: ReactNode }) {
const { user } = useAuth();
const owner =
!isStaticWebsiteOnly() && user?.id !== "default" ? user?.id : undefined;
// SSR and initial hydration render the same workspace with the settings
// store's safe server snapshot. Initialize the browser cache before paint.
const [activeOwner, setActiveOwner] = useState(owner);
useClientLayoutEffect(() => {
const stop = owner ? startUserPreferences(owner) : undefined;
setActiveOwner(owner);
return stop;
}, [owner]);
// Only gate a subsequent account switch, keeping the old owner's settings
// out of the new owner's consumers until the new cache is activated.
if (owner !== activeOwner) return null;
return children;
}

View File

@ -0,0 +1,143 @@
import { fetch as apiFetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { safeLocalStorage, type LocalSettings } from "./local";
import {
parsePreferences,
PreferencesSync,
type Preferences,
} from "./preferences-sync";
import { activatePreferences } from "./store";
const PREFIX = "deerflow.preferences.";
function readJSON(read: () => string | null): Preferences {
try {
return parsePreferences(JSON.parse(read() ?? "{}"));
} catch {
return {};
}
}
export function preferencesFromSettings(settings: LocalSettings): Preferences {
return {
notification_enabled: settings.notification.enabled,
model_name: settings.context.model_name ?? null,
mode: settings.context.mode ?? null,
reasoning_effort: settings.context.reasoning_effort ?? null,
};
}
/** Network lifecycle is attached to the authenticated workspace, never render. */
export function startUserPreferences(userId: string) {
const key = `${PREFIX}${userId}`;
// sessionStorage is tab-local; a sibling tab cannot overwrite our outbox.
const outbox = `${key}.pending`;
const abort = new AbortController();
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let retryMs = 1000;
let flushing = false;
const request = async (method: "GET" | "PATCH", value?: Preferences) => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/v1/auth/preferences`,
{
method,
headers: {
"Content-Type": "application/json",
"X-Expected-User-Id": userId,
},
body: value ? JSON.stringify(value) : undefined,
signal: AbortSignal.any([abort.signal, AbortSignal.timeout(15000)]),
cache: "no-store",
},
);
if (response.status === 409 || response.status === 403) {
// A cookie can switch accounts in another tab before useAuth updates.
// Keep this account's outbox, but never retry it under the new identity.
stop();
}
if (!response.ok)
throw new Error(`Preferences request failed: ${response.status}`);
return response;
};
let apply: (value: Preferences) => void = () => undefined;
const cached = readJSON(() => safeLocalStorage.getItem(key));
const pending = readJSON(() => window.sessionStorage.getItem(outbox));
const sync = new PreferencesSync(
{
read: async () => parsePreferences(await (await request("GET")).json()),
patch: async (value) => {
await request("PATCH", value);
},
saveConfirmed: (value) => {
const serialized = JSON.stringify(value);
if (safeLocalStorage.getItem(key) !== serialized)
safeLocalStorage.setItem(key, serialized);
},
apply: (value) => apply(value),
savePending: (value) => {
try {
window.sessionStorage.setItem(outbox, JSON.stringify(value));
} catch {}
},
},
cached,
pending,
);
const wake = () => {
if (stopped || flushing) return;
clearTimeout(timer);
flushing = true;
void sync
.flush()
.then(() => {
retryMs = 1000;
})
.catch(() => {
if (stopped) return;
timer = setTimeout(wake, retryMs);
retryMs = Math.min(retryMs * 2, 30000);
})
.finally(() => {
flushing = false;
});
};
const detach = activatePreferences(
{ ...cached, ...pending },
(before, after) => {
const oldValue = preferencesFromSettings(before);
const newValue = preferencesFromSettings(after);
const changes = Object.fromEntries(
Object.entries(newValue).filter(
([field, value]) => oldValue[field as keyof Preferences] !== value,
),
);
if (Object.keys(changes).length) {
sync.edit(parsePreferences(changes));
wake();
}
},
);
apply = detach.apply;
const onStorage = (event: StorageEvent) => {
if (event.key === key || event.key === null) wake();
};
function stop() {
if (stopped) return;
stopped = true;
clearTimeout(timer);
abort.abort();
sync.stop();
detach.stop();
window.removeEventListener("focus", wake);
window.removeEventListener("online", wake);
window.removeEventListener("storage", onStorage);
}
window.addEventListener("focus", wake);
window.addEventListener("online", wake);
window.addEventListener("storage", onStorage);
wake();
return stop;
}

View File

@ -0,0 +1,294 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
for (const agent of [false, true]) {
for (const field of ["effort", "mode"] as const) {
test(`${agent ? "agent" : "normal"} thread ${field} selection preserves the account model`, async ({
page,
}) => {
mockLangGraphAPI(page, {
agents: [{ name: "researcher", description: "Research agent" }],
threads: [
{
thread_id: MOCK_THREAD_ID,
agent_name: agent ? "researcher" : undefined,
},
],
});
await page.addInitScript(
({ threadId }) => {
localStorage.setItem(
`deerflow.thread-model.${threadId}`,
"thread-model",
);
},
{ threadId: MOCK_THREAD_ID },
);
await page.route("**/api/models", (route) =>
route.fulfill({
json: {
models: [
{
name: "account-model",
display_name: "Account Model",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
name: "thread-model",
display_name: "Thread Model",
supports_thinking: true,
supports_reasoning_effort: true,
},
],
},
}),
);
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: "00000000-0000-0000-0000-000000000028",
email: "thread@example.com",
system_role: "admin",
needs_setup: false,
},
}),
);
const patches: unknown[] = [];
let reads = 0;
let server = {
model_name: "account-model",
mode: "pro",
reasoning_effort: "medium",
notification_enabled: true,
};
await page.route("**/api/v1/auth/preferences", async (route) => {
if (route.request().method() === "PATCH") {
const patch = route.request().postDataJSON() as Partial<
typeof server
>;
patches.push(patch);
server = { ...server, ...patch };
await route.fulfill({ status: 204 });
} else {
reads++;
await route.fulfill({ json: server });
}
});
await page.goto(
`/workspace/${agent ? "agents/researcher/chats" : "chats"}/${MOCK_THREAD_ID}`,
);
await page
.locator("[data-sidebar='sidebar']")
.getByRole("button", { name: /Settings and more/ })
.click();
await page.keyboard.press("Escape");
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect.poll(() => reads).toBe(1);
await expect(
page.getByRole("button", { name: "Thread Model", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: "Reasoning Effort: Medium",
exact: true,
}),
).toBeVisible();
if (field === "effort") {
await page
.getByRole("button", {
name: "Reasoning Effort: Medium",
exact: true,
})
.click();
await page.getByRole("menuitem").filter({ hasText: /^High/ }).click();
} else {
await page.getByRole("button", { name: "Pro", exact: true }).click();
await page
.getByRole("menuitem")
.filter({ hasText: /^Ultra/ })
.click();
}
await expect
.poll(() => patches)
.toEqual([
field === "effort"
? { reasoning_effort: "high" }
: { mode: "ultra", reasoning_effort: "high" },
]);
expect(server.model_name).toBe("account-model");
await page.keyboard.press("Escape");
await expect(
page.getByRole("button", { name: "Thread Model", exact: true }),
).toBeVisible();
});
}
}
test("custom agent automatic default does not become an account preference", async ({
page,
}) => {
mockLangGraphAPI(page, {
agents: [{ name: "researcher", description: "Research agent" }],
});
const patches: unknown[] = [];
let reads = 0;
let allowModels!: () => void;
const modelsReady = new Promise<void>((resolve) => {
allowModels = resolve;
});
await page.route("**/api/agents/researcher", (route) =>
route.fulfill({
json: {
name: "researcher",
description: "Research agent",
model: "agent-model",
system_prompt: "Research",
tools: [],
skills: [],
},
}),
);
await page.route("**/api/models", async (route) => {
// Do not let the auth-disabled fixture select a model before the account
// is activated. The real agent page must then choose its configured model.
await modelsReady;
await route.fulfill({
json: {
models: [
{
name: "first-model",
display_name: "First Model",
supports_thinking: true,
},
{
name: "agent-model",
display_name: "Agent Model",
supports_thinking: true,
},
],
},
});
});
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: "00000000-0000-0000-0000-000000000027",
email: "agent@example.com",
system_role: "admin",
needs_setup: false,
},
}),
);
await page.route("**/api/v1/auth/preferences", async (route) => {
if (route.request().method() === "PATCH") {
patches.push(route.request().postDataJSON());
await route.fulfill({ status: 204 });
} else {
reads++;
await route.fulfill({
json: {
model_name: null,
mode: null,
reasoning_effort: null,
notification_enabled: true,
},
});
}
});
await page.goto("/workspace/agents/researcher/chats/new");
await page
.locator("[data-sidebar='sidebar']")
.getByRole("button", { name: /Settings and more/ })
.click();
await page.keyboard.press("Escape");
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect.poll(() => reads).toBe(1);
allowModels();
await expect(
page.getByRole("button", { name: "Agent Model", exact: true }),
).toBeVisible();
// Flush the local outbox cycle through a completed server read, so this also
// catches a PATCH that would otherwise arrive just after the UI assertion.
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect.poll(() => reads).toBe(2);
expect(patches).toEqual([]);
// Explicit model selections on this same page must still be synchronized.
await page.getByRole("button", { name: "Agent Model", exact: true }).click();
await page.getByRole("option").filter({ hasText: "First Model" }).click();
await expect.poll(() => patches).toEqual([{ model_name: "first-model" }]);
});
test("automatic model fallback cannot overwrite a slowly loaded account preference", async ({
page,
}) => {
mockLangGraphAPI(page);
const patches: unknown[] = [];
let requested = false;
let release!: () => void;
const delayed = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/api/models", (route) =>
route.fulfill({
json: {
models: [
{ name: "model-a", display_name: "Model A", supports_thinking: true },
{ name: "model-b", display_name: "Model B", supports_thinking: true },
],
},
}),
);
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: "00000000-0000-0000-0000-000000000026",
email: "model@example.com",
system_role: "admin",
needs_setup: false,
},
}),
);
await page.route("**/api/v1/auth/preferences", async (route) => {
if (route.request().method() === "PATCH") {
patches.push(route.request().postDataJSON());
await route.fulfill({ status: 204 });
return;
}
requested = true;
await delayed;
await route.fulfill({
json: {
model_name: "model-b",
mode: "pro",
reasoning_effort: "high",
notification_enabled: true,
},
});
});
await page.goto("/workspace/chats/new");
// Wait for actual hydration before refreshing the auth-disabled fixture's
// AuthProvider into a session account with a deliberately slow preference GET.
await page
.locator("[data-sidebar='sidebar']")
.getByRole("button", { name: /Settings and more/ })
.click();
await page.keyboard.press("Escape");
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect.poll(() => requested).toBe(true);
await expect(
page.getByRole("button", { name: "Model A", exact: true }),
).toBeVisible();
release();
await expect(
page.getByRole("button", { name: "Model B", exact: true }),
).toBeVisible();
expect(patches).toEqual([]);
});

View File

@ -0,0 +1,86 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test("account notification setting survives clearing browser storage", async ({
page,
}) => {
mockLangGraphAPI(page);
const owner = "00000000-0000-0000-0000-000000000025";
let enabled: boolean | null = null;
const patches: unknown[] = [];
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: owner,
email: "preferences@example.com",
system_role: "admin",
needs_setup: false,
},
}),
);
await page.route("**/api/v1/auth/preferences", async (route) => {
expect(route.request().headers()["x-expected-user-id"]).toBe(owner);
if (route.request().method() === "PATCH") {
const patch = route.request().postDataJSON() as {
notification_enabled: boolean;
};
patches.push(patch);
enabled = patch.notification_enabled;
await route.fulfill({ status: 204 });
} else {
await route.fulfill({
json: {
notification_enabled: enabled,
model_name: null,
mode: null,
reasoning_effort: null,
},
});
}
});
await page.addInitScript(() => {
Object.defineProperty(Notification, "permission", {
configurable: true,
get: () => "granted",
});
});
async function openSettings(page: Page) {
await page.goto("/workspace/chats/new");
await page
.locator("[data-sidebar='sidebar']")
.getByRole("button", { name: /Settings and more/ })
.click();
await page.getByRole("menuitem", { name: "Settings", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
await dialog
.getByRole("button", { name: "Notification", exact: true })
.click();
// Opening the dialog also waits for hydration. The mock web server starts
// auth-disabled; refresh the real AuthProvider to this session fixture.
const hydrated = page.waitForResponse((response) =>
response.url().endsWith("/api/v1/auth/preferences"),
);
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await hydrated;
// Account resolution remounts the dialog at its default section.
await dialog
.getByRole("button", { name: "Notification", exact: true })
.click();
return dialog.getByRole("switch", { name: "Notification", exact: true });
}
const toggle = await openSettings(page);
await expect(toggle).toBeChecked();
await toggle.click();
await expect.poll(() => enabled).toBe(false);
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await expect(await openSettings(page)).not.toBeChecked();
expect(patches).toEqual([{ notification_enabled: false }]);
});

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from "@rstest/core";
import {
PreferencesSync,
type Preferences,
} from "@/core/settings/preferences-sync";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function setup(
read: () => Promise<Preferences>,
patch: (value: Preferences) => Promise<void>,
) {
let visible: Preferences = {};
let pending: Preferences = {};
const sync = new PreferencesSync(
{
read,
patch,
apply: (value) => {
visible = value;
},
savePending: (value) => {
pending = value;
},
},
{},
{},
);
return { sync, visible: () => visible, pending: () => pending };
}
describe("account preference synchronization", () => {
it("retries a failed bootstrap and preserves edits made while loading", async () => {
let attempts = 0;
const writes: Preferences[] = [];
const state = setup(
async () => {
if (++attempts === 1) throw new Error("offline");
return { mode: "pro", notification_enabled: true };
},
async (value) => {
writes.push(value);
},
);
await expect(state.sync.flush()).rejects.toThrow("offline");
state.sync.edit({ notification_enabled: false });
await state.sync.flush();
expect(writes).toEqual([{ notification_enabled: false }]);
expect(state.visible()).toEqual({
mode: "pro",
notification_enabled: false,
});
expect(state.pending()).toEqual({});
});
it("does not let an older acknowledgement erase a newer edit", async () => {
const ack = deferred<void>();
const writes: Preferences[] = [];
const state = setup(
async () => ({}),
async (value) => {
writes.push(value);
if (writes.length === 1) await ack.promise;
},
);
await state.sync.flush();
state.sync.edit({ mode: "pro" });
const flushing = state.sync.flush();
await Promise.resolve();
state.sync.edit({ mode: "flash" });
ack.resolve();
await flushing;
expect(writes).toEqual([{ mode: "pro" }, { mode: "flash" }]);
expect(state.visible().mode).toBe("flash");
expect(state.pending()).toEqual({});
});
it("ignores a late read after stopping an account", async () => {
const read = deferred<Preferences>();
const state = setup(
() => read.promise,
async () => undefined,
);
const loading = state.sync.flush();
state.sync.stop();
read.resolve({ model_name: "alice-only" });
await loading;
expect(state.visible()).toEqual({});
});
});

View File

@ -0,0 +1,35 @@
import { expect, rs, test } from "@rstest/core";
import { renderToString } from "react-dom/server";
import { useLocalSettings } from "@/core/settings/hooks";
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
const mocks = rs.hoisted(() => ({ start: rs.fn() }));
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: () => ({ user: { id: "alice" } }),
}));
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
rs.mock("@/core/settings/user-preferences", () => ({
startUserPreferences: mocks.start,
}));
function Workspace() {
const [settings] = useLocalSettings();
return (
<main>
<nav>Workspace sidebar</nav>
<output>{settings.context.model_name ?? "default"}</output>
</main>
);
}
test("signed-in workspace content remains in server HTML without starting browser sync", () => {
const html = renderToString(
<UserPreferencesBoundary>
<Workspace />
</UserPreferencesBoundary>,
);
expect(html).toContain("<nav>Workspace sidebar</nav>");
expect(html).toContain("<output>default</output>");
expect(mocks.start).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,313 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { useSyncExternalStore } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import {
DEFAULT_LOCAL_SETTINGS,
getLocalSettings,
LOCAL_SETTINGS_KEY,
} from "@/core/settings/local";
import {
getBaseSettingsSnapshot,
getThreadModelSnapshot,
resolveThreadContext,
subscribe,
updateLocalSettings,
} from "@/core/settings/store";
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
const mocks = rs.hoisted(() => ({
user: { id: "alice" },
fetch: rs.fn(),
}));
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: () => ({ user: mocks.user }),
}));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
function Consumer() {
const settings = useSyncExternalStore(
subscribe,
getBaseSettingsSnapshot,
() => DEFAULT_LOCAL_SETTINGS,
);
return (
<output>{`${settings.notification.enabled}:${settings.context.model_name ?? "default"}:${settings.context.mode ?? "default"}`}</output>
);
}
function Workspace() {
return (
<UserPreferencesBoundary>
<Consumer />
</UserPreferencesBoundary>
);
}
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
mocks.user = { id: "alice" };
mocks.fetch.mockReset();
});
afterEach(() => {
cleanup();
});
describe("authenticated workspace preferences", () => {
it.each(["write", "remove", "clear"] as const)(
"merges local-only settings on a cross-tab %s without changing account preferences",
async (operation) => {
const account = {
notification_enabled: false,
model_name: "account-model",
mode: "pro",
reasoning_effort: "high",
};
mocks.fetch.mockImplementation(async () => Response.json(account));
render(<Workspace />);
await screen.findByText("false:account-model:pro");
act(() => {
updateLocalSettings("projectsDisplayMode", "grouped");
updateLocalSettings("tokenUsage", {
headerTotal: false,
inlineMode: "off",
});
});
act(() => {
if (operation === "write") {
localStorage.setItem(
LOCAL_SETTINGS_KEY,
JSON.stringify({
projectsDisplayMode: "flat",
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
notification: { enabled: true },
context: {
model_name: "legacy-model",
mode: "flash",
reasoning_effort: "low",
},
}),
);
} else if (operation === "remove") {
localStorage.removeItem(LOCAL_SETTINGS_KEY);
} else {
localStorage.clear();
}
window.dispatchEvent(
new StorageEvent("storage", {
key: operation === "clear" ? null : LOCAL_SETTINGS_KEY,
storageArea: localStorage,
}),
);
});
expect(getBaseSettingsSnapshot()).toMatchObject({
projectsDisplayMode: "flat",
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
notification: { enabled: false },
context: {
model_name: "account-model",
mode: "pro",
reasoning_effort: "high",
},
});
// A later unrelated write must not restore the stale display setting.
act(() => updateLocalSettings("tokenUsage", { headerTotal: false }));
expect(getLocalSettings().projectsDisplayMode).toBe("flat");
await act(async () => {
await Promise.resolve();
});
expect(
mocks.fetch.mock.calls.every(([, init]) => init.method === "GET"),
).toBe(true);
expect(
sessionStorage.getItem("deerflow.preferences.alice.pending"),
).toBeNull();
},
);
it("hydrates server HTML from the correct account cache without mismatches", async () => {
localStorage.setItem(
LOCAL_SETTINGS_KEY,
JSON.stringify({ context: { model_name: "legacy-other-account" } }),
);
localStorage.setItem(
"deerflow.preferences.alice",
JSON.stringify({
notification_enabled: false,
model_name: "alice-cached",
}),
);
mocks.fetch.mockImplementation(
() => new Promise<Response>(() => undefined),
);
const container = document.createElement("div");
container.innerHTML = renderToString(<Workspace />);
expect(container.textContent).toBe("true:default:default");
document.body.appendChild(container);
const errors: unknown[] = [];
let root!: Root;
try {
await act(async () => {
root = hydrateRoot(container, <Workspace />, {
onRecoverableError: (error) => {
errors.push(error);
},
});
});
expect(container.textContent).toBe("false:alice-cached:default");
expect(errors).toEqual([]);
} finally {
act(() => root.unmount());
container.remove();
}
});
it("does not upload automatic model fallback or mask a later server model", async () => {
let finish!: (response: Response) => void;
mocks.fetch.mockImplementation(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
render(<Workspace />);
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
act(() =>
resolveThreadContext("slow-hydration", {
model_name: "fallback",
mode: "pro",
}),
);
expect(
sessionStorage.getItem("deerflow.preferences.alice.pending"),
).toBeNull();
expect(getThreadModelSnapshot("slow-hydration")).toBeUndefined();
finish(Response.json({ model_name: "server-choice", mode: "ultra" }));
await screen.findByText("true:server-choice:ultra");
expect(mocks.fetch).toHaveBeenCalledTimes(1);
});
it("restores server preferences after clearing browser storage and sends only edited fields", async () => {
let server = {
notification_enabled: false,
model_name: "saved-model",
mode: "pro",
};
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
expect(new Headers(init.headers).get("X-Expected-User-Id")).toBe("alice");
if (init.method === "PATCH") {
expect(JSON.parse(init.body as string)).toEqual({
notification_enabled: true,
});
server = { ...server, ...JSON.parse(init.body as string) };
return new Response(null, { status: 204 });
}
return Response.json(server);
});
const view = render(<Workspace />);
await screen.findByText("false:saved-model:pro");
act(() => updateLocalSettings("notification", { enabled: true }));
await waitFor(() => expect(server.notification_enabled).toBe(true));
await waitFor(() =>
expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe(
"{}",
),
);
view.unmount();
localStorage.clear();
render(<Workspace />);
await screen.findByText("true:saved-model:pro");
});
it("does not import legacy preferences or expose the previous account on a switch", async () => {
localStorage.setItem(
LOCAL_SETTINGS_KEY,
JSON.stringify({
notification: { enabled: false },
context: { model_name: "legacy-secret" },
}),
);
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
expect(init.method).toBe("GET");
const owner = new Headers(init.headers).get("X-Expected-User-Id");
return Response.json(
owner === "alice" ? { model_name: "alice-model" } : {},
);
});
const view = render(<Workspace />);
await screen.findByText("true:alice-model:default");
mocks.user = { id: "bob" };
view.rerender(<Workspace />);
expect(screen.queryByText("true:alice-model:default")).toBeNull();
await screen.findByText("true:default:default");
expect(localStorage.getItem(LOCAL_SETTINGS_KEY)).toContain("legacy-secret");
});
it("retries failed initialization on reconnect and retains pending changes across reload", async () => {
mocks.fetch.mockRejectedValue(new Error("offline"));
const view = render(<Workspace />);
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
act(() => updateLocalSettings("context", { mode: "ultra" }));
expect(
JSON.parse(sessionStorage.getItem("deerflow.preferences.alice.pending")!),
).toEqual({ mode: "ultra" });
view.unmount();
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
if (init.method === "PATCH") {
expect(JSON.parse(init.body as string)).toEqual({ mode: "ultra" });
return new Response(null, { status: 204 });
}
return Response.json({ model_name: "remote" });
});
render(<Workspace />);
act(() => {
window.dispatchEvent(new Event("online"));
});
await screen.findByText("true:remote:ultra");
await waitFor(() =>
expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe(
"{}",
),
);
});
it("does not write an unchanged cache back in response to a storage event", async () => {
mocks.fetch.mockResolvedValue(Response.json({ mode: "pro" }));
// Each HTTP request needs its own readable Response body.
mocks.fetch.mockImplementation(async () => Response.json({ mode: "pro" }));
render(<Workspace />);
await screen.findByText("true:default:pro");
const writes = rs.spyOn(Storage.prototype, "setItem");
act(() => {
window.dispatchEvent(
new StorageEvent("storage", { key: "deerflow.preferences.alice" }),
);
});
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(2));
expect(writes).not.toHaveBeenCalled();
writes.mockRestore();
});
it("stops requests when another tab changes the authenticated account", async () => {
sessionStorage.setItem(
"deerflow.preferences.alice.pending",
JSON.stringify({ mode: "ultra" }),
);
mocks.fetch.mockImplementation(
async () => new Response(null, { status: 409 }),
);
render(<Workspace />);
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
await act(async () => {
await Promise.resolve();
});
act(() => {
window.dispatchEvent(new Event("focus"));
});
expect(mocks.fetch).toHaveBeenCalledTimes(1);
expect(
sessionStorage.getItem("deerflow.preferences.alice.pending"),
).toContain("ultra");
});
});