Skip to content

[feat] Send users without Classic mode to /m - #6261

Open
ardaerzin wants to merge 7 commits into
fe-feat/session-deep-linksfrom
feat/classic-mode-redirect
Open

[feat] Send users without Classic mode to /m#6261
ardaerzin wants to merge 7 commits into
fe-feat/session-deep-linksfrom
feat/classic-mode-redirect

Conversation

@ardaerzin

@ardaerzin ardaerzin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Context

We ship two web apps on one origin. /w is the full desktop app, /m is 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/mobileGate rather 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:

/w/:w/p/:p/<anything>   ->  /m/w/:w/p/:p/sessions

After:

/apps (home), /agents, /agents/:id, /sessions, /observability, /settings  ->  the same screen on /m
/apps/agent-templates[/:key]                                             ->  /templates[/:key]
/apps/:id and its subroutes                                              ->  /agents/:id

mobileRouteFor returns null for pages /m does not have (evaluations, test sets, prompts, evaluators, annotations, the registry). The preference gate reads null as "leave them on desktop". The device gate still reads it as "the /m root", because a phone cannot use those pages either way.

The desktop playground maps to a session's chat, because /m has 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.

/w/:w/p/:p/apps/:agentId/playground?session_id=S  ->  /m/w/:w/p/:p/sessions/S?agent=:agentId

Two layers, because the preference lives in localStorage and middleware can only read cookies. Both apps mirror it to an agenta-classic-mode cookie, 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 /m stays there.

AGENTA_CLASSIC_MODE_GATE (default on) disables the whole thing per deployment. It is separate from AGENTA_MOBILE_GATE so device detection is not caught in the same switch. Operators set the one bare variable: the middleware reads it server side, and entrypoint.sh mirrors it into __env.js as NEXT_PUBLIC_AGENTA_CLASSIC_MODE_GATE so 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 /m is 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 /m for the preference and the mobile gate would send them straight back for the device, forever. decideMobileGate passes 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_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 hid four nav items. It is not, now that it picks an app.

GET /profile already returns created_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 users table 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

  • Unit tests cover the route map per route, the desktop-only passes, each cookie state, the flag off, the auth and post-signup exceptions, and the cohort derivation. That last one includes the space separated datetime Python's str() produces, which Safari refuses to parse until the separator is a T.
  • Verified: 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.
  • Replaces web/mobile/src/proxy.ts's hand-copied gate with the real import, as its own header asked for once /m had workspace deps. Without it, every change above had to be written twice.
  • Two things are new on deployments that never enabled the device gate. ?view=desktop now 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.
  • The client-side hop waits for a settled session and never fires from /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.
  • Not yet QA'd in a browser. The cross-app hop needs the docker stack, since pnpm dev and pnpm dev-mobile both bind port 3000.
  • Stacked on fe-feat/session-deep-links, which owns ?session_id= and @agenta/sessions/link.

What to QA

Needs a deployment where /m is reachable, so the docker stack rather than pnpm dev.

  • Sign in as an account created after 2026-08-01. You land in /m, not /w.
  • In /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.
  • On the desktop, Settings › Preferences, turn Classic mode off. You go back to /m.
  • With Classic mode off, open /w/:w/p/:p/evaluations directly. It stays on desktop.
  • Open a session in the desktop playground, copy the URL, load it with Classic mode off. You land on that session's chat in /m, showing the right agent.
  • Sign in on a second browser as the same post-cutoff account, without touching any switch. You land in /m there too. This is the part that did not work before.
  • Regression: sign in as an account created before 2026-08-01. Nothing changes and you stay on the full desktop app.
  • Regression: add ?view=desktop to a /w URL with Classic mode off. You stay on desktop, and later navigations keep working.
  • Regression: open /m directly while Classic mode is on. You stay in /m rather than being bounced out.
  • Regression, only if this deployment also sets AGENTA_MOBILE_GATE=true: with Classic mode off, open a /w URL on a desktop browser. You land in /m and stay there. If the address bar flickers between /w and /m, the two gates are fighting.

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.
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Aug 24, 2026
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a Classic mode preference that lets users switch between the simplified and classic experiences.
    • Added automatic navigation between desktop and mobile experiences based on device and preference.
    • Preserved query parameters during cross-experience navigation.
    • Added account-age-based defaults for the Classic mode setting.
  • Bug Fixes

    • Improved authentication and redirect handling to avoid interrupting sign-in flows.
  • Tests

    • Expanded coverage for Classic mode preferences, routing, redirects, and persistence.

Walkthrough

Classic 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.

Changes

Classic mode gating

Layer / File(s) Summary
Shared preference state and ownership
web/packages/agenta-shared/src/state/*, web/packages/agenta-shared/src/types/user.ts, web/packages/agenta-entities/src/profile/index.ts, web/oss/src/lib/onboarding/*, web/oss/src/state/onboarding/selectors.ts, web/oss/src/hooks/usePostAuthRedirect.ts, web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx, web/oss/src/components/pages/settings/Preferences/Preferences.tsx
Classic mode state uses shared atoms, per-user storage, and optional account creation dates. OSS onboarding atoms and consumers now use the shared state.
Shared gate routing and cookie helpers
web/packages/agenta-shared/src/utils/mobileGate/*, web/packages/agenta-shared/src/hooks/*, web/packages/agenta-shared/src/utils/sessionParam.ts, web/packages/agenta-sessions/src/link/sessionDeepLink.ts, web/mobile/src/proxy.ts
The shared gate supports Classic mode cookies, route-aware mappings, desktop-only exceptions, query preservation, and independent device and preference gates.
Application preference and middleware wiring
web/ee/src/middleware.ts, web/oss/src/middleware.ts, web/oss/src/state/Providers.tsx, web/oss/src/state/onboarding/ClassicModeGate.tsx, web/mobile/src/features/app/ContextSync.tsx, web/mobile/src/features/settings/PreferencesTab.tsx
Middleware enables the preference gate by default. Desktop syncs the preference and redirects to mobile when required. Mobile syncs the cookie and navigates to desktop when Classic mode is enabled.
Preference and route validation
web/packages/agenta-shared/tests/unit/classicMode.test.ts, web/packages/agenta-shared/tests/unit/mobileGate.test.ts
Tests cover storage compatibility, account-date defaults, route mappings, cookies, exceptions, gate precedence, and query preservation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟡 Moderate · up to f289e

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 90.91% which is sufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 24 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: routing users without Classic mode to /m.
Description check ✅ Passed The description is detailed and directly explains Classic mode routing, route mappings, cookie handling, deployment configuration, tests, and pending browser QA.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/classic-mode-redirect

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Error Error Aug 25, 2026 8:50am

Request Review

@dosubot dosubot Bot added enhancement New feature or request frontend labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-6261.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6261-3123db8
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-08-25T08:35:44.844Z

@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
web/packages/agenta-shared/tests/unit/mobileGate.test.ts (1)

159-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the two untested mobileRouteFor branches.

The table covers every mapped area except two branches in mobileRouteFor: the /auth* prefix, and observability with the drawer ?session= param. The drawer param is named session, not SESSION_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 win

Reduce 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

📥 Commits

Reviewing files that changed from the base of the PR and between c1cbf58 and f289e9f.

📒 Files selected for processing (25)
  • web/ee/src/middleware.ts
  • web/mobile/src/features/app/ContextSync.tsx
  • web/mobile/src/features/settings/PreferencesTab.tsx
  • web/mobile/src/proxy.ts
  • web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx
  • web/oss/src/components/pages/settings/Preferences/Preferences.tsx
  • web/oss/src/hooks/usePostAuthRedirect.ts
  • web/oss/src/lib/onboarding/atoms.ts
  • web/oss/src/lib/onboarding/index.ts
  • web/oss/src/middleware.ts
  • web/oss/src/state/Providers.tsx
  • web/oss/src/state/onboarding/ClassicModeGate.tsx
  • web/oss/src/state/onboarding/selectors.ts
  • web/packages/agenta-entities/src/profile/index.ts
  • web/packages/agenta-sessions/src/link/sessionDeepLink.ts
  • web/packages/agenta-shared/src/hooks/index.ts
  • web/packages/agenta-shared/src/hooks/useClassicModeGate.ts
  • web/packages/agenta-shared/src/state/classicMode.ts
  • web/packages/agenta-shared/src/state/index.ts
  • web/packages/agenta-shared/src/types/user.ts
  • web/packages/agenta-shared/src/utils/index.ts
  • web/packages/agenta-shared/src/utils/mobileGate/index.ts
  • web/packages/agenta-shared/src/utils/sessionParam.ts
  • web/packages/agenta-shared/tests/unit/classicMode.test.ts
  • web/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.

Comment on lines +83 to +104
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])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 -50

Repository: 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.

Comment on lines +40 to +43
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 -120

Repository: 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 -160

Repository: 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.ts

Repository: 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:


🏁 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 -100

Repository: 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.py

Repository: Agenta-AI/agenta

Length of output: 3354


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '30,72p' api/oss/src/routers/user_profile.py

Repository: 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.

Comment on lines +322 to +336
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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.ts

Repository: 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request frontend size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant