[feat] Send users without Classic mode to /m - #6261
Conversation
The switch has only ever driven the desktop sidebar, so its atoms sat in web/oss/src/lib/onboarding. It is about to decide which APP a user gets, and the app that needs to read it — and to offer the switch that leaves it — is /m, which is lint-banned from importing @/oss/*. Move the pair to @agenta/shared/state, under the SAME agenta:onboarding: keys: a changed prefix would silently reset every existing user to their signup-era default. Add classicModeEnabledAtom so both Preferences pages bind one thing rather than each inverting advancedNavHidden by hand. The atoms gain getOnInit so they read storage on first get instead of on mount. Without it the value starts at the default and corrects a tick later, and the gate would act on the wrong answer first. SESSION_QUERY_PARAM moves down to @agenta/shared too, re-exported from @agenta/sessions/link: the route map that translates one app's URL into the other's needs the same string, and a second copy is a silent deep-link break.
Classic mode off has meant a simplified desktop sidebar. It now means the simplified APP: those users land in /m for every page /m has, and the desktop keeps the rest. Nobody is opted in by this commit who was not already opted in by signup — setNavSimplifiedDefault(true) has fired for every new user since 2026-07-28 (#5478), and existing users default to classic on and stay where they are. Built on the device gate rather than beside it, so there is one route map and one exception list. mapDesktopToMobile stops sending every project route to the sessions list and maps per route; the new mobileRouteFor returns null for pages /m does not have (evaluations, test sets, prompts, evaluators, annotations), which the preference gate reads as "leave them on desktop" and the device gate still reads as "/m root" — a phone cannot use those pages either way. The desktop playground maps to a session's chat, since /m has no playground route: the session page IS the playground there, and ?agent= names the agent a session with no turns cannot name for itself. Two layers, because the preference is localStorage and the middleware can only read cookies: the apps mirror it to agenta-classic-mode, and the first visit — which has no cookie yet — is caught in the client instead. An absent cookie is UNKNOWN, never "off": the preference is per-browser, so guessing would move users who never chose the simplified experience. The client hop waits for a settled session and never fires from /auth. Post-auth sets the user id and the signup default and THEN routes onward, so a user is briefly known while still on the sign-in page; jumping apps there races the router and skips a new EE user's post-signup survey outright. Leaving is the switch in /m's Settings > Preferences, which carries the user to the desktop equivalent of the page they were on. There is deliberately no reverse gate — a Classic-mode user who opens /m stays there. AGENTA_CLASSIC_MODE_GATE (default on) disables it per deployment, independent of AGENTA_MOBILE_GATE so device detection is not caught in the same switch. Also drops web/mobile's declared copy of the gate for the real import, as its own header asked once /m had workspace deps — the alternative was writing all of the above twice.
It read localStorage during the first render, which diverges from prerendered
HTML and breaks hydration wherever the value renders. The desktop was safe --
its Layout is dynamic({ssr: false}) -- but /m's pages are statically
prerendered, and its Preferences tab renders this into a Switch. The
neighbouring flags (voice input, playground inspector) omit it for the same
reason.
The gate never needed it. Both the cookie sync and the redirect are effects on
this value, so they re-run when it settles a tick later: late, never wrong. The
worst case is a cookie that briefly says "classic on", which the next render
corrects.
Tests mount the atoms instead, which is how the app reads them anyway --
atomWithStorage reads storage in onMount, so an unsubscribed get reports the
default rather than what is stored.
…he browser The signup-era default was written to localStorage on whichever browser completed signup, and nothing ever re-seeds it: is_new_user comes from _create_account, which is idempotent and returns False once a user row exists. So the same person read "simplified" on their signup browser and "classic" everywhere else -- and it never healed. That was survivable while the flag only hid four nav items. It is not now that it decides which app you get. GET /profile already returns created_at; the zod schema at the boundary was dropping it. Let it through, and read the default as "stored flag OR account created after the cutoff". The signup browser keeps answering exactly as it always has, and a second device stops disagreeing with it. The cutoff is padded past the release that started seeding the flag (v0.106.1, on main 2026-07-30) rather than the merge date of the change itself (#5478, 2026-07-28), because the seed is written client-side -- what matters is when the frontend carrying it reached users. Padding forward only ever leaves someone on the status quo, since the stored flag still covers anyone it misses. The explicit override stays browser-local. Fixing that needs somewhere to put it, and there is no user-settings column or endpoint -- the users table is id, uid, username, email and timestamps. It affects only people who deliberately flipped the switch, rather than the whole cohort.
📝 WalkthroughSummary by CodeRabbit
WalkthroughClassic mode state now lives in shared Jotai atoms. Shared routing utilities apply device and preference gates across both apps. Preference pages synchronize cookies and navigate between desktop and mobile routes. ChangesClassic mode gating
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟡 Moderate · up to This change can misroute eligible users to the desktop app, create redirect loops when both navigation gates are enabled, and continue redirecting even when the deployment flag disables the feature. These bounded routing and configuration issues should be fixed before merging. Sequence Diagram(s)sequenceDiagram
participant MobilePreferences
participant SharedClassicModeState
participant ClassicModeCookie
participant DesktopMiddleware
participant MobileRoute
MobilePreferences->>SharedClassicModeState: set Classic mode
MobilePreferences->>ClassicModeCookie: write preference
DesktopMiddleware->>ClassicModeCookie: read preference
DesktopMiddleware->>MobileRoute: resolve mobile counterpart
MobileRoute-->>DesktopMiddleware: mapped route or no route
DesktopMiddleware-->>MobilePreferences: redirect when gate applies
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Railway Preview Environment
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
web/packages/agenta-shared/tests/unit/mobileGate.test.ts (1)
159-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd cases for the two untested
mobileRouteForbranches.The table covers every mapped area except two branches in
mobileRouteFor: the/auth*prefix, andobservabilitywith the drawer?session=param. The drawer param is namedsession, notSESSION_QUERY_PARAM, so a rename on either side would silently route the user to the observability list instead of the session chat.♻️ Proposed additional cases
["/w/ws1/p/pr1/observability", "", "/m/w/ws1/p/pr1/observability"], + // The observability drawer's own param, distinct from SESSION_QUERY_PARAM. + ["/w/ws1/p/pr1/observability", "?session=s9", "/m/w/ws1/p/pr1/sessions/s9"], + ["/auth", "", "/m/auth"], + ["/auth/reset-password", "", "/m/auth"], ["/w/ws1/p/pr1/settings", "", "/m/w/ws1/p/pr1/settings"],web/packages/agenta-entities/src/profile/index.ts (1)
17-20: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReduce policy comments to short constraint comments.
Keep a long comment only where it explains the storage-compatibility or hydration constraint. Move release history and product-policy narratives to PR documentation.
web/packages/agenta-entities/src/profile/index.ts#L17-L20: reduce the schema comment to one short field-purpose comment.web/packages/agenta-shared/src/types/user.ts#L13-L20: reduce the JSDoc to the backend timestamp contract.web/packages/agenta-shared/src/state/classicMode.ts#L1-L9: reduce the module overview to one short purpose comment.web/packages/agenta-shared/src/state/classicMode.ts#L18-L25: move release and cutoff rationale out of code.web/packages/agenta-shared/src/state/classicMode.ts#L28-L36: reduce cohort policy explanation to one short comment.web/packages/agenta-shared/src/state/classicMode.ts#L68-L76: reduce storage-source history to one short comment.web/oss/src/state/onboarding/selectors.ts#L46-L48: remove or shorten the import-direction comment.web/packages/agenta-shared/src/utils/sessionParam.ts#L1-L11: reduce the constant comment to one short contract statement.As per coding guidelines, “Keep in-code comments to at most one short line; use longer comments only for genuinely surprising constraints such as bugs, races, or ordering requirements.”
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: b69e80fc-add5-46cb-98ea-71142918c18c
📒 Files selected for processing (25)
web/ee/src/middleware.tsweb/mobile/src/features/app/ContextSync.tsxweb/mobile/src/features/settings/PreferencesTab.tsxweb/mobile/src/proxy.tsweb/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsxweb/oss/src/components/pages/settings/Preferences/Preferences.tsxweb/oss/src/hooks/usePostAuthRedirect.tsweb/oss/src/lib/onboarding/atoms.tsweb/oss/src/lib/onboarding/index.tsweb/oss/src/middleware.tsweb/oss/src/state/Providers.tsxweb/oss/src/state/onboarding/ClassicModeGate.tsxweb/oss/src/state/onboarding/selectors.tsweb/packages/agenta-entities/src/profile/index.tsweb/packages/agenta-sessions/src/link/sessionDeepLink.tsweb/packages/agenta-shared/src/hooks/index.tsweb/packages/agenta-shared/src/hooks/useClassicModeGate.tsweb/packages/agenta-shared/src/state/classicMode.tsweb/packages/agenta-shared/src/state/index.tsweb/packages/agenta-shared/src/types/user.tsweb/packages/agenta-shared/src/utils/index.tsweb/packages/agenta-shared/src/utils/mobileGate/index.tsweb/packages/agenta-shared/src/utils/sessionParam.tsweb/packages/agenta-shared/tests/unit/classicMode.test.tsweb/packages/agenta-shared/tests/unit/mobileGate.test.ts
💤 Files with no reviewable changes (1)
- web/oss/src/lib/onboarding/index.ts
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.
| export const useClassicModeRedirect = (enabled = true) => { | ||
| const userId = useAtomValue(activeUserIdAtom) | ||
| const advancedNavHidden = useAtomValue(advancedNavHiddenAtom) | ||
|
|
||
| useEffect(() => { | ||
| if (!enabled || typeof window === "undefined") return | ||
| // No user means no preference to read — the atom reports the default, not a choice. | ||
| if (!userId || !advancedNavHidden) return | ||
|
|
||
| const {pathname, search} = window.location | ||
| // Never from `/auth`, even though the gate maps it: post-auth sets the user id and the | ||
| // signup-era default and THEN routes onward, so a user is briefly known while still | ||
| // standing on the sign-in page. Redirecting into that window races the router and, for a | ||
| // new EE user, skips the post-signup survey outright. | ||
| if (/^\/auth(\/|$)/.test(pathname)) return | ||
| if (isDesktopOnlyLink(pathname, search)) return | ||
| if (readCookie(MOBILE_OPTOUT_COOKIE)) return | ||
|
|
||
| const target = mobileRouteFor(pathname, search) | ||
| if (target) window.location.replace(target) | ||
| }, [enabled, userId, advancedNavHidden]) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
# Description: Look for a client-readable classic-mode gate value the hook could read.
set -euo pipefail
rg -n 'AGENTA_CLASSIC_MODE_GATE' web || true
echo "--- runtime env exposure mechanism ---"
rg -n --iglob '*env*' -g '!**/node_modules/**' 'NEXT_PUBLIC_|__env' web/oss/src web/mobile/src web/packages/agenta-shared/src | head -50Repository: Agenta-AI/agenta
Length of output: 6258
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- hook and bound helpers ---"
sed -n '1,140p' web/packages/agenta-shared/src/hooks/useClassicModeGate.ts
sed -n '1,130p' web/packages/agenta-shared/src/utils/mobileGate/index.ts
echo "--- middleware contract ---"
sed -n '1,90p' web/oss/src/middleware.ts
sed -n '1,90p' web/ee/src/middleware.ts
echo "--- dynamic environment exposure and consumers ---"
sed -n '1,130p' web/oss/src/lib/helpers/dynamicEnv.ts
rg -n 'dynamicEnv|NEXT_PUBLIC_AGENTA_LICENSE|window\.__|runtime env|runtimeEnv|env\.json' web/oss/src web/ee/src web/mobile/src web/packages/agenta-shared/src -g '*.{ts,tsx,js,jsx}'Repository: Agenta-AI/agenta
Length of output: 29648
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- shared runtime environment contract ---"
sed -n '1,90p' web/packages/agenta-shared/src/api/env.ts
sed -n '175,220p' web/oss/src/lib/helpers/dynamicEnv.ts
echo "--- hook callers ---"
rg -n 'useClassicModeRedirect|useClassicModeCookieSync' web -g '*.{ts,tsx}'Repository: Agenta-AI/agenta
Length of output: 6048
Honor AGENTA_CLASSIC_MODE_GATE in useClassicModeRedirect. When the flag is "false", the middleware disables the classic-mode gate, but the client hook still redirects authenticated users with advancedNavHidden enabled. Expose the flag through window.__env and return early when it is disabled.
| // The backend stringifies a Python datetime, so the date and time are SPACE-separated. | ||
| // Safari refuses that; every engine accepts it once the separator is a `T`. | ||
| const created = Date.parse(createdAt.replace(" ", "T")) | ||
| return Number.isNaN(created) ? false : created >= SIMPLIFIED_SIGNUP_CUTOFF |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '1,110p' web/packages/agenta-shared/src/state/classicMode.ts
printf '%s\n' '--- timestamp references ---'
rg -n -g '*.ts' -g '*.tsx' -g '*.py' -g '*.json' -g '*.md' 'created_at|createdAt|SIMPLIFIED_SIGNUP_CUTOFF|simplifiedCohortAtom' web | head -120Repository: Agenta-AI/agenta
Length of output: 17591
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- user contract and profile consumers ---'
rg -n -g '*.ts' -g '*.tsx' -g '*.py' -g '*.md' 'interface User|type User|created_at.*datetime|datetime.*created_at|/profile|profile.*created_at|class.*User' . | head -160
printf '%s\n' '--- timestamp serializer definitions ---'
rg -n -g '*.py' -g '*.ts' -g '*.tsx' 'isoformat|strftime|datetime\.|Date\.parse\(createdAt|createdAt\.replace' . | head -160Repository: Agenta-AI/agenta
Length of output: 38933
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- account profile serialization ---'
sed -n '200,250p' api/oss/src/core/accounts/service.py
sed -n '1600,1650p' api/entrypoints/routers.py
printf '%s\n' '--- frontend profile contract ---'
sed -n '1,95p' web/packages/agenta-entities/src/profile/index.ts
sed -n '1,65p' web/packages/agenta-entities/src/shared/user/atoms.tsRepository: Agenta-AI/agenta
Length of output: 7666
🌐 Web query:
ECMAScript Date.parse Date Time String Format fractional seconds more than three digits normative specification
💡 Result:
The ECMAScript specification for the Date Time String Format defines the fractional seconds component as exactly three digits [1]. According to the normative specification, the format for the time part includes a "sss" component representing milliseconds, which is explicitly defined as three digits ("000" to "999") [1]. The Date.parse() method is required to support strings that conform to this specific Date Time String Format [2][3]. If a string does not conform to this format (such as a string containing more or fewer than three digits for fractional seconds), it falls outside the strict requirements of the Date Time String Format [1][2]. In such cases, the behavior of Date.parse() is implementation-defined [2][3]. While many major JavaScript engines (such as Chrome, Edge, Firefox, and Safari) have historically supported a superset of the format that includes more than three fractional seconds digits in practice [4], this behavior is not mandated by the ECMAScript specification [1][5]. Consequently, providing more than three digits for fractional seconds results in non-conforming input that relies on implementation-specific heuristics rather than the standard normative specification [2][3].
Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
- 2: https://tc39.es/proposal-uniform-interchange-date-parsing/
- 3: Request For Clarification: Normative requirements of Date.parse() tc39/ecma262#3526
- 4: https://github.com/tc39/proposal-uniform-interchange-date-parsing
- 5: https://tc39.es/proposal-uniform-interchange-date-parsing/cases.html
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- profile route binding ---'
rg -n -g '*.py' 'def .*profile|_user_db_to_read_dto|AdminUserRead|user_profile' api/oss api/entrypoints | head -100Repository: Agenta-AI/agenta
Length of output: 2104
🏁 Script executed:
#!/bin/bash
set -eu
sed -n '1,58p' api/oss/src/routers/user_profile.py
sed -n '50,95p' api/oss/src/core/accounts/dtos.pyRepository: Agenta-AI/agenta
Length of output: 3354
🏁 Script executed:
#!/bin/bash
set -eu
sed -n '30,72p' api/oss/src/routers/user_profile.pyRepository: Agenta-AI/agenta
Length of output: 1295
Normalize backend microseconds before Date.parse.
The /profile handler uses str(user.created_at), which can return 2026-08-01 12:34:56.789012+00:00. After the separator replacement, six fractional-second digits remain. This is outside ECMAScript’s required Date Time String Format, so Date.parse can return NaN; simplifiedCohortAtom then selects Classic mode for a post-cutoff account.
Truncate fractional seconds to milliseconds before parsing. Add a WebKit regression test with 2026-08-01 12:34:56.789012+00:00.
| if (isDesktopOnlyLink(input.pathname, input.search)) return {kind: "pass"} | ||
| if (input.cookie(MOBILE_OPTOUT_COOKIE)) return {kind: "pass"} | ||
| if (!isMobileDevice(input.header)) return {kind: "pass"} | ||
|
|
||
| return {kind: "redirect", location: mapDesktopToMobile(input.pathname, input.search)} | ||
| // Device: a phone gets /m for anything, mapped or not. | ||
| if (input.gateEnabled && isMobileDevice(input.header)) { | ||
| return {kind: "redirect", location: mapDesktopToMobile(input.pathname, input.search)} | ||
| } | ||
|
|
||
| // Preference: Classic mode off means live in /m — but only for pages /m actually has. | ||
| if (classicGateEnabled && input.cookie(CLASSIC_MODE_COOKIE) === "0") { | ||
| const location = mobileRouteFor(input.pathname, input.search) | ||
| if (location) return {kind: "redirect", location} | ||
| } | ||
|
|
||
| return {kind: "pass"} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
# Description: Confirm the mobile reverse gate never reads the Classic mode cookie, and check the tests for a combined-flags case.
set -euo pipefail
fd -t f 'mobileGate' web/packages/agenta-shared | while IFS= read -r f; do
echo "=== $f"
rg -n 'CLASSIC_MODE_COOKIE|decideMobileGate|reverseGateEnabled|gateEnabled' "$f"
done
# Any test that combines gateEnabled with a classic-off cookie on the mobile gate?
rg -n -C4 'decideMobileGate' web/packages/agenta-shared/tests/unit/mobileGate.test.tsRepository: Agenta-AI/agenta
Length of output: 5075
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== web/packages/agenta-shared/src/utils/mobileGate/index.ts"
cat -n web/packages/agenta-shared/src/utils/mobileGate/index.ts | sed -n '1,390p'
echo "=== web/packages/agenta-shared/tests/unit/mobileGate.test.ts"
cat -n web/packages/agenta-shared/tests/unit/mobileGate.test.ts | sed -n '270,375p;487,590p'Repository: Agenta-AI/agenta
Length of output: 29357
Prevent the Classic mode/reverse-gate redirect loop.
When CLASSIC_MODE_COOKIE is "0" and both gates are enabled, decideDesktopGate can redirect a desktop request to /m. If reverseGateEnabled is not false, decideMobileGate then redirects the desktop user agent back through mapMobileToDesktop. The unchanged cookie can repeat this cycle for mapped routes. Read CLASSIC_MODE_COOKIE in decideMobileGate and pass through after handling ?view=mobile.
…h other Two gates, both able to redirect, neither aware of the other. With AGENTA_MOBILE_GATE on and a desktop browser whose Classic mode is off, the desktop gate sent the request to /m for the preference, the mobile gate sent it straight back for the device, and the cookie that started it never changed. An infinite ping-pong, on exactly the flag combination the previous commit advertised as independent. Classic mode off means /m is where that user belongs, so the device heuristic must not overrule it. decideMobileGate now passes on the classic cookie, gated on the same flag, and web/mobile's proxy reads that flag so both halves agree. The regression test asserts both directions of one request: the desktop gate still wants them in /m, and the mobile gate leaves them there. Asserting only the second would hide the loop rather than break it. Also makes AGENTA_CLASSIC_MODE_GATE a real kill switch. It is a bare variable resolved server-side, so the client redirect could not see it: turning the flag off stopped the middleware and left the browser redirecting anyway, which is worse than no switch at all. entrypoint.sh mirrors it into __env.js, the same mechanism every other runtime flag uses, and the hook reads it from there.
`str(datetime)` produces "2026-08-01 12:34:56.789012+00:00". Two things there are outside the format Date.parse is required to accept: the space separator, which Safari refuses, and six fractional digits where the spec defines exactly three. Past the third digit the behaviour is implementation-defined, so an engine is free to answer NaN. Every major engine copes today, but the failure mode here is silent. NaN reads as "not in the simplified cohort", which parks a post-cutoff account on classic mode and quietly withholds the redirect, with nothing to show for it. Truncate to milliseconds alongside the separator fix, and cover the shapes the backend can actually emit. The added cutoff-boundary test is there because truncation must not shift the instant across it.
Context
We ship two web apps on one origin.
/wis the full desktop app,/mis the simplified, agent-focused one. Which app you get was never wired to anything, so everyone landed on/w.The preference to drive that already exists, and is already set. "Classic mode" in Settings › Preferences has gated the desktop sidebar since #5478, and every account created since then has it off. Nothing acted on it beyond hiding four nav items.
This makes Classic mode mean what it says. Off means the simplified app, not just a simplified sidebar. Nobody is opted in who was not already opted in at signup:
setNavSimplifiedDefault(true)has fired for every new user since v0.106.1, and older accounts default to classic on and stay where they are.Changes
Built into the existing device gate in
@agenta/shared/utils/mobileGaterather than beside it, so there is one route map and one exception list.The route map is now per route, instead of sending every project URL to the sessions list.
Before:
After:
mobileRouteForreturnsnullfor pages/mdoes not have (evaluations, test sets, prompts, evaluators, annotations, the registry). The preference gate readsnullas "leave them on desktop". The device gate still reads it as "the/mroot", because a phone cannot use those pages either way.The desktop playground maps to a session's chat, because
/mhas no playground route. The session page is the playground there, and?agent=names the agent that a session with no turns cannot name for itself.Two layers, because the preference lives in localStorage and middleware can only read cookies. Both apps mirror it to an
agenta-classic-modecookie, and the first visit, which has no cookie yet, is caught in the client instead. An absent cookie means unknown and never "off", so nobody who did not choose the simplified experience gets moved on a guess.Leaving is the new Classic mode switch in
/m's Settings › Preferences, which carries you to the desktop equivalent of the page you were on. There is deliberately no reverse gate, so a Classic-mode user who opens/mstays there.AGENTA_CLASSIC_MODE_GATE(default on) disables the whole thing per deployment. It is separate fromAGENTA_MOBILE_GATEso device detection is not caught in the same switch. Operators set the one bare variable: the middleware reads it server side, andentrypoint.shmirrors it into__env.jsasNEXT_PUBLIC_AGENTA_CLASSIC_MODE_GATEso the client-side redirect obeys the same switch. Without the mirror it would be half a kill switch, which is worse than none.The two gates also have to agree about the same request. Classic mode off means
/mis where that user belongs, so the device gate must not bounce them out of it: with both flags on and a desktop browser, the desktop gate would send them to/mfor the preference and the mobile gate would send them straight back for the device, forever.decideMobileGatepasses on the classic cookie, and the regression test asserts both directions of one request, because asserting only the mobile half would hide the loop rather than break it.The preference stops being browser-local
Worth a look from a reviewer, since it changes an existing behaviour rather than adding one.
setNavSimplifiedDefault(true)was written to localStorage on whichever browser completed signup, and nothing ever re-seeded it.is_new_usercomes from_create_account, which is idempotent and returnsFalseonce a user row exists. So the same person read "simplified" on their signup browser and "classic" everywhere else, and it never healed.That was survivable while the flag hid four nav items. It is not, now that it picks an app.
GET /profilealready returnscreated_at, and the zod schema at the boundary was dropping it. Letting it through means the default can read as "stored flag, or account created after the cutoff", so a second device stops disagreeing with the first. No backend change. The cutoff is padded past the release that started seeding the flag rather than the merge date of the change itself, because the seed is written client side: what matters is when the frontend carrying it reached users. Padding forward can only leave someone on the status quo.The explicit override is still browser-local. There is nowhere to persist it (the
userstable is id, uid, username, email and timestamps, with no settings column or endpoint), and it affects only people who deliberately flipped the switch rather than the whole cohort.Tests and notes
str()produces, which Safari refuses to parse until the separator is aT.pnpm lint-fix, tsc on oss, ee, mobile, shared and entities, tests (shared 409, entities 1353, mobile 124, oss 449), and builds for all three apps.web/mobile/src/proxy.ts's hand-copied gate with the real import, as its own header asked for once/mhad workspace deps. Without it, every change above had to be written twice.?view=desktopnow works, which it must, since it is the escape hatch. And the middleware evaluates instead of returning early on the flag, inside the existing try/catch that falls through to a pass./auth. Post-auth sets the user id and the signup default before it routes onward, so a user is briefly known while still on the sign-in page, and redirecting there would skip a new EE user's post-signup survey.pnpm devandpnpm dev-mobileboth bind port 3000.fe-feat/session-deep-links, which owns?session_id=and@agenta/sessions/link.What to QA
Needs a deployment where
/mis reachable, so the docker stack rather thanpnpm dev./m, not/w./m, Settings › Preferences, turn Classic mode on. You arrive on the desktop equivalent of the page you were reading, and it stays that way after a reload./m./w/:w/p/:p/evaluationsdirectly. It stays on desktop./m, showing the right agent./mthere too. This is the part that did not work before.?view=desktopto a/wURL with Classic mode off. You stay on desktop, and later navigations keep working./mdirectly while Classic mode is on. You stay in/mrather than being bounced out.AGENTA_MOBILE_GATE=true: with Classic mode off, open a/wURL on a desktop browser. You land in/mand stay there. If the address bar flickers between/wand/m, the two gates are fighting.