Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a0fe38
feat(prefs): ui_mode, ui_start and theme_variant are per-user prefere…
mrveiss Oct 11, 2026
9478f53
feat(ui): one brand mark, an accessible tooltip and one screen-reader…
mrveiss Oct 11, 2026
31183d6
feat(ui-mode): Minimal / Full interface mode is one singleton that fo…
mrveiss Oct 11, 2026
da138ff
feat(i18n): strings for the refined shell and Settings in all 11 loca…
mrveiss Oct 11, 2026
c733ed2
feat(settings): Appearance has Preferred mode and Additional themes (…
mrveiss Oct 11, 2026
afcb46d
feat(shell): App.vue's header is AppHeader, TopNavLinks, NavIconClust…
mrveiss Oct 11, 2026
0241844
refactor(theme): data-theme has one writer, useTheme, and a guard kee…
mrveiss Oct 11, 2026
2101706
chore(routes): retire the unused config/routes.ts (#18240)
mrveiss Oct 11, 2026
7b767a4
fix(a11y): name the UI-mode toggle at every width, make the tooltip h…
mrveiss Oct 12, 2026
aa80fa6
fix(appearance): the account's start mode and theme variant apply onl…
mrveiss Oct 12, 2026
5fcf8de
fix(appearance): every account appearance field applies only at a fre…
mrveiss Oct 12, 2026
ee4e61c
fix(tooling): resolve @autobot/ui to the in-tree kit in app vite/vite…
mrveiss Oct 12, 2026
c102db0
test(tooling): drop the Vue dedupe test, which passes with or without…
mrveiss Oct 12, 2026
f0e5ab2
test(prefs): PATCH /users/me/preferences refuses explicit null and un…
mrveiss Oct 12, 2026
13e74ff
fix(ui): autobotUiResolve aliases each package.json export exactly, s…
mrveiss Oct 12, 2026
b7030e1
fix(profile): ProfileModal reads --color-primary without a hex fallba…
mrveiss Oct 12, 2026
96e803c
fix(types): regenerate api.ts for the partial PATCH /users/me/prefere…
mrveiss Oct 12, 2026
e9cf083
fix(guards): the 18240 guards read code through tools.lint._comment_s…
mrveiss Oct 12, 2026
fbb50bf
test(frontend): warm the cold module import once in beforeAll, so the…
mrveiss Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions autobot-backend/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,37 @@ class UserPreferences(BaseModel):
max_length=64,
description="Selected named theme preset (e.g. catppuccin-mocha); 'auto' when none",
)
ui_mode: str = Field(
"full",
pattern=r"^(minimal|full)\z",
description="Shell UI mode (minimal, full)",
)
ui_start: str = Field(
"last",
pattern=r"^(last|minimal|full)\z",
description="Which UI mode to open in on sign-in (last, minimal, full)",
)
theme_variant: str = Field(
"default",
max_length=64,
pattern=r"^[a-z0-9][a-z0-9_-]{0,62}\z", # same charset as theme_install._ID
description="Theme variant id: 'default', a built-in, or an installed theme id",
)
# Additional preferences can be added here in the future


# Appearance fields persisted alongside reasoning_effort (#8988).
_APPEARANCE_FIELDS = ("theme", "accent_color", "layout_density", "font_size", "theme_preset")
_APPEARANCE_FIELDS = (
"theme",
"accent_color",
"layout_density",
"font_size",
"theme_preset",
"ui_mode",
"ui_start",
"theme_variant",
)
_ALL_FIELDS = ("reasoning_effort", *_APPEARANCE_FIELDS)


class UserPreferencesData(BaseModel):
Expand Down Expand Up @@ -100,7 +126,7 @@ async def _read(field: str) -> str | None:

defaults = UserPreferences()
values: Dict[str, str] = {}
for field in ("reasoning_effort", *_APPEARANCE_FIELDS):
for field in _ALL_FIELDS:
stored = await _read(field)
values[field] = stored if stored is not None else getattr(defaults, field)

Expand All @@ -112,21 +138,24 @@ async def _read(field: str) -> str | None:
return UserPreferences()


async def _store_user_preferences_to_redis(user_id: str, preferences: UserPreferences) -> None:
async def _store_user_preferences_to_redis(
user_id: str, preferences: UserPreferences, fields: tuple[str, ...] | None = None
) -> None:
"""
Store user preferences to Redis.

Args:
user_id: User ID
preferences: UserPreferences object to store
fields: Only these fields are written (partial PATCH); None writes all

Raises:
RedisError: If Redis operation fails
"""
redis_client = await get_async_redis_client(database=RedisDatabase.MAIN)

# Store each preference under its own key (no expiration - permanent preference)
for field in ("reasoning_effort", *_APPEARANCE_FIELDS):
for field in fields if fields is not None else _ALL_FIELDS:
await redis_client.set(f"user:{user_id}:preferences:{field}", getattr(preferences, field))

logger.info(
Expand Down Expand Up @@ -178,10 +207,10 @@ async def update_user_preferences(
current_user: Dict = Depends(get_current_user),
) -> DataResponse[UserPreferencesData]:
"""
Update current user's preferences.
Update current user's preferences (partial: omitted fields keep stored values).

Args:
preferences: New preference values
preferences: Preference values; only fields present in the body are written

Returns:
DataResponse with updated preferences
Expand All @@ -195,7 +224,9 @@ async def update_user_preferences(
raise HTTPException(status_code=401, detail="User ID not found in token")

try:
await _store_user_preferences_to_redis(user_id, preferences)
sent = tuple(f for f in _ALL_FIELDS if f in preferences.model_fields_set)
await _store_user_preferences_to_redis(user_id, preferences, sent)
merged = await _get_user_preferences_from_redis(user_id)
except RedisError as e:
logger.error(f"Failed to update user preferences: {e}")
raise HTTPException(status_code=500, detail="Failed to update preferences")
Expand All @@ -204,7 +235,7 @@ async def update_user_preferences(
success=True,
data=UserPreferencesData(
user_id=user_id,
preferences=preferences,
preferences=merged,
),
message="User preferences updated successfully",
)
178 changes: 178 additions & 0 deletions autobot-backend/tests/api/test_user_preferences_partial_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""PATCH /users/me/preferences is truly partial; ui_mode/ui_start/theme_variant (#18240)."""

from unittest.mock import AsyncMock, MagicMock, patch

import pydantic
import pytest

from api.users import UserPreferences, get_user_preferences, update_user_preferences

_USER = {"user_id": "u-partial"}


def _fake_redis():
store: dict[str, str] = {}

async def _set(key, value):
store[key] = value

async def _get(key):
return store.get(key)

client = AsyncMock()
client.set.side_effect = _set
client.get.side_effect = _get
return client, store


async def _patch(body: dict, client):
prefs = UserPreferences(**body)
with patch("api.users.get_async_redis_client", new=AsyncMock(return_value=client)):
res = await update_user_preferences(prefs, MagicMock(), _USER)
return res.data.preferences


@pytest.mark.asyncio
async def test_omitted_field_keeps_stored_value_but_sent_field_changes():
client, store = _fake_redis()
await _patch({"theme": "dark", "ui_mode": "minimal"}, client)
# Contrast pair: omitting ui_mode leaves it; sending it changes it.
omitted = await _patch({"font_size": "large"}, client)
assert omitted.ui_mode == "minimal" and omitted.theme == "dark"
assert omitted.font_size == "large"
sent = await _patch({"ui_mode": "full"}, client)
assert sent.ui_mode == "full" and sent.theme == "dark"
assert store["user:u-partial:preferences:ui_mode"] == "full"


@pytest.mark.asyncio
async def test_empty_patch_writes_nothing():
client, _ = _fake_redis()
await _patch({}, client)
client.set.assert_not_awaited()


@pytest.mark.asyncio
async def test_get_returns_defaults_when_unset():
client, _ = _fake_redis()
with patch("api.users.get_async_redis_client", new=AsyncMock(return_value=client)):
res = await get_user_preferences(MagicMock(), _USER)
p = res.data.preferences
assert (p.ui_mode, p.ui_start, p.theme_variant) == ("full", "last", "default")


@pytest.mark.parametrize(
"field,value",
[
("ui_mode", "minimal"),
("ui_mode", "full"),
("ui_start", "last"),
("ui_start", "minimal"),
("theme_variant", "default"),
("theme_variant", "ember-2_x"),
("theme_variant", "a" * 63),
],
)
def test_new_fields_accept_good(field, value):
assert getattr(UserPreferences(**{field: value}), field) == value


@pytest.mark.parametrize(
"field,value",
[
("ui_mode", "last"),
("ui_mode", "full\n"),
("ui_mode", ""),
("ui_start", "other"),
("ui_start", "last\n"),
("theme_variant", "default\n"),
("theme_variant", "Ember"),
("theme_variant", "-x"),
("theme_variant", "../x"),
("theme_variant", "a" * 64),
("theme_variant", ""),
],
)
def test_new_fields_reject_bad(field, value):
with pytest.raises(pydantic.ValidationError):
UserPreferences(**{field: value})


# --- HTTP-level: the real route, 422 validation and the auth dependency ---------

_URL = "/api/users/me/preferences"


def _http_app(authenticated: bool = True):
from fastapi import FastAPI

from api import users as users_api
from auth_middleware import get_current_user

app = FastAPI()
app.include_router(users_api.router, prefix="/api")
if authenticated:
app.dependency_overrides[get_current_user] = lambda: _USER
return app


def _http_client(app, redis_client):
from fastapi.testclient import TestClient

return TestClient(app), patch("api.users.get_async_redis_client", new=AsyncMock(return_value=redis_client))


@pytest.mark.parametrize("field", ["ui_mode", "theme"])
def test_http_explicit_null_is_422_and_stored_value_unchanged(field):
client, store = _fake_redis()
store[f"user:u-partial:preferences:{field}"] = "minimal" if field == "ui_mode" else "dark"
before = dict(store)
tc, redis_patch = _http_client(_http_app(), client)
with redis_patch:
res = tc.patch(_URL, json={field: None})
assert res.status_code == 422
assert store == before
client.set.assert_not_awaited()


def test_http_unauthenticated_is_401_and_writes_nothing(real_auth_middleware, monkeypatch):
"""Judged with the REAL get_current_user (the conftest stub always admits)."""
from types import SimpleNamespace

from api import users as users_api

async def _no_token(_request):
return None

middleware = SimpleNamespace(
get_user_from_request=lambda _request: None,
_extract_user_from_run_jwt=_no_token,
_extract_user_from_device_jwt=_no_token,
)
monkeypatch.setattr(real_auth_middleware, "get_auth_middleware", lambda: middleware)
app = _http_app(authenticated=False)
app.dependency_overrides[users_api.get_current_user] = real_auth_middleware.get_current_user
client, store = _fake_redis()
tc, redis_patch = _http_client(app, client)
with redis_patch:
res = tc.patch(_URL, json={"ui_mode": "minimal"})
assert res.status_code == 401
assert store == {}
client.set.assert_not_awaited()


def test_http_happy_path_returns_merged_full_preferences():
client, store = _fake_redis()
store["user:u-partial:preferences:theme"] = "dark"
tc, redis_patch = _http_client(_http_app(), client)
with redis_patch:
res = tc.patch(_URL, json={"ui_mode": "minimal"})
assert res.status_code == 200
prefs = res.json()["data"]["preferences"]
assert prefs["ui_mode"] == "minimal" and prefs["theme"] == "dark"
assert prefs["font_size"] == "medium" and prefs["ui_start"] == "last"
assert store["user:u-partial:preferences:ui_mode"] == "minimal"
3 changes: 2 additions & 1 deletion autobot-frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>AutoBot</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1f2937" />
<meta name="description" content="Autonomous Bot AI Platform - Code Analysis, Knowledge Base, and Workflow Automation" />
Expand Down
10 changes: 10 additions & 0 deletions autobot-frontend/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading