Skip to content

[feat] Sign-in parity, chat-package re-sync, and the mobile UX pass (12/12) - #5691

Draft
ardaerzin wants to merge 19 commits into
fix/desktop-session-convergencefrom
feat/mobile-parity-and-consolidation
Draft

[feat] Sign-in parity, chat-package re-sync, and the mobile UX pass (12/12)#5691
ardaerzin wants to merge 19 commits into
fix/desktop-session-convergencefrom
feat/mobile-parity-and-consolidation

Conversation

@ardaerzin

Copy link
Copy Markdown
Contributor

Context

The top of the stack: sign-in parity, the first consolidation work against the copy-drift the stack created, and the mobile UX pass that came out of using it on a real phone.

Changes

Sign-in parity. /m gains email OTP, social providers, and org SSO. Providers only ever redirect to the one registered URI (the desktop /auth/callback/<id>), so the desktop hands a mobile-initiated callback back to /m rather than requiring a second registration.

Consolidation, wave 0. @agenta/chat's message store had drifted from the OSS original it was copied from on the same localStorage key: it had re-enabled the cross-tab sync OSS deliberately strips, and dropped the record-count watermark. The replay adapter had drifted too, missing the approval-resume handling (a resumed turn replayed as still parked, so a reload kept the approval dock up) and user attachments (a message with files replayed as bare text). Both re-synced, with the mobile side rendering attachments.

Correctness. A replayed approval envelope now names its tool. Without it the runner's resume nudge said "Call the tool again", naming nothing the model could call, and the model narrated a fabricated execution instead of re-issuing the call.

UX pass. Statuses became real tags instead of bare coloured words (and live no longer renders identically to ended). The pending count became a filter rather than a statistic. The session list polls its newest page, so a session created on desktop appears without a manual refresh. The header stopped eating the first screen, and the workspace selector moved into it.

Tests / notes

  • Mobile and @agenta/chat suites both grew with this work; the settle fix is covered by tests ported from the OSS original, and I verified they fail without it.
  • Steer-lite is behind a flag and off: the runner cannot yet carry a redirect in-band with a denial (docs(agent-workflows): reject-siblings approval policy proposal (Deny/Stop/steer) #5444), so the note is persisted but not delivered.
  • Attachment rendering is compile-and-test verified only; I had no session with a real attachment to view.

What to QA

  • Sign in on /m with Google, with an email OTP, and with org SSO. Each completes and lands you in the app.
  • Open a session whose approval you answered on another device. It replays as answered, not as still waiting.
  • With approvals pending, tap the "N waiting" chip. The list narrows to those sessions; tap again to restore.
  • Create a session on desktop and watch the mobile list. It appears within ~30s without a refresh.

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 3, 2026 10:31pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added mobile sign-in options for password, email one-time codes, organization SSO, and OIDC providers.
    • Added workspace and project switching, session filtering for pending interactions, and improved session status indicators.
    • Added attachment display in chat transcripts and optional guidance when denying approvals.
    • Added mobile OAuth callback handling for smoother authentication flows.
  • Bug Fixes

    • Improved transcript synchronization to prevent stale or duplicate messages.
    • Improved approval replay and resumed conversation handling.
    • Corrected live-session status detection and mobile routing behavior.

Walkthrough

This change expands the mobile application with authentication, context and session navigation, chat transcript replay, approval steering, shared UI components, OAuth callback routing, and updated mobile design and execution documentation.

Changes

Mobile planning and contracts

Layer / File(s) Summary
Planning and contract updates
docs/design/agenta-mobile/..., docs/design/agenta-sessions-consolidation/plan.md
Updates mobile execution plans, infrastructure checks, SSE contracts, approval steering notes, and session consolidation design.
Shared mobile UI and navigation
web/mobile/src/components/*, web/mobile/src/features/context/*, web/mobile/src/features/sessions/*
Adds reusable scaffolding, status tags, filtering, workspace selection, project switching, session merging, liveness handling, and pending-session views.
Authentication and callback flow
web/mobile/src/lib/auth/*, web/mobile/src/features/auth/*, web/mobile/src/pages/auth/*, web/mobile/src/middleware.ts, web/packages/agenta-shared/src/utils/mobileGate/*
Adds password, OTP, OIDC, SSO discovery, callback completion, session refresh, and mobile OAuth callback routing.
Chat replay and approval handling
web/packages/agenta-chat/src/*, web/mobile/src/features/chat/*
Adds attachment replay, paused-turn reconciliation, transcript adoption watermarks, local persistence updates, and flag-gated denial messages.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SignInScreen
  participant MobileAuth
  participant OAuthProvider
  participant AuthCallback
  participant MobileGate
  SignInScreen->>MobileAuth: Start password, OTP, or OIDC sign-in
  MobileAuth->>OAuthProvider: Redirect for OIDC authorization
  OAuthProvider->>AuthCallback: Return callback parameters
  AuthCallback->>MobileAuth: Complete OIDC sign-in
  MobileAuth->>SignInScreen: Report authentication outcome
  MobileGate->>AuthCallback: Pass callback route without mobile redirect
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the PR's three primary changes: sign-in parity, chat-package synchronization, and the mobile UX pass.
Description check ✅ Passed The description is directly related to the changes and clearly explains sign-in parity, chat re-sync, correctness fixes, UX updates, tests, and QA scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 60.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mobile-parity-and-consolidation

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.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
✅ 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.

@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from 28314ae to cc14c6a Compare August 3, 2026 15:13
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from a0ceb44 to 553ef34 Compare August 3, 2026 15:13
@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from cc14c6a to 0aa2522 Compare August 3, 2026 16:14
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from 553ef34 to 61f34b3 Compare August 3, 2026 16:15
@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from 0aa2522 to cfb9c84 Compare August 3, 2026 16:47
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from 61f34b3 to ccdc697 Compare August 3, 2026 16:47
@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from cfb9c84 to defca8b Compare August 3, 2026 17:34
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from ccdc697 to 5c01194 Compare August 3, 2026 17:34
@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from defca8b to de37daa Compare August 3, 2026 17:54
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from 5c01194 to a60bf4e Compare August 3, 2026 17:54
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
✅ 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.

@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from de37daa to 94e03ca Compare August 3, 2026 18:26
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from a60bf4e to 59641db Compare August 3, 2026 18:26
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
✅ 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.

The approval dock gains a flag-gated Redirect control that sends the denial and a
short instruction in one respond call. Delivery is cold-replay-only: a warm-parked
resume answers the harness gate on the still-pending original prompt and never
prompts again, so the note is dropped (measured live; the model then re-tried the
denied work with a sibling tool and raised a new gate). The harness has no
reject-with-feedback channel either - PermissionReply is once|always|reject - so
the control stays hidden unless NEXT_PUBLIC_AGENT_CHAT_STEER=true, matching the
desktop flag.
Providers only ever redirect to the one URI registered for them, the desktop
`/auth/callback/<providerId>`, so a mobile SSO flow could never land in /m and
the mobile sign-in had to tell users to switch to the desktop app.

The mobile app now marks the flow with a short-lived cookie before leaving for
the provider. The desktop forward gate reads it and forwards the landing to
`/m/auth/callback/...` with the query intact, where the OAuth state (same-origin
sessionStorage) is still readable — no new redirect URI has to be registered
with any provider. `?view=desktop` still wins, and the other desktop-only
exceptions are untouched.

The reverse gate also stops bouncing any `/auth/callback` off /m: an OAuth
landing must complete where it lands or the one-time code is lost.
/m/auth only did email+password and told everyone else to use the desktop app.
It now offers every method the deployment enables, derived from the same env
flags as the desktop's getEffectiveAuthConfig:

- Passwordless: request a code, verify it, resend on a 60s cooldown, with each
  SuperTokens status mapped to a retry-in-place or a restart.
- Social: one button per configured OAuth client id, in the desktop's order,
  running the redirect flow through the shared device gate's mobile callback.
- Organization SSO: /auth/discover by work email, then the same redirect flow
  (404 on OSS reads as "no SSO", not an error).
- Email/password unchanged; an SSO-only deployment no longer shows an email
  form, and a deployment with nothing enabled says so instead of rendering an
  empty card.

Every route lands the same way it already did — invalidate the cached
["mobile","projects"] verdict, then hand over to the root context resolver.

The pure halves (provider derivation, mode selection, the OTP state machine,
discovery parsing) are unit-tested; 40 -> 83 tests.
…calStorage key

The package copy of the agent-chat message store writes the same localStorage key
as the desktop original but had drifted twice: it used jotai's default storage,
re-enabling the cross-tab `storage` subscribe the original deliberately strips
(an incoming replacement unmounts a streaming conversation and orphans its stream
mid-turn), and its quota guard returned void, so there was no record-count
watermark counterpart to `agenta:agent-chat:record-counts`.

Port `tabLocalStorage()`, the watermark store and its read atom, the
`{evicted, persisted}` quota-guard result, `persistSessionMessagesAtom`'s
`recordCount`, and the joint deletion path (exposed as a write atom here, since
the scope-keyed delete/close/prune callers stay in the app layer).
…eam nest

The badge read `flags.is_running` raw, so it disagreed with the desktop tab dot on
every row the coarse filter returns but the flags contradict: a flagless or
proc-dead (zombie) row was badged live, where the desktop dot reads it as idle.

Split on `deriveStreamNest` instead — running wins, then alive, everything else is
omitted — so both surfaces answer "running"/"alive" from one derivation. Polling
cadence and the react-query shape are untouched.
…termark

The chat screen hand-rolled its adoption logic on message counts alone, so it
missed the case the shared guard exists for: a turn that grows in place — tool
results landing, an approval round-trip completing — keeps its message count, and
mobile kept rendering the partial copy (issue #5530).

Mobile now calls `shouldAdoptServerTranscript`. Its inputs map as: `busy` is
always false (mobile never holds a live stream), and the watermark is an in-memory
ref rather than desktop's persisted record-counts store, since mobile caches no
transcript. The mapping lives in `transcriptAdoption.ts` so it is testable without
mounting the hook. The trailing-refresh queue, the cancellation flag and the
loading/ready/empty transitions are unchanged.

The guard needs a record count, so the package's `loadSessionMessages` regains the
`SessionTranscript` return its OSS original already carries (the copy is byte-identical
again below its header); `useAgentConversation` follows the new shape and now files
the record count with the transcripts it persists.
… switchable

Sign-in and the OIDC callback carry the Agenta wordmark, inlined rather than
served from public/ (a bare <img src="/assets/..."> misses the /m basePath) and
drawn in currentColor so one component covers both themes.

Header pinning becomes one ScreenScaffold instead of a shape hand-copied per
screen: a h-dvh column whose middle is the only scroller. The project picker never
had it and scrolled its own title away. The chat dock is passed as a footer only
when it renders, so the scaffold's safe-area rule holds instead of silently
lapsing when no gate is pending.

The workspace line is now a real selector: chips when there are several, a labelled
name when there is one (the default workspace is called "Default", indistinguishable
from a project of that name). Reaching the picker at all needed ?switch=1, since a
stored context auto-forwards past it — and that decision is gated on router.isReady,
without which the first render forwards before the switch intent is knowable.

Signed-out copy no longer claims you signed out on a screen that also renders for
someone who never signed in.
123 commits in one PR is not reviewable. Measured against candidate lanes, only
three files are touched by two lanes, and in each the lanes stack in the order the
edits happened — so a chronological path-partitioned replay needs no hunk surgery.
Twelve lanes, with the empty diff against the branch tip as the correctness gate.
…er attachments

The package copy had drifted from the original it was extracted from, in two ways that were
both user-visible on mobile.

Approval resume: no pause folding, no settle pass. A turn that paused for a gate and then
resumed replayed as still parked, so a reload kept the approval dock up on a turn the user
had already answered — worst when the gate was answered on another device, where the durable
log carries no interaction_response at all and the settle pass is the only thing that closes
it. Ported with the original's tool-call dedup and re-raise-under-a-new-id handling.

Attachments: a message that carried files replayed as bare text. Ported the file parts and
their content URL (on @agenta/shared/api rather than the OSS app layer), and taught mobile
to render them — inline for images, a labelled link otherwise.

Seven tests ported from the original alongside the settle logic; removing the settle pass
fails the no-interaction_response case.
Path-partitioned replay was rejected during execution: a pre-flight check over the real
commit x file matrix found 42 files touched by more than one lane, 22 with an edit order no
lane ordering can respect — each of which would have had a lower lane replay a newer version
underneath a higher one, silently dropping edits.

Contiguous ranges of the linear history cannot have that failure, and need no replay at all.
Records the 12 lanes as built, and that fixed merge order is the trade.
The list never refreshed. Only staleTime was set, no refetchInterval, and the app disables
refetchOnWindowFocus globally — so a session created on desktop stayed invisible until the
screen remounted. The badges on existing rows polled while the set of rows was frozen.

Poll the newest page as a SEPARATE query rather than adding refetchInterval to the infinite
one: refetching an infinite query refetches every page scrolled into, so its cost grows with
scroll depth. This stays one request per tick. Foreground-only, plus a visibilitychange
refetch so returning from the lock screen does not wait out the interval.

The merge dedupes on session identity and lets the head win on order: a session that just
became active is returned at its new position and would otherwise render twice — fresh at the
top, stale in the page it was fetched in.
Statuses rendered as bare coloured words butted against a title, so "new session test
running" read as a sentence rather than a name plus its state. One StatusTag pill with its
own border and background now carries all of them: the row badges (running/live/approval/
ended), the chat running bar, the transcript's awaiting-approval marker, and the list
header's pending count.

A dot marks the two tones that mean something is happening, so the distinction does not rest
on colour alone. The title truncates independently of its tags, which the old inline spans
could not do — a long name pushed them off the row.
The live and muted tones had byte-identical class strings, so a warm session and an ended one
differed only by a dot — which is why live read as inert grey. They are opposite states.

Bridge the palette semantic success role into the mobile theme (it already carries error) and
give live its own tone. The three states are now separable without reading the label: accent
means act on this, success means healthy and warm, muted means nothing is happening.
"2 approvals pending" told you work existed and left you to find it. The count is now a
toggle that narrows the list to the sessions waiting on you.

It counts SESSIONS, not interactions — the filter shows rows, and one session can hold several
gates, so the old number could exceed the rows it produced.

The interactions poll is project-wide while the list is paged, so a waiting session can sit
beyond what has been fetched. Rather than silently showing a short list, the filter reports
how many are further down and offers to load them; the empty case says the same instead of
claiming there is nothing to do. Answering the last gate clears the filter rather than
stranding the user on an empty list.
…hrase

"show only these" pointed at nothing — while the filter is off the rows it means are not on
screen, and a pill next to an underlined phrase reads as two controls when it is one.

One toggleable chip carries it: the icon supplies the affordance (funnel to apply, cross to
clear) so the visible label stays the count, and the spoken label is the full sentence the
visible text should not have to be. The unloaded-count copy loses its instruction too — the
Load more button is directly beneath it.
Three stacked full-width rows plus 44px tap targets on text-sized controls left roughly 150px
of chrome above the first session — most of a phone screen's useful top half.

Search and the filter now share a row, since both narrow the same list. The chip and the
project switcher keep their 44px touch targets through an `after` pseudo-element instead of
their own height, so a 32px chip and an 18px switcher row still hit the minimum. Header is
about 84px.

The search input keeps text-base: below 16px iOS zooms the viewport on focus.
The workspace governs everything below it, but it sat inside the scrolling list — with 50
projects it scrolled away, leaving no indication of which workspace the rows belonged to and
no way to change it without scrolling back.

It moves into the pinned header beside the title: chips when there are several, a labelled
name when there is one. The list below becomes the selected workspace's projects, which
also drops the nested section-per-workspace shape it no longer needs.

Splits the old component in three along the way — the selector, the list, and the grouping,
which was an inline useMemo and is now a covered pure function.
Review of the plan set found the documents lagging the code in about twenty places. Each
correction was checked against the tree, not against the plan:

- WP0's residual gaps are all implemented. The section is re-headed as the analysis that produced
  the work, and the step text now carries the two things review changed during execution:
  ordering rides `coalesce(updated_at, created_at)` because `updated_at` is nullable and a DESC
  sort puts NULLs first, and the references hydration requires the batch `latest_turn_per_session`
  helper rather than the per-session one, which would make /sessions/query an N+1 path.
- The infra-tail plan's snippets now match the workflow: dispatch inputs through `env:`, exact
  status assertions in the smoke checks, the segment-exact Traefik rule, `pipefail` before the
  piped verifications, and a bounded readiness poll with a cleanup trap instead of `sleep 3`.
- The WP1 plan gains the repo-wide `cd web && pnpm lint-fix` step, the corrected Turbo inputs,
  package-relative paths in the shadcn verification (it runs after `cd web/mobile`), and the dark
  role-map values review actually landed.
- `NoMobilePageWrapper` retires when the gate is turned on, not when its code ships. The design
  doc said the latter, which would leave a mobile visitor with neither the gate nor the blocker.
- The m3 plan states the three event types the endpoint emits, records that the channel's project
  comes from the credential and never the URL, corrects the claim that a session-scoped stream can
  retire the list's project-wide badge polls, and states the unbounded stream age as a known gap
  with its fix rather than as an aside.
- The approvals plan is re-statused as a pre-execution snapshot, since M2 shipped a different
  client payload than it sketches.
- The chat contract's `useApprovalDock` line named an argument the hook does not take and a
  `renderer` it does not return, while omitting `open` and `responding`.
- README: the WP5 status contradiction, the doubled path in the standalone check, a completed
  `.gitignore` chore still listed as pending, the commit count against the review record, and
  `run.sh` invocations that named neither the entrypoint nor the edition.
@ardaerzin
ardaerzin force-pushed the fix/desktop-session-convergence branch from 52d6e1f to 4c08deb Compare August 3, 2026 22:29
@ardaerzin
ardaerzin force-pushed the feat/mobile-parity-and-consolidation branch from 4ed04bf to b5476ae Compare August 3, 2026 22:29
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
✅ 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

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md (1)

227-243: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add publishers for the new SSE event types.

T2 now promises records-changed, lifecycle, and interaction, but T1 only publishes after RecordsWorker.append_many, and T2 still says the endpoint yields records-changed frames. Without lifecycle and interaction publishers, the open-session liveness and approval updates do not arrive. Define their source transitions, channel payloads, and tests, or remove them from the M3 contract.

Also applies to: 293-300

🟡 Minor comments (13)
web/mobile/src/features/sessions/SessionListScreen.tsx-74-76 (1)

74-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not report search-excluded sessions as unloaded.

When search is nonempty, merged contains only search matches but pendingBySession still contains every project pending session. pending.unloaded then claims that search-excluded sessions are further down the list, although loading more pages cannot show them.

Scope pending IDs to the search result, or suppress the unloaded count and load-more prompt while a search is active. Add a regression test for this state.

web/mobile/src/features/context/ContextResolver.tsx-30-31 (1)

30-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the loading state until router.isReady is true.

If a saved context exists during the first client render, shortcut disables the query while selectContextTarget returns null because the router is not ready. Line 71 then skips the loading branch and renders “Something went wrong.” before the redirect starts.

Proposed fix
-    if (target || (!shortcut && query.isPending)) {
+    if (!router.isReady || target || (!shortcut && query.isPending)) {

Also applies to: 71-74

web/mobile/src/features/auth/EmailPasswordForm.tsx-33-50 (1)

33-50: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Give every auth input an accessible name. All the new sign-in fields rely on placeholder alone. A placeholder is not a reliable accessible name, and it disappears once the user types. Add an aria-label, or add a visually hidden <label> bound with htmlFor.

  • web/mobile/src/features/auth/EmailPasswordForm.tsx#L33-L50: add aria-label="Email" to the email input and aria-label="Password" to the password input.
  • web/mobile/src/features/auth/EmailOtpForm.tsx#L84-L92: add aria-label="Email" to the email input.
  • web/mobile/src/features/auth/EmailOtpForm.tsx#L106-L116: add aria-label="6-digit code" to the code input.
web/mobile/src/features/auth/EmailPasswordForm.tsx-22-28 (1)

22-28: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the onSuccess() call in both sign-in forms. Both forms await onSuccess() and return without resetting their pending state. useAuthSuccess calls queryClient.invalidateQueries and router.replace, and both can reject. If the promise rejects, the form stays disabled with no error, and the rejection is unhandled.

  • web/mobile/src/features/auth/EmailPasswordForm.tsx#L22-L28: wrap await onSuccess() in try/catch; in the catch, call setPending(false) and set an error message.
  • web/mobile/src/features/auth/EmailOtpForm.tsx#L37-L41: wrap await onSuccess() in try/catch; in the catch, dispatch {type: "failed", message: ...} so the phase leaves "verifying".
🛡️ Proposed fix for EmailPasswordForm.tsx
         const outcome = await signInWithEmailPassword(email.trim(), password)
         if (outcome.kind === "ok") {
-            await onSuccess()
-            return
+            try {
+                await onSuccess()
+                return
+            } catch {
+                setPending(false)
+                setError("Signed in, but navigation failed. Try again.")
+                return
+            }
         }
web/mobile/src/features/auth/SignInScreen.tsx-71-73 (1)

71-73: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a page heading for the sign-in screen.

The header contains only a logo and a <p>. Screen-reader users cannot reach the page purpose through heading navigation. Promote the description to an <h1>, or add a visually hidden <h1>.

♿ Proposed fix
             <header className="flex flex-col items-center gap-3">
                 <AgentaLogo className="text-foreground h-6 w-auto" />
-                <p className="text-muted-foreground text-xs">Sign in or create an account.</p>
+                <h1 className="text-muted-foreground text-xs font-normal">
+                    Sign in or create an account.
+                </h1>
             </header>
web/mobile/src/features/auth/SsoDiscoveryForm.tsx-97-105 (1)

97-105: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a programmatic label for the email input.

A placeholder is not an accessible label. Add an associated <label> or aria-label="Work email" so screen reader users can identify the field.

web/mobile/src/features/chat/ApprovalDock.tsx-64-75 (1)

64-75: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add an accessible name to the redirect textarea.

The text at Lines 64-66 is not associated with the textarea. Screen readers announce an unnamed control. Add a <label> or an aria-label.

web/mobile/src/features/chat/TurnRow.tsx-64-68 (1)

64-68: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Allow the approval tool label to shrink.

truncate cannot constrain this flex item while its intrinsic minimum width remains active. Long tool names can overflow the transcript on narrow screens. Add min-w-0 max-w-full to the row and min-w-0 to the label.

web/packages/agenta-chat/src/state/sessionMessages.ts-158-168 (1)

158-168: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the watermark write against the same quota failure.

writeMessagesWithQuotaGuard handles QuotaExceededError for the messages key. The set(sessionRecordCountsAtom, counts) call at the end has no guard. If storage is full, this write can throw a DOMException that escapes the atom writer and reaches the effect in useAgentConversation. The reported failure would then be an unhandled error instead of a skipped persistence.

🛡️ Proposed guard
-        set(sessionRecordCountsAtom, counts)
+        try {
+            set(sessionRecordCountsAtom, counts)
+        } catch (e) {
+            if (!isQuotaExceeded(e)) throw e
+            console.warn("[agent-chat] record-count store over quota; skipping watermark write")
+        }
web/packages/agenta-chat/src/assets/transcriptToMessages.ts-139-159 (1)

139-159: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Attachment-only messages still add an empty text part.

draft.parts.push({type: "text", text: str(payload.text)}) runs before the attachment loop. A message record that carries only attachments therefore replays with an empty text part in front of the file parts. If the renderer draws a bubble for an empty text part, the replayed turn shows a blank line. Confirm the OSS original behaves the same before changing it, because this file must keep parity.

🐛 Optional guard
-            draft.parts.push({type: "text", text: str(payload.text)})
+            const text = str(payload.text)
+            if (text) draft.parts.push({type: "text", text})
docs/design/agenta-mobile/design.md-176-183 (1)

176-183: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the WP0 closure status.

The banner says all five items are implemented, but Line 198 still lists the liveness filter as deferred and Fern regeneration as pending. State that the required items are closed, keep optional work explicitly deferred, and label the following list as historical gap analysis.

docs/design/agenta-sessions-consolidation/plan.md-47-50 (1)

47-50: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language to the fenced command block.

The fence at Line 47 triggers MD040. Use shell for the grep commands.

Based on static analysis: markdownlint reported MD040 for this fence.

Source: Linters/SAST tools

docs/design/agenta-sessions-consolidation/plan.md-290-297 (1)

290-297: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run each standing gate from the repository root.

In one shell, the first cd web/packages/agenta-entities persists. The next cd web/packages/agenta-chat then resolves relative to that package and fails. Wrap each command in a subshell or reset to the repository root before every command.

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

170-217: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: pass the callback cookie through the input helper instead of mutating i.cookie.

Four tests reassign i.cookie after input() returns. The helper already spreads ...rest over the built object, so a cookies option would remove the mutation and the repetition.

♻️ Proposed helper change
-const input = (overrides: Partial<GateInput> & {headers?: Record<string, string>}): GateInput => {
-    const {headers = {}, ...rest} = overrides
-    const cookies: Record<string, string> = {}
+const input = (
+    overrides: Partial<GateInput> & {
+        headers?: Record<string, string>
+        cookies?: Record<string, string>
+    },
+): GateInput => {
+    const {headers = {}, cookies = {}, ...rest} = overrides

Then each test becomes:

-        const i = input({pathname: "/auth/callback/github", headers: docHeaders(DESKTOP_UA)})
-        i.cookie = (name) => (name === MOBILE_AUTH_CALLBACK_COOKIE ? "1" : undefined)
-        expect(decideDesktopGate(i)).toEqual({
+        expect(
+            decideDesktopGate(
+                input({
+                    pathname: "/auth/callback/github",
+                    headers: docHeaders(DESKTOP_UA),
+                    cookies: {[MOBILE_AUTH_CALLBACK_COOKIE]: "1"},
+                }),
+            ),
+        ).toEqual({
             kind: "redirect",
             location: "/m/auth/callback/github",
         })
web/mobile/src/components/FilterChip.tsx (1)

7-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce nonessential multi-line comments.

  • web/mobile/src/components/FilterChip.tsx#L7-L14: remove the UI rationale or reduce it to one short comment.
  • web/mobile/src/features/sessions/SessionListScreen.tsx#L147-L148: remove the layout rationale or reduce it to one short comment.

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

web/mobile/src/features/auth/EmailOtpForm.tsx (1)

128-128: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the cooldown copy from OTP_RESEND_COOLDOWN_MS.

The label hardcodes 60s. OTP_RESEND_COOLDOWN_MS controls the actual wait. If the constant changes, this text becomes wrong.

♻️ Proposed refactor
-                    {state.resendBlocked ? "Code sent — wait 60s to resend" : "Resend code"}
+                    {state.resendBlocked
+                        ? `Code sent — wait ${Math.round(OTP_RESEND_COOLDOWN_MS / 1000)}s to resend`
+                        : "Resend code"}
web/mobile/src/features/auth/SignInScreen.tsx (1)

46-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The providers.length === 0 check is redundant.

ssoDiscovery is isOidcEnabled(), and isOidcEnabled returns true whenever listOidcProviders is non-empty (web/mobile/src/lib/auth/config.ts lines 57-60). So providers.length > 0 already implies ssoDiscovery. The condition is equivalent to methods.mode === "disabled" && !methods.ssoDiscovery.

Keeping the explicit check is defensible as documentation of intent. Remove it only if you want the tighter expression.

web/mobile/src/features/auth/SsoDiscoveryForm.tsx (1)

10-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten this comment.

This comment describes normal control flow. Replace it with one short line or remove it.

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

web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts (1)

124-137: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the attachment replay path.

The new message-with-attachments branch and the filename field on file events have no test here. A record-level fixture is enough: assert the file part's url, mediaType, filename, and providerMetadata.agenta.attachmentId. This locks the URL contract that attachmentContentUrl produces, which the PR description notes was not exercised against a real attachment session.

docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md (1)

259-266: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consolidate the M3 tenant-boundary guidance with the consolidated plan.

The M3 plan now specifies project_id only via request.state.project_id, but keep the same explicit instruction in docs/design/agenta-sessions-consolidation/plan.md for S4 and section 5.3 so the shared relay contract stays aligned.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d81cb020-23f8-4fa9-8e4f-2c61becd864d

📥 Commits

Reviewing files that changed from the base of the PR and between 4c08deb and b5476ae.

⛔ Files ignored due to path filters (1)
  • web/mobile/src/styles/theme.generated.css is excluded by !**/*.generated.*
📒 Files selected for processing (83)
  • api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py
  • docs/design/agenta-mobile/README.md
  • docs/design/agenta-mobile/chat-headless-contract.md
  • docs/design/agenta-mobile/design.md
  • docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md
  • docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md
  • docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md
  • docs/design/agenta-mobile/plans/2026-07-26-wp5-device-gate.md
  • docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md
  • docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md
  • docs/design/agenta-mobile/plans/2026-08-01-pr-stack-split.md
  • docs/design/agenta-sessions-consolidation/plan.md
  • web/mobile/scripts/generate-shadcn-tokens.ts
  • web/mobile/src/components/AgentaLogo.tsx
  • web/mobile/src/components/FilterChip.tsx
  • web/mobile/src/components/ScreenScaffold.tsx
  • web/mobile/src/components/StatusTag.tsx
  • web/mobile/src/features/auth/AuthDivider.tsx
  • web/mobile/src/features/auth/EmailOtpForm.tsx
  • web/mobile/src/features/auth/EmailPasswordForm.tsx
  • web/mobile/src/features/auth/OidcCallbackScreen.tsx
  • web/mobile/src/features/auth/OidcProviderButtons.tsx
  • web/mobile/src/features/auth/SignInScreen.tsx
  • web/mobile/src/features/auth/SsoDiscoveryForm.tsx
  • web/mobile/src/features/auth/authStyles.ts
  • web/mobile/src/features/auth/states/AuthMethodsSkeleton.tsx
  • web/mobile/src/features/auth/states/NoAuthMethods.tsx
  • web/mobile/src/features/auth/useAuthSuccess.ts
  • web/mobile/src/features/chat/ApprovalDock.tsx
  • web/mobile/src/features/chat/AttachmentPart.tsx
  • web/mobile/src/features/chat/ChatScreen.tsx
  • web/mobile/src/features/chat/TurnRow.tsx
  • web/mobile/src/features/chat/steer.ts
  • web/mobile/src/features/chat/transcriptAdoption.ts
  • web/mobile/src/features/chat/useApprovalActions.ts
  • web/mobile/src/features/chat/useSessionTranscript.ts
  • web/mobile/src/features/chat/useSessionWatch.ts
  • web/mobile/src/features/context/ContextResolver.tsx
  • web/mobile/src/features/context/ProjectList.tsx
  • web/mobile/src/features/context/ProjectSwitcher.tsx
  • web/mobile/src/features/context/WorkspaceProjectList.tsx
  • web/mobile/src/features/context/WorkspaceSelector.tsx
  • web/mobile/src/features/context/contextTarget.ts
  • web/mobile/src/features/context/states/SignedOutNotice.tsx
  • web/mobile/src/features/context/workspaceGroups.ts
  • web/mobile/src/features/sessions/SessionListScreen.tsx
  • web/mobile/src/features/sessions/SessionRow.tsx
  • web/mobile/src/features/sessions/SessionSearchBar.tsx
  • web/mobile/src/features/sessions/mergeSessionRows.ts
  • web/mobile/src/features/sessions/pendingFilter.ts
  • web/mobile/src/features/sessions/states/SessionListStates.tsx
  • web/mobile/src/features/sessions/useLivenessPoll.ts
  • web/mobile/src/features/sessions/useSessionListHead.ts
  • web/mobile/src/features/sessions/useSessionsInfinite.ts
  • web/mobile/src/lib/auth.ts
  • web/mobile/src/lib/auth/config.ts
  • web/mobile/src/lib/auth/discover.ts
  • web/mobile/src/lib/auth/index.ts
  • web/mobile/src/lib/auth/otpMachine.ts
  • web/mobile/src/middleware.ts
  • web/mobile/src/pages/auth/callback/[[...provider]].tsx
  • web/mobile/src/pages/auth/index.tsx
  • web/mobile/src/styles/globals.css
  • web/mobile/tests/unit/authConfig.test.ts
  • web/mobile/tests/unit/authDiscover.test.ts
  • web/mobile/tests/unit/contextTarget.test.ts
  • web/mobile/tests/unit/livenessBadge.test.ts
  • web/mobile/tests/unit/mergeSessionRows.test.ts
  • web/mobile/tests/unit/middleware.test.ts
  • web/mobile/tests/unit/otpMachine.test.ts
  • web/mobile/tests/unit/pendingFilter.test.ts
  • web/mobile/tests/unit/steer.test.ts
  • web/mobile/tests/unit/transcriptAdoption.test.ts
  • web/mobile/tests/unit/workspaceGroups.test.ts
  • web/packages/agenta-chat/src/assets/loadSession.ts
  • web/packages/agenta-chat/src/assets/transcriptToMessages.ts
  • web/packages/agenta-chat/src/hooks/useAgentConversation.ts
  • web/packages/agenta-chat/src/state/sessionMessages.ts
  • web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts
  • web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts
  • web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts
  • web/packages/agenta-shared/src/utils/mobileGate/index.ts
  • web/packages/agenta-shared/tests/unit/mobileGate.test.ts
💤 Files with no reviewable changes (2)
  • web/mobile/src/features/context/WorkspaceProjectList.tsx
  • web/mobile/src/lib/auth.ts

Comment on lines +114 to +118
- `NoMobilePageWrapper` is retired when the gate is actually turned **on**, not when its code
ships. WP5 landed the gate default-off (`AGENTA_MOBILE_GATE`), so retiring the wrapper any
earlier would leave a mobile visitor with neither the gate nor the blocker. Retirement (T8)
belongs to the deployment window that flips the flag, once WP2 and WP4 can serve what the
redirect points at.

Copy link
Copy Markdown

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

Separate T8 from the WP5 dependency row.

Lines 114-118 correctly require WP2, WP4, and an enabled AGENTA_MOBILE_GATE. Line 384 still includes retire NoMobilePageWrapper in WP5 with only WP1 as a dependency. A reader can apply T8 while the gate is off and remove the only mobile blocker. Update the table to list gate delivery and T8 retirement as separate milestones.

Comment on lines +92 to +100
session_streams). Both ORDER BY and the keyset cursor filters must ride the SAME
expression — ordering by one column while cursor-filtering on another paginates
incorrectly.

That expression is `coalesce(updated_at, created_at)`, not bare `updated_at`:
`updated_at` is nullable, and a DESC sort puts NULLs first in Postgres, so a session
that never got a heartbeat would sit above every active one. The full statement
therefore mentions `created_at` by design — assert on the coalesced expression, not on
the absence of that column.

Copy link
Copy Markdown

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

Make the coalesced expression the implementation and test contract.

The new text requires coalesce(updated_at, created_at) for ordering and cursor predicates. The implementation steps still register raw DBE.updated_at, the sample assertions still require bare updated_at and forbid created_at, and the no-windowing fallback still uses raw updated_at. Update all references to use the same coalesced expression.

Also applies to: 149-156

Comment on lines +387 to +392
# Match the `/m` SEGMENT, not every path whose first two characters are `/m`:
# a bare PathPrefix(`/m`) would also capture /mobile, /metrics and friends and
# steal them from the web catch-all. The rule still auto-wins over PathPrefix(`/`)
# by length; no stripprefix — the app is built with basePath /m.
labels:
- "traefik.http.routers.web-mobile.rule=PathPrefix(`/m`)"
- "traefik.http.routers.web-mobile.rule=(Path(`/m`) || PathPrefix(`/m/`))"

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files matching target:"
git ls-files | rg 'docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail\.md|2026-07-25-wp1-infra-tail\.md' || true

echo
echo "Targeted matcher occurrences with line numbers:"
rg -n 'PathPrefix\(`/m`\)|Path\(`/m`\)|PathPrefix\(`/m/`\)|web-mobile|traefik\.http\.routers\.web-mobile' docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md || true

echo
echo "Context around candidate lines:"
sed -n '360,475p' docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md

Repository: Agenta-AI/agenta

Length of output: 15707


🌐 Web query:

Traefik PathPrefix matcher documentation

💡 Result:

In Traefik, the PathPrefix matcher is used to match incoming HTTP requests based on the URL path [1]. It identifies requests where the path begins with a specified prefix [1]. Key characteristics of PathPrefix include: 1. Functional scope: PathPrefix matches the specified prefix and any sub-paths or variations that start with that string [1]. For example, PathPrefix(/products) will match /products, /products/shoes, /products/, and even /products-for-sale [1]. 2. Non-regexp: PathPrefix does not support regular expressions [2]. If you require regex-based matching for paths, you must use the PathRegexp matcher instead [1][2]. 3. Syntax: Paths used with matchers must always start with a forward slash (/) [1]. Comparison with other path matchers: - Path: Used for an exact match of the path. For example, Path(/products) matches /products but does not match /products/ or /products/shoes [1]. - PathPrefix: Used for a prefix match, as described above [1]. - PathRegexp: Used for complex matching using regular expressions [1]. For further configuration details and usage examples, you can refer to the official Traefik documentation on HTTP routers rules and priority [1].

Citations:


🌐 Web query:

Traefik PathRegexp matcher documentation

💡 Result:

The PathRegexp matcher in Traefik allows you to match request paths using regular expressions [1][2]. It is distinct from the Path and PathPrefix matchers, which are used for exact and prefix-based matching, respectively [1]. Key characteristics and usage: - Syntax: PathRegexp uses the Go-flavored regular expression syntax [3][4]. - Flexibility: Unlike Path and PathPrefix, which always start with a slash (/), PathRegexp does not have this restriction [1]. - Dynamic Routing: In Traefik v3, PathRegexp is the recommended way to handle dynamic path segments (e.g., path parameters) that were previously supported via placeholders like {id} in v2 [3]. Common Examples: - Matching specific patterns with IDs: PathRegexp(^/products/(shoes|socks)/[0-9]+$) [1][4]. - Matching file extensions: PathRegexp(\.(jpeg|jpg|png)$) [1][4]. - Case-insensitive prefix matching: PathRegexp((?i)^/products) [1][4]. Migration Note: If you are migrating from Traefik v2 to v3, note that Path and PathPrefix no longer support path parameter placeholders (e.g., /route/{id}). You must update these rules to use PathRegexp instead (e.g., ^/route/[^/]+$) [3].

Citations:


Use segment-safe Traefik mappers in every web-mobile rule.

PathPrefix(/m) also matches /mobile, /metrics, and similar /m* paths. OSS SSL and SS local still use PathPrefix(/m), and EE local says it uses the same labels as EE gh, which also uses PathPrefix(/m). Apply (Path(/m) || PathPrefix(/m/)) to every documented web-mobile router that uses PathPrefix(/m).

Comment on lines +94 to +104
evaluated once at connect; scope holds for the connection's lifetime. That is a real
window: revoking a user's `VIEW_SESSIONS` does not close streams they already hold, so
they keep receiving change notifications (never payloads — see the wire contract) until
they reconnect. **Not bounded as shipped** — there is no maximum stream age today
(`env.sessions` carries only `watch_heartbeat_seconds` and `watch_retry_milliseconds`).
Accepted for M3 because the stream carries no payloads, only "something changed" for a session
the client was already authorized to read, and every revalidation it triggers is a normal
authorized request that fails on its own once access is gone. The fix when that is not enough:
close the response after a configured max age and let `EventSource` reconnect, which re-runs
auth and `check_action_access`. Cheaper than re-checking mid-stream and gives the same upper
bound on staleness.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Bound the authorization lifetime of an open watch.

The plan accepts an unlimited connection after one VIEW_SESSIONS check. The later wire contract sends session_id and lifecycle or interaction status, so an already-open stream can expose state changes after access is revoked. Set a finite maximum age and reconnect, or reauthorize before each event. Do not describe these frames as payload-free.

| --- | --- | --- | --- | --- | --- | --- |
| **S0** | Fix the drifted twin **before** anything else | Re-align `@agenta/chat/state/sessionMessages.ts` to `sessions.ts` byte-parity: pass `tabLocalStorage()` (port the `subscribe`-stripping storage), and either add the record-count watermark store or make the writer refuse to write the shared key without one | nothing changes (no consumer) | new `agenta-chat` unit test: writing through the package leaves `agenta:agent-chat:record-counts` consistent with the OSS reader's expectation | **No** | revert one file |
| **S1** | Activity key + ordering, one definition | New `session/core/ordering.ts`: `sessionActivityMs(stream)` (= `Date.parse(updated_at ?? created_at)`, NaN→0), `compareByActivityDesc`, `dedupeBySessionId` | `projectSessions.ts:55-70` imports them; `useSessionsInfinite.ts:29` and `SessionRow.tsx:35` import `sessionActivityMs` | entities unit tests over the three current behaviours (NaN, missing `updated_at`, dedupe tie) | yes, ~15 lines in one file | revert; the local copies are 5 lines each |
| **S2** | Shared list query **options** | `session/state/list.ts`: `sessionListQueryKey(projectId, {references, search, windowing})` + `sessionListQueryOptions(...)` returning `{queryKey, queryFn, staleTime}` — **not** a mounted query | `projectSessionsQueryAtomFamily` wraps it in `atomWithQuery`; `useSessionsInfinite` wraps it in `useInfiniteQuery` | key-shape unit test; mobile 83 unchanged; oss tsc | yes, `projectSessions.ts` only | revert; both call sites keep working with inline options |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '(^|/)(session/state/list\.ts|session/state/project-sessions\.ts|.*projectSessions.*\.ts|.*useSessionsInfinite.*\.ts|plan\.md)$' || true

echo "== search identifiers =="
rg -n "sessionStateQueryKey|sessionListQueryKey|useSessionsInfinite|includeArchived|includeEnded|windowing|pageParam|queryKey" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== plan lines around 307 =="
if [ -f docs/design/agenta-sessions-consolidation/plan.md ]; then
  sed -n '280,330p' docs/design/agenta-sessions-consolidation/plan.md
fi

echo "== candidate file sizes =="
for f in $(git ls-files | rg '(^|/)(session/state/list\.ts|session/state/project-sessions\.ts|.*projectSessions.*\.ts|.*useSessionsInfinite.*\.ts)$'); do
  wc -l "$f"
done

Repository: Agenta-AI/agenta

Length of output: 7095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files list =="
git ls-files | rg '(^|/)session/state/(list|.*sessions).*\.ts$|web/(oss|mobile)/src/features/sessions/(useSessionsInfinite|sessions).ts$|projectSessions\.ts$' || true

echo "== focused symbol search =="
rg -n "sessionListQueryKey|sessionListQueryOptions|useSessionsInfinite|includeArchived|includeEnded|include_archived|include_ended|windowing|pageParam|sessionAt|queryKey" web/oss web/mobile/ --glob '*.ts' --glob '*.tsx' || true

echo "== session state files sizes =="
while IFS= read -r f; do
  wc -l "$f"
done < <(git ls-files | rg '(^|/)src/[^/]+/[^/]+/(sessions\.ts|list\.ts)$')

Repository: Agenta-AI/agenta

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant implementation slices =="
for f in web/mobile/src/features/sessions/useSessionsInfinite.ts web/oss/src/components/AgentChatSlice/state/projectSessions.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
    sed -n '1,220p' "$f"
  fi
done

for f in $(git ls-files | rg '(^|/)src/[^/]+/[^/]+/(sessions\.ts|list\.ts)$'); do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  rg -n "function|const|queryKey|queryKeyFn|includeArch|includeEnd|includeArchived|includeEnded|windowing|pageParam|sessionListQueryKey|sessionListQueryOptions|atomWithQuery|useInfiniteQuery" "$f"
done

Repository: Agenta-AI/agenta

Length of output: 6855


Include the stable session list filters in the shared query key.

The desktop/OSS caller does not currently pass archive or ended filters, but the mobile infinite query uses includeArchived: false on every page. Model the shared stable filter shape: include references, search, includeArchived, and includeEnded; keep cursors out of windowing for the key and pass next/newest only through pageParam. Add key-shape tests covering the desktop/matrix call sites and infinite-scroll pages.

Comment on lines +21 to +25
const query = useQuery({
queryKey: ["mobile", "projects"],
queryFn: () => fetchProjects(),
staleTime: 30_000,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline web/mobile/src/features/context/ProjectSwitcher.tsx --items all
ast-grep outline web/mobile/src/features/sessions/useSessionListHead.ts --items all
rg -n -C 3 'atomWithQuery|useQuery' web/mobile/src

Repository: Agenta-AI/agenta

Length of output: 9167


Use query atoms for mobile API queries.

These useQuery calls bypass the mobile data-fetching boundary required by the atomWithQuery guideline.

  • Keep fetchProjects() behind a query atom and consume that atom in ProjectSwitcher and ContextResolver.
  • Move useSessionListHead()’s querySessions() call into a query atom and keep the current input key, enablement, stale time, and refetch interval on that atom.
📍 Affects 2 files
  • web/mobile/src/features/context/ProjectSwitcher.tsx#L21-L25 (this comment)
  • web/mobile/src/features/sessions/useSessionListHead.ts#L20-L34

Source: Coding guidelines

Comment on lines +57 to +72
export function isOidcEnabled(read: EnvReader = getEnv): boolean {
if (read("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").toLowerCase() === "true") return true
return listOidcProviders(read).length > 0
}

/**
* Effective email-auth mode. NEXT_PUBLIC_AGENTA_AUTHN_EMAIL wins; unset
* defaults to "password" only when no OIDC provider is enabled (otherwise the
* deployment is SSO-only and email must stay hidden).
*/
export function getEmailSignInMode(read: EnvReader = getEnv): EmailSignInMode {
const authnEmail =
read("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL") || (isOidcEnabled(read) ? "" : "password")
if (authnEmail === "password" || authnEmail === "otp") return authnEmail
return "disabled"
}

Copy link
Copy Markdown

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

Normalize the env values before comparison.

Line 58 lowercases the OIDC flag but does not trim it. Line 69 neither trims nor lowercases NEXT_PUBLIC_AGENTA_AUTHN_EMAIL.

getEnv can return values injected into window.__env at runtime, where trailing whitespace is easy to introduce. A value of "password " or "Password" falls through to "disabled". On a password-only deployment that removes the only sign-in method and SignInScreen renders NoAuthMethods. The failure is silent.

🐛 Proposed fix
 export function isOidcEnabled(read: EnvReader = getEnv): boolean {
-    if (read("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").toLowerCase() === "true") return true
+    if (read("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").trim().toLowerCase() === "true") return true
     return listOidcProviders(read).length > 0
 }
 
 export function getEmailSignInMode(read: EnvReader = getEnv): EmailSignInMode {
     const authnEmail =
-        read("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL") || (isOidcEnabled(read) ? "" : "password")
+        read("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL").trim().toLowerCase() ||
+        (isOidcEnabled(read) ? "" : "password")
     if (authnEmail === "password" || authnEmail === "otp") return authnEmail
     return "disabled"
 }

Add unit cases for whitespace and mixed-case values in web/mobile/tests/unit/authConfig.test.ts.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function isOidcEnabled(read: EnvReader = getEnv): boolean {
if (read("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").toLowerCase() === "true") return true
return listOidcProviders(read).length > 0
}
/**
* Effective email-auth mode. NEXT_PUBLIC_AGENTA_AUTHN_EMAIL wins; unset
* defaults to "password" only when no OIDC provider is enabled (otherwise the
* deployment is SSO-only and email must stay hidden).
*/
export function getEmailSignInMode(read: EnvReader = getEnv): EmailSignInMode {
const authnEmail =
read("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL") || (isOidcEnabled(read) ? "" : "password")
if (authnEmail === "password" || authnEmail === "otp") return authnEmail
return "disabled"
}
export function isOidcEnabled(read: EnvReader = getEnv): boolean {
if (read("NEXT_PUBLIC_AGENTA_AUTH_OIDC_ENABLED").trim().toLowerCase() === "true") return true
return listOidcProviders(read).length > 0
}
/**
* Effective email-auth mode. NEXT_PUBLIC_AGENTA_AUTHN_EMAIL wins; unset
* defaults to "password" only when no OIDC provider is enabled (otherwise the
* deployment is SSO-only and email must stay hidden).
*/
export function getEmailSignInMode(read: EnvReader = getEnv): EmailSignInMode {
const authnEmail =
read("NEXT_PUBLIC_AGENTA_AUTHN_EMAIL").trim().toLowerCase() ||
(isOidcEnabled(read) ? "" : "password")
if (authnEmail === "password" || authnEmail === "otp") return authnEmail
return "disabled"
}

Comment on lines +23 to +39
export function parseDiscoveredSso(payload: unknown): DiscoveredSsoProvider[] {
const methods = (payload as {methods?: {sso?: {providers?: unknown}}} | null)?.methods
const providers = methods?.sso?.providers
if (!Array.isArray(providers)) return []
return providers.flatMap((entry) => {
const record = entry as {id?: unknown; slug?: unknown; third_party_id?: unknown}
// No third_party_id ⇒ nothing to hand SuperTokens; drop it.
if (typeof record.id !== "string" || typeof record.third_party_id !== "string") return []
const slug = typeof record.slug === "string" ? record.slug : record.third_party_id
return [
{
id: record.id,
thirdPartyId: record.third_party_id,
label: formatSsoLabel(slug, record.third_party_id),
},
]
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline web/packages/agenta-sdk/src/resources.ts --items all
rg -n -C 3 '/auth/discover|discoverSsoProviders|safeParseWithLogging' web

Repository: Agenta-AI/agenta

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '22,75p' web/mobile/src/lib/auth/discover.ts
sed -n '1,80p' web/packages/agenta-entities/src/shared/utils/zodSchema.ts 2>/dev/null || true
sed -n '40,70p' web/packages/agenta-entities/shared/utils/zodSchema.ts 2>/dev/null || true
rg -n -C 3 'postAuthDiscover|discover.*Sso|getAuth|Auth' web/packages/agenta-packages web/packages/agenta-sdk web/mobile/src/web.js web/packages web/packages/agenta-api-client/src/generated 2>/dev/null | head -n 200

Repository: Agenta-AI/agenta

Length of output: 23686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n '\.access\.|getAccessClient|AccessClient|postAuthDiscover|auth/discover' web/mobile web/packages/agenta-sdk web/packages/*.json web/tsconfig*.json 2>/dev/null | head -n 120
python3 - <<'PY'
from pathlib import Path
import re
for p in [Path('web/mobile/src/lib/auth/discover.ts'), Path('web/mobile/src/features/auth/SsoDiscoveryForm.tsx')]:
    text=p.read_text()
    if p.name=='SsoDiscoveryForm.tsx': m=re.search(r'startOidcSignIn\(\s*provider\.thirdPartyId\s*,\s*",?\s*email"[^)]*\)', text, re.S)
    else: m=None
    print(f'{p}:')
    if p.name=='discover.ts':
        print('contains_fetch_to_auth_discover=', bool(re.search(r'fetch\(\s*[`"]\\$\{getApiUrl\}\(\)\s*/auth/discover[`"]', text)))
        print('contains_raw_cast=', bool(re.search(r'\(payload\s+as\b|\(entry\s+as\b', text)))
        print('allows_empty_third_party_id=', bool(re.search(r'typeof\s+record\.third_party_id\s*!==?\s*["\']string["\']', text)))
    else:
        print('startOidcSignIn_source=', m.group(0) if m else 'NOT FOUND')
PY

Repository: Agenta-AI/agenta

Length of output: 970


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '45,65p' web/mobile/src/features/auth/SsoDiscoveryForm.tsx
sed -n '1,200p' web/packages/agenta-entities/src/shared/utils/zodSchema.ts

Repository: Agenta-AI/agenta

Length of output: 7407


Use the required API boundary for SSO discovery.

discoverSsoProviders uses raw fetch and unchecked casts. Add a per-resource Fern accessor for /auth/discover to web/packages/agenta-sdk/src/resources.ts, then call that accessor behind a local safeParseWithLogging Zod boundary. Require non-empty id and third_party_id, otherwise an empty thirdPartyId reaches startOidcSignIn.

Source: Coding guidelines

Comment on lines +5 to +9
const FLAG = "NEXT_PUBLIC_AGENT_CHAT_STEER"

afterEach(() => {
delete process.env[FLAG]
})

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how the steer flag is read on mobile and how runtime env is exposed.
fd -g 'steer.ts' web/mobile -x cat -n {}
rg -nP -C3 '__env|NEXT_PUBLIC_AGENT_CHAT_STEER' web/mobile/src

Repository: Agenta-AI/agenta

Length of output: 6824


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Entrypoint env generation:"
if [ -f web/entrypoint.sh ]; then
  cat -n web/entrypoint.sh | sed -n '1,220p'
else
  fd -a 'entrypoint.sh' . -x cat -n {}
fi

echo
echo "Steer tests:"
cat -n web/mobile/tests/unit/steer.test.ts

echo
echo "Config/runtime references:"
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' '__env\.js|NEXT_PUBLIC_AGENT_CHAT_STEER|NEXT_PUBLIC_.*ENV|process\.environment|window\.__env' web/mobile web -g '*.tsx' -g '*.ts' -g '*.js' -g '*.sh' -g 'entrypoint.sh' -g '*_document.tsx' | sed -n '1,240p'

Repository: Agenta-AI/agenta

Length of output: 17717


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re, ast

env_file = Path("web/mobile/src/lib/env.ts")
test_file = Path("web/mobile/tests/unit/steer.test.ts")
entry = Path("web/entrypoint.sh")

print("env.ts source:")
print(env_file.read_text())

print("\nsteer.test.ts source:")
print(test_file.read_text())

print("\nentrypoint.sh contains NEXT_PUBLIC_AGENT_CHAT_STEER:")
print(entry.read_text().encode() if entry.exists else b"")
print(re.search(r"NEXT_PUBLIC_AGENT_CHAT_STEER", entry.read_text() if entry.exists else "") is not None)

print("\nMobile imports/use of getEnv in features/chat/steer.ts:")
feature = Path("web/mobile/src/features/chat/steer.ts")
if feature.exists:
    text = feature.read_text()
    print("imports getEnv:", "import {getEnv}" in text or "getEnv" in text)
    print(text)
PY

Repository: Agenta-AI/agenta

Length of output: 14685


Add mobile runtime config for NEXT_PUBLIC_AGENT_CHAT_STEER and restore the previous env value.

isSteerEnabled reads window.__env before process.env, but mobile /m/__env.js does not expose this flag. Add it through the same web/entrypoint.sh config path, with an OFF default, so browser behavior matches the test. Also save and restore process.env[FLAG] in afterEach so a pre-existing test-worker value is not removed.

Comment on lines +313 to +324
const adopt = (transcript: SessionTranscript | null) => {
if (cancelled || !transcript || transcript.messages.length === 0) return
const serverMsgs = transcript.messages
const prev = messagesRef.current
if (busyRef.current || serverMsgs.length <= prev.length) return
serverMsgs.forEach((m) => restoredIdsRef.current.add(m.id))
setMessages(serverMsgs)
persistMessages({id: sessionId, messages: serverMsgs})
persistMessages({
id: sessionId,
messages: serverMsgs,
recordCount: transcript.recordCount,
})

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate any reader of the record-count watermark and the shared adoption rule.
rg -nP -C6 'shouldAdoptServerTranscript|shouldAdoptTranscript|sessionRecordCountsReadAtom' web

Repository: Agenta-AI/agenta

Length of output: 44922


🏁 Script executed:

<tool_call>shell
#!/bin/bash
set -euo pipefail

# Inspect the desktop hook adoption code and any imports/exported watermark helpers.
fd -a 'useAgentConversation.ts|sessionMessages.ts' web/packages/agenta-chat | sed 's#^\./##'
echo '--- useAgentConversation outline ---'
ast-grep outline web/packages/agenta-chat/src/hooks/useAgentConversation.ts --view expanded | sed -n '1,200p'
echo '--- useAgentConversation lines 260-360 ---'
sed -n '260,360p' web/packages/agenta-chat/src/hooks/useAgentConversation.ts | nl -ba -v260
echo '--- sessionMessages imports/exports count ---'
sed -n '1,90p' web/packages/agenta-chat/src/state/sessionMessages.ts | nl -ba -v1
echo '--- desktop watermark/adoption symbols ---'
rg -n -C3 'recordCount|recordWatermark|shouldAdoptServerTranscript|persistSessionMessagesAtom|sessionRecordCountsReadAtom' web/packages/agenta-chat/src/hooks web/packages/agenta-chat/src/state

Repository: Agenta-AI/agenta

Length of output: 1782


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the desktop hook adoption code and any imports/exported watermark helpers.
fd -a 'useAgentConversation.ts|sessionMessages.ts' web/packages/agenta-chat | sed 's#^\./##'
echo '--- useAgentConversation outline ---'
ast-grep outline web/packages/agenta-chat/src/hooks/useAgentConversation.ts --view expanded | sed -n '1,200p'
echo '--- useAgentConversation lines 260-360 ---'
sed -n '260,360p' web/packages/agenta-chat/src/hooks/useAgentConversation.ts | nl -ba -v260
echo '--- sessionMessages imports/exports count ---'
sed -n '1,90p' web/packages/agenta-chat/src/state/sessionMessages.ts | nl -ba -v1
echo '--- desktop watermark/adoption symbols ---'
rg -n -C3 'recordCount|recordWatermark|shouldAdoptServerTranscript|persistSessionMessagesAtom|sessionRecordCountsReadAtom' web/packages/agenta-chat/src/hooks web/packages/agenta-chat/src/state

Repository: Agenta-AI/agenta

Length of output: 1730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the desktop hook adoption code and any imports/exported watermark helpers.
fd -a 'useAgentConversation.ts|sessionMessages.ts' web/packages/agenta-chat | sed 's#^\./##'

echo '--- useAgentConversation outline ---'
ast-grep outline web/packages/agenta-chat/src/hooks/useAgentConversation.ts --view expanded | sed -n '1,200p'

echo '--- useAgentConversation lines 260-360 ---'
sed -n '260,360p' web/packages/agenta-chat/src/hooks/useAgentConversation.ts | nl -ba -v260

echo '--- sessionMessages imports/exports count ---'
sed -n '1,90p' web/packages/agenta-chat/src/state/sessionMessages.ts | nl -ba -v1

echo '--- desktop watermark/adoption symbols ---'
rg -n -C3 'recordCount|recordWatermark|shouldAdoptServerTranscript|persistSessionMessagesAtom|sessionRecordCountsReadAtom' web/packages/agenta-chat/src/hooks web/packages/agenta-chat/src/state

Repository: Agenta-AI/agenta

Length of output: 1731


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=web/packages/agenta-chat/src/hooks/useAgentConversation.ts

echo '--- relevant counts/adoption lines ---'
grep -n -C8 'serverMsgs.length <= prev.length\|persistMessage' "$file" || true

echo '--- imports around shouldAdoptServerTranscript/sessionRecordCountsReadAtom occurrences ---'
grep -n -C3 'shouldAdoptServerTranscript|sessionRecordCountsReadAtom|recordCount|recordWatermark|persistSessionMessagesAtom' "$file" web/packages/agenta-chat/src/state/sessionMessages.ts || true

Repository: Agenta-AI/agenta

Length of output: 4851


Use the shared record-count watermark when adopting transcripts on desktop.

persistMessages persists recordCount, but both adoption paths decide with serverMsgs.length <= prev.length. A session turn can grow record-wise while leaving the message count unchanged, so that comparison rejects the updated transcript and the stored watermark becomes write-only. Read the persisted watermark and use the shared watermark-aware rule here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant