diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index c9a31e04a5..693945b28e 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -185,6 +185,14 @@ def compose_approval_messages( warm-parked sandbox still fingerprint-matches and resumes live). An optional deny-with-redirect ``message`` is appended as a trailing user message, which the fingerprint's prior-conversation slice excludes. + + Where that note is DELIVERED is asymmetric, and verified live (2026-07-30). A warm resume + answers the parked harness gate on the still-pending original prompt and sends no new + prompt (``run-turn.ts``: the resume branch reuses the parked ``promptPromise``), so the + note never reaches the model — only a cold replay closes the replayed transcript with it. + Fingerprint parity therefore makes the note undeliverable in the common case; mobile's + steer control stays flag-gated off until the runner can carry a redirect in-band with the + denial (#5444). The note is still persisted as a user record either way. """ messages = build_wire_messages(records) gated_id = resolve_gated_tool_call_id(records, interaction, answer) diff --git a/docs/design/agenta-mobile/README.md b/docs/design/agenta-mobile/README.md index e357e7c835..1b2962ab42 100644 --- a/docs/design/agenta-mobile/README.md +++ b/docs/design/agenta-mobile/README.md @@ -38,9 +38,9 @@ headless chat core shared between desktop and mobile skins). | [plans/2026-07-25-wp1-infra-tail.md](./plans/2026-07-25-wp1-infra-tail.md) | WP1 infra tail (prod image CI, compose, run.sh) — **EXECUTED** (Tasks 1-5, 7); fixed the latent entrypoint crash in the unbuilt mobile image. Only the first `workflow_dispatch` publish (Task 7 runbook) is still pending, and it's post-merge by design | | [plans/2026-07-26-wp5-device-gate.md](./plans/2026-07-26-wp5-device-gate.md) | WP5 device gate (flag-gated middleware, both directions) — **EXECUTED** (Tasks T1-T7); default-off, T8 banner-retirement deferred to flag-flip | -Wave-2 plans (WP2 auth + project drawer, WP3b mobile chat skin, WP4 product pages, WP5 device -gate) are **deliberately unwritten** — they must be planned against the real wave-1 code and the -finalized sessions surface. +Remaining wave-2 plans (WP2 auth + project drawer, WP3b mobile chat skin, WP4 product pages) are +**deliberately unwritten** — they must be planned against the real wave-1 code and the finalized +sessions surface. WP5 is no longer among them: its plan was written and executed (row above). ## Execution state @@ -65,10 +65,11 @@ What works right now: `cd web && pnpm dev-mobile` → http://localhost:3000/m re shell (light+dark from the bridged palette); `pnpm build-mobile` produces a standalone server; `pnpm --filter @agenta/mobile lint` enforces the bans + token sync; the dev compose stacks have a routable `web-mobile` service (needs a dev-image rebuild to pick up the Dockerfile changes). -Opt-in in dev too: `run.sh --dev --with-mobile` (originally it rode `with-web` and auto-started, -but a live dev run showed the second Next dev server pushes an 8GB Docker VM into OOM-killing -the main web app's first big Turbopack compile — dmesg-confirmed `next-server` kills at ~4.5GB -RSS. Running both dev servers comfortably wants a 12GB+ VM). +Opt-in in dev too, from the repo root (swap `--oss` for `--ee` to match the loaded env file): +`bash ./hosting/docker-compose/run.sh --oss --dev --with-mobile`. Originally it rode `with-web` +and auto-started, but a live dev run showed the second Next dev server pushes an 8GB Docker VM +into OOM-killing the main web app's first big Turbopack compile — dmesg-confirmed `next-server` +kills at ~4.5GB RSS. Running both dev servers comfortably wants a 12GB+ VM. ### WP0 residual — COMPLETE (2026-07-25, 9 commits, all dual-reviewed) @@ -179,7 +180,8 @@ breakdown and grounding facts. produces, because playwright specs are type-checked by the `web/tests` harness, not oss tsc. Accepted as precedented noise; the clean fix (excluding `tests/playwright` in `web/oss/tsconfig.json`) is a separate cleanup, not part of this WP. -- Combined six-commit review verdict: **approve**. Every code block landed byte-identical to +- Combined review verdict across the five commits above plus the T7 documentation commit: + **approve**. Every code block landed byte-identical to the plan, the desktop matcher regex was independently confirmed correct against the compiled middleware manifest (`/m` and `/m/*` excluded; `/models`-style paths still gated), and the middleware bundle carries only the gate core (no transitive leaks). Two deferred hardening @@ -262,7 +264,7 @@ navigation / flows / logic"). All raw-UI; the radix-primitives track re-skins la cd web && pnpm install pnpm --filter @agenta/mobile lint # bans + tokens:check pnpm --filter @agenta/mobile types:check -pnpm build-mobile && test -f web/../web/mobile/.next/standalone/mobile/server.js +pnpm build-mobile && test -f mobile/.next/standalone/mobile/server.js pnpm dev-mobile # → http://localhost:3000/m, check light+dark ``` @@ -282,10 +284,9 @@ pnpm dev-mobile # → http://localhost:3000/m, check light+dark - **Dev `__env.js`:** the entrypoint mirrors it into the bind-mounted `web/mobile/public/` on container start (gitignored). Until the dev image is rebuilt with the P5 Dockerfile changes, `/m/__env.js` 404s — harmless console noise. -- **Chores pending:** `.gitignore` allowlist entries for the `mobile-*` skills (they're tracked - via `git add -f`, matching repo precedent — new files inside them would be invisible to - `git status` until allowlisted); Fern regen so the FE `include_ended` param stops being a - runtime cast. +- **Chores:** the `.gitignore` allowlist entries for the `mobile-*` skills are **done** (see + `.gitignore` — both the `.agents/skills/` and `.claude/skills/` blocks). Still pending: Fern + regen so the FE `include_ended` param stops being a runtime cast. - **Mobile image CI + publication runbook** (WP1 infra tail, EXECUTED — [plans/2026-07-25-wp1-infra-tail.md](./plans/2026-07-25-wp1-infra-tail.md)): `.github/workflows/17-check-mobile.yml` build-verifies + smoke-tests the `agenta-web-mobile` @@ -293,7 +294,7 @@ pnpm dev-mobile # → http://localhost:3000/m, check light+dark in workflow 11) and `@agenta/chat` unit tests (recursive package discovery in workflow 12) were already covered by their generic mechanisms — verified, not changed. **First publication ordering:** merge → run `17 - check mobile` via `workflow_dispatch` with `push=true, - push_latest=true` → only then can operators pass `run.sh --gh --with-mobile`. Until that first + push_latest=true` → only then can operators pass `--with-mobile` to `bash ./hosting/docker-compose/run.sh --oss --gh` (or `--ee`). Until that first dispatch-push, the `with-web-mobile` compose profile stays opt-in on purpose — `docker compose up`/`pull` would fail the whole stack against a `ghcr.io/agenta-ai/agenta-web-mobile:latest` that doesn't exist yet. diff --git a/docs/design/agenta-mobile/chat-headless-contract.md b/docs/design/agenta-mobile/chat-headless-contract.md index 2c37ad201e..eb56d92e86 100644 --- a/docs/design/agenta-mobile/chat-headless-contract.md +++ b/docs/design/agenta-mobile/chat-headless-contract.md @@ -83,7 +83,7 @@ useAgentConversation({entityId, sessionId}): { useTurnRenderModel(message, ctx): RenderItem[] // lifted from AgentMessage.tsx:329-416 useComposerAttachments({sessionId, limits}): {files, rejections, add, remove, clear, atMax, toParts} useSessionHydration({sessionId}) -useApprovalDock({messages, onRespond}): {current, count, respond, approveAll, renderer} +useApprovalDock({messages, respond}): {open, current, count, responding, respond, approveAll} useClientToolDispatch() useAgentChatQueue(...) // exists — moves in useAgentModelKeyStatus(...) // exists — moves in diff --git a/docs/design/agenta-mobile/design.md b/docs/design/agenta-mobile/design.md index e1702e9705..92648afc8d 100644 --- a/docs/design/agenta-mobile/design.md +++ b/docs/design/agenta-mobile/design.md @@ -111,7 +111,11 @@ provider stack before deciding). - Escape hatch both ways: a `agenta-mobile-optout` / `agenta-mobile-optin` cookie set by "View desktop site" / "Open mobile version" links; middleware honors it. This also fixes the current banner's dismissal-not-persisted annoyance. -- `NoMobilePageWrapper` is retired once the gate ships. +- `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. **URL scheme (mobile app, under basePath `/m`):** @@ -169,7 +173,14 @@ Mobile consumes this as-is: project-wide list = `querySessions` without a `refer per-agent filter = the existing `references` filter; continue-session = resolve the session's latest turn (turns endpoints exist) → references → hydrate that revision. -### Residual gaps (the revised WP0 scope) +### Residual gaps (the revised WP0 scope) — CLOSED, kept as the record of what was scoped + +> **Status (2026-07-25): all five items below are implemented.** They are left here as the gap +> analysis that produced WP0, not as open work. Ordering rides `coalesce(updated_at, created_at)` +> with a direction-matched id tiebreak, search is an escaped `ilike` on the stream name, +> references are batch-hydrated by `latest_turn_per_session` (one `DISTINCT ON` query, not +> per-session), and the zod wire test exists. Only the optional Fern regen for `include_ended` +> is still outstanding. See the README's "WP0 residual" section for the commit-by-commit record. 1. **`updated_at` ordering/cursor** — the query DAO windows on `id` (uuid7 ≈ creation order); the FE sorts client-side per page, which breaks "last-activity" ordering across pages for @@ -364,7 +375,7 @@ same standard, per the repo's instruction-organization model: | WP | Scope | Depends on | |----|-------|------------| -| WP0 | BE residual (revised 2026-07-18 — list/title/linkage landed via sessions-extensions): `updated_at` windowing, title search, references echo on list rows, `querySessions` zod test | — | +| WP0 | **EXECUTED.** BE residual (revised 2026-07-18 — list/title/linkage landed via sessions-extensions): `updated_at` windowing, title search, references echo on list rows, `querySessions` zod test | — | | WP1 | Foundation: skill/instruction infrastructure, `web/mobile` scaffold, shadcn + token bridge + motion presets, lint import-bans, compose/Traefik/`__env.js` | — | | WP2 | Auth: mobile sign-in (headless SuperTokens + shadcn), callback, context resolution, project drawer | WP1 | | WP3a | `@agenta/chat` headless core: lift the behavior blocks (host hook, turn render model, hydration, approvals, attachments neutralization), generalize the registries, fixture tests; OSS re-imports the lifted pure blocks (behavior-neutral) | — | diff --git a/docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md b/docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md index da8e85aee8..1d5f0439fd 100644 --- a/docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md +++ b/docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md @@ -89,8 +89,15 @@ scroll. `session_streams.updated_at` is heartbeat-fed last activity. """apply_windowing must support `updated_at` as the order/cursor attribute. The sessions list is ordered by last activity (`updated_at` is heartbeat-fed on -session_streams). Both ORDER BY and the keyset cursor filters must ride updated_at — -ordering by updated_at while cursor-filtering on another column paginates incorrectly. +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. """ from datetime import datetime, timezone @@ -196,10 +203,12 @@ the references *filter*; extend it to hydrate. **Files** - Modify: `api/oss/src/core/sessions/service.py` (`query_sessions` — after fetching streams, - batch-fetch the latest turn per session via `SessionTurnsDAO` (one query, - `DISTINCT ON (session_id) ... ORDER BY session_id, turn_index DESC` or the DAO's existing - latest-turn helper from the turn-index fix `9613e7964e`) and attach `references` (+ - `trace_id` if cheap) to each row) + batch-fetch the latest turn per session via `SessionTurnsDAO` and attach `references` (+ + `trace_id` if cheap) to each row). This needs a NEW batch helper — + `latest_turn_per_session(session_ids)`, one `DISTINCT ON (session_id) ... ORDER BY + session_id, turn_index DESC` query. The existing latest-turn helper from the turn-index fix + `9613e7964e` takes a single session and would make `/sessions/query` an N+1 path; do not use + it here. Keep the one-call assertion in the service test. - Modify: response model — either add `references`/`latest_turn` to the session row model the root query returns, or wrap rows in an enriched envelope; follow whichever the track's maintainer style suggests (read `SessionsResponse` in `api/oss/src/apis/fastapi/sessions/models.py` first) diff --git a/docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md b/docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md index 8ed51ba583..be9dc85d5e 100644 --- a/docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md +++ b/docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md @@ -27,7 +27,7 @@ Stand up the foundation for the Agenta mobile web app per `docs/design/agenta-mo **Tech Stack** Next.js `15.5.18` (workspace pin, enforced by the `next@<15.5.18 → >=15.5.18` pnpm override in `web/package.json`), React `^19`, TypeScript `^5.9`, Tailwind CSS v4 (`@tailwindcss/postcss`, CSS-first config — the latest toolchain shadcn supports), shadcn/ui (registry workflow, `new-york` style, CSS variables), `motion` `^12` (same major as OSS), ESLint 9 flat config + Prettier (repo `web/.prettierrc` applies by upward resolution — no new prettier config), pnpm `11.1.2` + turbo `2.8.20`, Docker Compose + Traefik v2. -**Conventions for all commit steps:** run `git branch --show-current` first — if it prints `gitbutler/workspace`, this repo is in GitButler workspace mode and you must use `but branch new ` / `but commit -m "..."` per root `AGENTS.md`; the commands below assume plain git on a feature branch (e.g. `mobile/wp1-foundation`). Never include Claude/Anthropic/Co-Authored-By lines in commit messages. All commands run from the repo root unless a `cd` is shown. +**Conventions for all commit steps:** run `git branch --show-current` first — if it prints `gitbutler/workspace`, this repo is in GitButler workspace mode and you must use `but branch new ` / `but commit -m "..."` per root `AGENTS.md`; the commands below assume plain git on a feature branch (e.g. `mobile/wp1-foundation`). Never include Claude/Anthropic/Co-Authored-By lines in commit messages. All commands run from the repo root unless a `cd` is shown. Before every commit step that touches frontend files, run `cd web && pnpm lint-fix` (the repo-wide convention in `web/AGENTS.md`) — a package-local ESLint run is not a substitute, because the format check in CI runs over the whole `web` tree. --- @@ -355,8 +355,9 @@ const {sharedAxisPush, sheetSlideUp, crossfade, reduced} = useMotionPresets() "public/**", "next.config.ts", "postcss.config.mjs", - "tsconfig.json", - "components.json" + "$TURBO_DEFAULT$", + "!tests/**", + "!**/*.md" ], "outputs": [".next/**", "!.next/cache/**"], "env": ["NODE_ENV", "NEXT_PUBLIC_*"] @@ -786,11 +787,15 @@ const {sharedAxisPush, sheetSlideUp, crossfade, reduced} = useMotionPresets() "muted-foreground": [color(p.text.secondary.light), color(p.text.secondary.dark)], accent: [ color(p.surface.controlItemBgActive.light), - color(p.surface.controlItemBgActive.dark), + // Review delta: the palette's dark controlItemBgActive reads olive against the + // mobile surface; zinc[2] is the neutral the dark theme actually wants. + color(p.scales.zinc[2].dark), ], "accent-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], destructive: [color(p.semantic.error.light), color(p.semantic.error.dark)], - "destructive-foreground": [color(p.surface.white.light), color(p.surface.white.dark)], + // Review delta: pure white on the dark destructive fill was too hot; the antd dark + // button primary colour is the value the rest of the dark theme uses. + "destructive-foreground": [color(p.surface.white.light), p.componentsDark.Button.primaryColor], border: [color(p.border.secondary.light), color(p.border.secondary.dark)], input: [color(p.border.default.light), color(p.border.default.dark)], ring: [color(p.accent.primary.light), color(p.accent.primary.dark)], @@ -932,7 +937,7 @@ const {sharedAxisPush, sheetSlideUp, crossfade, reduced} = useMotionPresets() cd web/mobile && pnpm dlx shadcn@latest add button skeleton ``` Expected: CLI reports it found `components.json`, and creates `src/components/ui/button.tsx` and `src/components/ui/skeleton.tsx`. -- [ ] Verify: `ls web/mobile/src/components/ui/` → `button.tsx skeleton.tsx`; `git diff web/mobile/package.json` shows the added radix dep; `web/pnpm-lock.yaml` updated. +- [ ] Verify (still inside `web/mobile` from the previous step): `ls src/components/ui/` → `button.tsx skeleton.tsx`; `git diff package.json` shows the added radix dep; `git diff ../pnpm-lock.yaml` shows the lockfile updated. ### Task 3.9 — Commit the design-system foundation diff --git a/docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md b/docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md index 05fb1de011..72cab5ec0d 100644 --- a/docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md +++ b/docs/design/agenta-mobile/plans/2026-07-25-wp1-infra-tail.md @@ -192,9 +192,15 @@ jobs: - name: Determine build metadata id: meta + env: + # Through `env` these are only ever data. Interpolated directly into `run:` they + # are substituted before bash parses the line, so a value could close the quote + # and run commands. + PR: ${{ github.event.pull_request.number }} + INPUT_TAG: ${{ inputs.image_tag }} run: | - PR="${{ github.event.pull_request.number }}" - INPUT_TAG="${{ inputs.image_tag }}" + PR="$PR" + INPUT_TAG="$INPUT_TAG" SHA="$(git rev-parse --short HEAD)" if [ -n "$PR" ]; then @@ -278,12 +284,21 @@ jobs: if curl -sf http://127.0.0.1:3000/m >/dev/null; then break; fi sleep 1 done - curl -sf http://127.0.0.1:3000/m >/dev/null || { docker logs mobile-smoke; exit 1; } - curl -sf http://127.0.0.1:3000/m/__env.js >/dev/null || { docker logs mobile-smoke; exit 1; } - # basePath /m owns the prefix: the bare root must 404. - if curl -sf http://127.0.0.1:3000/ >/dev/null; then - echo "::error::expected / to 404 (basePath /m)"; exit 1 - fi + # Assert the status itself rather than curl's exit code: `-sf` succeeds on a + # 3xx (the page never rendered) and fails identically on 404 and 500. + expect_status() { + local path="$1" want="$2" got + got="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:3000${path}")" + if [ "$got" != "$want" ]; then + echo "::error::expected ${path} to return ${want}, got ${got}" + docker logs mobile-smoke + exit 1 + fi + } + expect_status /m 200 + expect_status /m/__env.js 200 + # basePath /m owns the prefix, so the bare root belongs to nothing. + expect_status / 404 docker rm -f mobile-smoke - name: Verify image runs as non-root @@ -369,10 +384,12 @@ Insert each block **immediately after the `web` service** in its file. All block networks: - agenta-oss-gh-network # === LABELS =============================================== # - # PathPrefix(`/m`) auto-wins over the web catch-all PathPrefix(`/`) - # by rule length; no stripprefix — the app is built with basePath /m. + # 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/`))" - "traefik.http.routers.web-mobile.entrypoints=web" - "traefik.http.services.web-mobile.loadbalancer.server.port=3000" # === LIFECYCLE ============================================ # @@ -510,7 +527,9 @@ SHUTDOWN_CMD="$COMPOSE_CMD --profile with-web-mobile --profile with-web --profil ## Task 5 — Verify (don't change) the already-wired CI coverage -Pin the G3/G5 claims so nobody re-adds duplicate jobs later: +Pin the G3/G5 claims so nobody re-adds duplicate jobs later. Run `set -o pipefail` first — +the two piped checks below would otherwise report the exit status of `python3`/`grep` and +swallow a failing producer: - [ ] `cd web && pnpm turbo run lint --filter=@agenta/mobile --dry=json | python3 -c "import json,sys; d=json.load(sys.stdin); print([t['taskId'] for t in d['tasks']])"` → includes `@agenta/mobile#lint` (proves `pnpm run lint` in `11-check-code-styling.yml:96` reaches mobile, tokens:check chained). - [ ] `cd web && pnpm -r --filter=!agenta-web-tests --if-present run test:unit --reporter=dot 2>&1 | grep -i chat` → `@agenta/chat` suite executes (the exact Phase-1 command from `run-tests.ts:200`); confirm `web/packages/agenta-chat/test-results/junit.xml` exists afterwards (matches the publish glob `12:133-135`). @@ -534,7 +553,13 @@ docker image ls agenta-web-mobile:smoke --format 'SIZE {{.Size}}' ```bash docker run -d --name mobile-smoke -p 3000:3000 agenta-web-mobile:smoke -sleep 3 +trap 'docker rm -f mobile-smoke >/dev/null 2>&1' EXIT # container goes away on failure too +# Bounded readiness poll, same as the workflow: a fixed sleep races a slow host, where +# the entrypoint plus the Next server can need well over three seconds. +for _ in $(seq 1 30); do + curl -sf http://127.0.0.1:3000/m >/dev/null && break + sleep 1 +done curl -sf -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/m # expect: 200 curl -sf -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/m/__env.js # expect: 200 curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/ # expect: 404 diff --git a/docs/design/agenta-mobile/plans/2026-07-26-wp5-device-gate.md b/docs/design/agenta-mobile/plans/2026-07-26-wp5-device-gate.md index eae5078157..97e5b83df0 100644 --- a/docs/design/agenta-mobile/plans/2026-07-26-wp5-device-gate.md +++ b/docs/design/agenta-mobile/plans/2026-07-26-wp5-device-gate.md @@ -458,7 +458,9 @@ export function decideMobileGate(input: GateInput): GateDecision { } ``` -- [ ] Run: `cd web && pnpm --filter @agenta/shared test:unit -- mobileGate` — expect **25 passed**. +- [ ] Run: `cd web && pnpm --filter @agenta/shared test:unit -- mobileGate` — expect **38 passed** + (25 at the time this step was written; the file grew with the OAuth-callback handback and the + token-bearing `/auth` pass-through). Assert the suite is green, not a fixed number. - [ ] Run: `cd web && pnpm --filter @agenta/shared check` — expect types + lint clean. - [ ] Commit: diff --git a/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md b/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md index 9031272dc7..daddffff72 100644 --- a/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md +++ b/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md @@ -91,8 +91,17 @@ through the existing (IDB-persisted, deduped) query. ApiKey, AND the `sAccessToken` cookie (auth.py:290), and sets `request.state.{user_id,project_id}` once at request start — the SSE handler then does the same `check_action_access(VIEW_SESSIONS)` as `query_records` (router.py:475-480). Auth is - evaluated once at connect; scope holds for the connection's lifetime (standard SSE; cap the - connection age server-side if that ever matters). + 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. - Proxy path: Traefik routes `/api` → api:8000 with a strip-prefix middleware only (`hosting/docker-compose/oss/docker-compose.dev.yml:188-194`); no custom responding timeouts configured. Heartbeats every ~15s keep any idle timeout (Traefik or client) happy. @@ -215,7 +224,14 @@ domain layering per api/AGENTS.md. Log-and-continue on publish failure — persistence is already committed and must not be re-driven by relay errors. Unit test with fakeredis: batch with 2 sessions ⇒ 2 publishes, each after append; append failure ⇒ no publish for that batch. -- **T2 — SSE watch endpoint.** `GET /sessions/streams/watch?session_id=` on the +- **T2 — SSE watch endpoint.** Three event types, not one: `records-changed` + (`{session_id}`), `lifecycle` (`{session_id, state: "running"|"ended"}`) and + `interaction` (`{session_id, status: "pending"|"resolved"}`). Each names the query the + client should revalidate — records, liveness, and actionable interactions respectively — + so a client can retire the matching poll. A payload shape that cannot distinguish them + would let the mobile task revalidate records only, and the liveness and approval badges + would go stale while the stream looked healthy. + `GET /sessions/streams/watch?session_id=` on the `StreamsRouter` (`api/oss/src/apis/fastapi/sessions/router.py`): `check_action_access` (`VIEW_SESSIONS`, mirroring router.py:475-480), validate `session_id` (contract.py:121-131), then `StreamingResponse(media_type="text/event-stream")` that @@ -240,6 +256,15 @@ domain layering per api/AGENTS.md. open ⇒ slow safety-net poll (30s); SSE errored/unsupported ⇒ today's 4s/7.5s cadence unchanged (the fallback IS the current behavior — no regression path). +### Tenant selection + +The URL carries `session_id` ONLY. `project_id` comes from `request.state.project_id`, +set by the auth middleware, and the channel is built from that. A `project_id` in the +query string would be a tenant selector supplied by the caller: passing another project's +id would subscribe the caller to that project's channel, and the `VIEW_SESSIONS` check +would have been run against a different project than the one being watched. The client +task must not add it to the URL. + ### Deferred (explicitly not in M3) - **Desktop consumption** — wire the same SSE into `revalidateSessionRecordsAtom` + @@ -265,7 +290,11 @@ domain layering per api/AGENTS.md. 1. **Paragraph-level (~1-2s) change-notification SSE: YES** for the current iteration. Token-by-token streaming (runner Redis client + per-delta publishing) stays rejected — revisit only if a future product need demands cursor-level liveness on mobile. -2. **Lifecycle events on the same channel: YES** — the watch stream also carries turn - lifecycle (running/ended/approval-pending), so mobile retires all three polls - (records tick, liveness, actionable-interactions) in favor of one EventSource; the - polls remain as the documented no-regression fallback when the stream is down. +2. **Lifecycle events on the same channel: YES, but only for the open session** — the + watch stream also carries turn lifecycle (running/ended/approval-pending), so an open + chat can drop its own records tick, liveness poll and actionable-interactions poll in + favor of one EventSource. The channel is `(project_id, session_id)`, so it says nothing + about the OTHER sessions in the project: `SessionListScreen`'s liveness and + pending-approval badges cover every row and MUST keep their project-wide polls. A + project-scoped lifecycle stream would be needed to retire those, and it is not in M3. + The polls also remain the documented no-regression fallback when the stream is down. diff --git a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md index ead77482d7..b88e820226 100644 --- a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md +++ b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md @@ -1,6 +1,12 @@ # Mobile approvals + steering — design & plan -**Status:** PLANNED · **Date:** 2026-07-27 · **Branch:** `feat/agenta-mobile-wave-1` +**Status:** HISTORICAL PRE-EXECUTION SNAPSHOT (M0-M3 executed; see [../README.md](../README.md)) +· **Date:** 2026-07-27 · **Branch:** `feat/agenta-mobile-wave-1` + +> Read this as the plan as written, not as a description of what shipped. The one contract +> difference worth knowing before reading further: the client answers a gate with +> `{approved}` through the detached respond dispatcher, not the payload sketched below, and +> the "no producer" / unverified notes in §3 were resolved during execution. **Goal:** from a phone, on a session whose agent runs in the cloud: (1) see that a turn is running and an approval is pending with enough context to decide, (2) approve/deny and have the agent proceed, (3) stop, and steer where feasible — all WITHOUT being the SSE stream holder. @@ -264,6 +270,7 @@ run). next send) — mirror the worktree's envelope exactly so the two implementations converge. **Dependency flag:** UX blocked on the same harness limitation; do not enable by default until #5444 (runner reject-with-feedback) exists. + **BUILT 2026-07-30, flag OFF — see §7 for the live delivery measurement.** ### Phase M2 — BE: wire the out-of-band respond path (small, separable) @@ -326,6 +333,8 @@ path for the "approve from the notification" dream, else it just opens the chat 3. **Steer-lite: WAIT** — do not ship deny-with-redirect now; wait for the runner's reject-with-feedback (#5444). ⚠️ **FOLLOW-UP (do not forget): Arda may ask for this implementation next**; the M1.5 task stays specced and unbuilt. + → **Revisited 2026-07-30 (Arda: "go ahead").** Built, flag OFF; the wait still stands and + is now measured rather than inferred — §7. 4. **Warm-park TTL: BUMP** when a pending interaction exists (phone-latency answers should warm-resume, not cold-replay). 5. **M2: build it in this workstream** ("finish this yourself") — do not hand to JP. @@ -412,3 +421,92 @@ session — an un-gated-concurrent-`/invoke` hazard, not a lock-contract one. **Also worth knowing:** `updated_at` is bumped by non-heartbeat writers (attach/detach, rename), so watcher churn can hold an orphan's sweep clock open — now a 30-minute window for alive-but-idle rows rather than 5. + +--- + +## 7. Steer-lite (M1.5): built, flag OFF — the redirect is cold-replay-only (2026-07-30) + +### 7.1 What a denial can carry today + +Nothing but the verdict. The harness reply type is a closed union — +`PermissionReply = "once" | "always" | "reject"` (`services/runner/node_modules/sandbox-agent`, +`Session.respondPermission(permissionId, reply)`) — and both runner call sites pass exactly that: +`replyPermission` (`acp-interactions.ts:242-280`, warm/deny path) and the cold decision map +(`responder.ts` `storedApprovalDecisionOf` → `{approved: boolean, interactionToken?}`). The durable +plane matches: the answer that resolves an interaction row is `{approved, toolCallId}`. There is no +feedback/message field anywhere between the client and the model. The denial text the model reads +(`"Denied by the permission policy."`) is a **static** string the in-sandbox Pi extension returns +(`extensions/agenta.ts:119,206,334`); on Claude ACP it is the harness's own "user refused +permission". So "deny with a reason" does not exist at any layer — that is #5444. + +### 7.2 Where the redirect note IS delivered + +`{approved:false, message}` on `POST /sessions/interactions/{id}/respond` already rides: the +dispatcher appends the note as a trailing user message (`compose_approval_messages`). Delivery is +asymmetric, and it lands on the wrong side of the split for mobile: + +- **Cold replay — DELIVERED.** No parked sandbox (or a fingerprint mismatch) ⇒ `buildTurnText` + replays the transcript, renders the denial as `[user DENIED ; the call was not executed.]`, + and closes with `Continue the conversation. The user now says:\n` — the note IS the prompt. +- **Warm resume — DROPPED.** A parked sandbox that fingerprint-matches resumes by calling + `respondPermission` on the still-pending ORIGINAL prompt and takes the + `if (opts.resume)` branch in `run-turn.ts:744-800`, which **never calls `session.prompt`**. The + trailing note is not sent to the model at all. Worse, it is still persisted as a `user` record + (the runner records `resolvePromptText(request)` as the turn's user message), so the transcript + shows the instruction as if it had been delivered. + +Warm is the DEFAULT for a phone answer since this workstream bumped `approvalTtlMs` to 30 minutes +(§4b-4) and since the dispatcher was deliberately given fingerprint parity so mobile answers +warm-resume. In other words the two earlier decisions that made mobile approvals fast are exactly +what make a mobile steer undeliverable. + +**Live measurement** (ephemeral projects, `scratchpad/steer_arm.py {warm,cold}`; identical answer +payload, redirect = "Do not create any file … reply with exactly the single word PIVOTED"): + +| arm | runner dispatch | outcome | +| --- | --- | --- | +| `warm` (respond dispatcher, the mobile path) | `[keepalive] resume … answered=1 approve=0 reject=1 tool=Bash` | note NOT delivered; model reasoned *"It appears I don't have permission … Let me try using the write tool instead"* and raised a NEW gate on `Write` | +| `cold` (same envelope, no fingerprint parity) | `approval-mismatch (history); evict + cold` → `[HITL] cold replay: … resumeFrame=none, turnText 480 chars` | note delivered; agent replied exactly `PIVOTED`, wrote no file, raised no new gate | + +The warm arm is the negative result this section exists for: a bare deny makes the model flail into +a sibling gate, which is precisely the UX #5444 is meant to fix. + +### 7.3 What shipped + +- `web/mobile/src/features/chat/steer.ts` — `isSteerEnabled()` (`NEXT_PUBLIC_AGENT_CHAT_STEER === + "true"`, desktop-parity spelling of `isAgentChatSteerEnabled`) and `buildApprovalAnswer`, which + trims the note and omits it entirely when blank (a blank redirect is a plain deny, never an empty + trailing user message). +- `ApprovalDock.tsx` — a flag-gated "Redirect" control; opening it swaps the decision row for a + raw textarea + Cancel / "Deny & send", mirroring the desktop dock's panel swap. The note is reset + whenever the acted-on gate changes. No new shadcn component, no motion (raw-UI ethos). +- `useApprovalActions.ts` — `respond({approvalId, approved, message?})` through the same + `respondInteraction` path: fire-and-forget, single-flight `busyRef`, `phase` transitions, 60s + re-arm, settle on `pendingCount === 0`, 409 treated as benign. +- No dispatcher change was needed (`message` already rides and is pinned by + `test_denial_with_message_appends_a_trailing_user_note`); its docstring now records the delivery + asymmetry. + +**Gated:** the control is hidden unless the flag is `true`, so no user can hit the silent drop. The +implementation ships intact behind it — flipping the flag is the entire enablement once the runner +carries a redirect in-band with the denial. + +### 7.4 Why not "just make it work" + +Three alternatives were traced and rejected: + +- **Deliver it on the warm path (runner).** Requires either a second `session.prompt` inside the + resume turn (new turn semantics: usage, run limits, otel spans, loop risk) or a real + reject-with-feedback channel — which is not a runner-local change: `PermissionReply` is a + closed ACP union and the denial text is produced in-sandbox by the Pi extension. Also unverifiable + here: the runner does not hot-reload TS. +- **Force the cold path from the API when a note is present.** It works (the `cold` arm proves the + end-to-end behavior), but the only lever available is deliberately breaking the history + fingerprint. That logs an intentional behavior as `approval-mismatch (history)`, throws away a + warm sandbox, and silently reverts to dropping the note the moment the runner relaxes that + comparison (the idle branch already skips it for minimal-history clients). Needs a real wire flag + to be honest, i.e. a runner change. +- **Deny now, deliver the note as a follow-up turn** (desktop's shape). Broken on mobile: after a + denial the model typically re-attempts with a sibling tool and the session parks on a NEW gate + (observed in the warm arm), so "wait for idle, then send" can block indefinitely — and mobile has + no send path to queue onto in the first place. diff --git a/docs/design/agenta-mobile/plans/2026-08-01-pr-stack-split.md b/docs/design/agenta-mobile/plans/2026-08-01-pr-stack-split.md new file mode 100644 index 0000000000..8b38a29ba2 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-08-01-pr-stack-split.md @@ -0,0 +1,99 @@ +# Splitting `feat/agenta-mobile-wave-1` into a reviewable PR stack + +**Status:** built locally 2026-08-03, unpushed +**Branch:** `feat/agenta-mobile-wave-1` — 127 commits on `main` (`ac6b1548d9`, = `v0.107.0`) + +One PR of this size is not reviewable. This plan cuts it into **12 stacked PRs**, each a +single reviewable concern, ordered so every PR's base is the branch directly below it. + +## Why path-partitioning was rejected + +The first version of this plan proposed partitioning by **path** and replaying +chronologically, on the belief that only 3 files were touched by two lanes. That estimate was +made against a coarse lane sketch and did not survive the real assignment. A pre-flight check +over the actual commit×file matrix found **42 files touched by more than one lane, 22 of them +with an edit order no lane ordering can respect**: + +- `web/turbo.json`, `web/pnpm-lock.yaml` and the dev compose files ping-pong between the + mobile-scaffold and device-gate lanes (commits 2 → 42 → 48 → 50 → 53). +- `api/oss/src/apis/fastapi/sessions/router.py` is touched by the list lane, then the respond + lane, then the watch lane — an order those lanes cannot stack in. +- `web/packages/agenta-shared/src/utils/mobileGate/index.ts` is gate → auth → gate. + +In each case a **lower** lane would replay a **newer** version of the file underneath a higher +lane, and the higher lane's older content would win — silently dropping edits. The failure is +invisible in a per-lane diff and only shows up as a wrong final tree. + +## The mechanic: contiguous ranges of the existing history + +Lanes are contiguous spans of the branch's linear history. Every file's edits then appear in +lane order automatically, so the class of bug above cannot occur. + +It also needs **no replay**: each lane is a branch ref at a boundary commit of the history that +was already reviewed and tested, so the stack is bit-identical to the branch rather than a +reconstruction of it. `git branch -f ` per boundary; nothing is rewritten. + +The cost is that lanes follow *when* work happened rather than *which tier* it touched, so a +few mix tiers (the approvals lane carries the runner's warm-park change alongside mobile UI). +The history is already grouped by theme, so in practice each span is coherent. + +Verification is the chain, not a golden diff (which contiguity satisfies trivially): each +lane's `git merge-base --is-ancestor ` holds, the per-lane commit counts sum to +the branch total, and the top lane is the same commit as the branch tip. + +## The stack (bottom → top) + +Built 2026-08-03. Each lane's base is the branch directly below it; the bottom lane's base is +`main`. Commit counts sum to the branch's 127. + +| # | Branch | Commits | Files | Contents | +|---|---|---|---|---| +| 1 | `feat/mobile-app-scaffold` | 8 | 37 | The `/m` skeleton: Pages Router at `basePath: "/m"`, shadcn + palette token bridge, motion presets, eslint bans (no antd, no app-layer imports), dev compose service behind Traefik. | +| 2 | `feat/api-sessions-list-ordering` | 9 | 18 | `/sessions/query` ordered by last activity (`coalesce(updated_at, created_at)`, direction-matched id tiebreak), free-text title search, latest-turn references — plus the `@agenta/entities` wire schema and fixture. | +| 3 | `feat/agenta-chat-package` | 17 | 77 | The headless `@agenta/chat` extraction. Biggest lane; see "Making the chat-package lane reviewable". | +| 4 | `feat/mobile-image-and-ci` | 5 | 12 | Production gh Dockerfile, typecheck/build workflow, gh compose wiring. | +| 5 | `feat/mobile-device-gate` | 13 | 28 | Gate decision core in `@agenta/shared`, flag-gated OSS/EE middleware (**default off**), mobile reverse gate, compose flag, UA-emulation smoke. | +| 6 | `feat/mobile-sessions-and-transcript` | 8 | 34 | `@agenta/*` wired into the app, providers + route-scoped project state, sessions list with search and paging, read-only transcript replay. | +| 7 | `feat/mobile-auth` | 4 | 10 | Session refresh before the signed-out verdict, raw email sign-in, refresh interceptor at provider scope. | +| 8 | `feat/mobile-approvals` | 19 | 40 | Approve/deny/stop from the phone, the runner's 30-minute warm park, and the chat UX mechanics (pinned headers, scroll containment, safe-area). | +| 9 | `feat/sessions-watch-and-liveness` | 16 | 37 | `GET /sessions/streams/watch` SSE relay and its mobile consumer; liveness mirror on every heartbeat, orphan sweep thresholds, handover-vs-takeover, supersession tombstones. | +| 10 | `feat/effective-turn-config` | 9 | 39 | The three-tier contract: SDK stamps `effectiveParameters` on the `/run` wire, runner echoes it onto the interaction row, API replays it on respond — plus the detached respond dispatcher. | +| 11 | `fix/desktop-session-convergence` | 7 | 34 | Desktop settles a resumed gate on replay, converges an open session through the watch relay, refreshes before reopening a dead relay; displaced-turn lock closure. | +| 12 | `feat/mobile-parity-and-consolidation` | 12 | 56 | Steer-lite behind its flag, sign-in parity (OTP/social/SSO) and the OAuth handback, consolidation Wave 0, the approval-envelope `toolName` fix, `@agenta/chat` re-sync. | + +Lanes 2, 9, 10 and 11 carry backend work that does not depend on the mobile app. They sit where +the history put them, but their PRs can be read without reference to the `/m` UI. + +## Making the chat-package lane reviewable + +Lane 3 is the biggest by far, but almost none of it is new logic — `@agenta/chat` was +**copy-extracted** from `web/oss/src/components/AgentChatSlice`. The PR body should carry a +per-file drift table (package copy vs its OSS original) so the reviewer reads only the drifted +lines. Generate it mechanically with `git diff --no-index` per file pair. + +`transcriptToMessages.ts` was re-synced to full parity on 2026-08-03 (approval-resume handling +and attachment parts); its copy header records what that covered. + +Note for the PR body: `grep "@agenta/chat" web/oss/src` returns **0 hits** — OSS has not adopted +the package it was extracted from. That re-plumb is deliberately out of scope (it collides with +the open frontend queue and the antd→shadcn branch) and is tracked as Wave 2 of +`docs/design/agenta-sessions-consolidation/plan.md`. + +## Risks + +- **Every lane boundary falls between original commits**, so each lane's tip is a state that was + built and tested as part of the branch. No lane is a synthesized tree. +- **GitButler stacks are linear** (root `AGENTS.md`), which is exactly what this is. Set each + PR's `--base` to the branch below it; GitHub then shows only that lane's own delta. +- **Do not rebase a single lane in isolation.** They share history; rebase the top branch and + re-point the boundaries with `git branch -f`, or the chain breaks. + +## Sequencing suggestion + +Open the stack bottom-up and let it merge in order. Contiguous lanes cannot be reordered — lane +N's commits are literally beneath lane N+1 — so the merge order is fixed by construction rather +than chosen. + +That is the trade against the path-partitioned version, which could have let the independent +backend lanes merge first: here a slow review on lane 1 blocks everything above it. If a lane +stalls, the escape is to merge it as-is and open a follow-up, not to re-cut the stack. diff --git a/docs/design/agenta-sessions-consolidation/plan.md b/docs/design/agenta-sessions-consolidation/plan.md new file mode 100644 index 0000000000..4616a8ffbe --- /dev/null +++ b/docs/design/agenta-sessions-consolidation/plan.md @@ -0,0 +1,530 @@ +# Consolidating session logic into a single source of truth + +**Status:** PLAN — nothing implemented · **Date:** 2026-08-01 · +**Branch:** `feat/agenta-mobile-wave-1` (119 commits ahead of `release/v0.106.2`) + +**Question asked:** desktop and mobile now both implement session-list ordering, liveness +polling, transcript revalidation and approval answering. Where does the shared logic live so +there is exactly one implementation? + +**Answer in one line:** extend `@agenta/entities/session` (fetch/schema/cache/derivation) and +`@agenta/chat` (conversation orchestration) — do **not** create `@agenta/sessions`. The reason +is in §3. + +Everything below is grounded in files at the branch tip. Where I could not verify a claim from +code I say so and state what would settle it. + +--- + +## 1. Inventory + +### 1.1 `@agenta/entities/session` — the shared layer that already exists + +2,021 lines across 12 files (`web/packages/agenta-entities/src/session/`), UI-free (verified: +zero hits for `antd|lexical|@/oss` in the tree). Public surface is +`web/packages/agenta-entities/src/session/index.ts:1-108`. + +| Sub-layer | Files | What is there | +| --- | --- | --- | +| `api/` | `api.ts` (730), `client.ts` (106) | Every Fern-backed accessor: `querySessions`, `querySessionStreams`, `querySessionRecords`, `queryInteractions`, `respondInteraction`, `setSessionHeader`, `commandSessionStream`, `killSession`, `delete/archive/unarchiveSession`, mounts. | +| `core/` | `schema.ts` (207), `liveness.ts` (90), `transcriptAdoption.ts` (47), `fileActivity.ts` (126), `mountBrowser.ts` (77), `pathUtils.ts` (22) | Pure derivations. `deriveStreamNest`/`deriveSessionLifecycle`/`refineLifecycleWithSandbox`; `shouldAdoptServerTranscript`. | +| `state/` | `records.ts` (121), `mounts.ts` (247), `fileActivity.ts` (140) | Jotai + `atomWithQuery` caches. `sessionRecordsQueryKey` = `["session","records",projectId,sessionId]` (`state/records.ts:18-19`), 15 s stale, IndexedDB-persisted (`records.ts:23-36`), plus `revalidateSessionRecordsAtom` / `fetchSessionRecordsAtom`. | + +Coverage: 67 unit-test files in `web/packages/agenta-entities/tests/unit/`, nine of them +session-specific (`session-transcript-adoption`, `session-liveness`, `session-query-schema`, …). + +**This is the current of convergence.** Upstream put `transcriptAdoption.ts` in `core/` this +release and desktop consumes it (`web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts:3,86-92`). +Nothing in this plan should fight that. + +### 1.2 `@agenta/chat` — the headless conversation core, extracted but **not adopted** + +38 files under `web/packages/agenta-chat/src/`, six subpath exports +(`web/packages/agenta-chat/package.json:19-27`), no antd/lexical/virtuoso (verified by grep). + +The load-bearing fact: + +``` +grep -rn "@agenta/chat" web/oss/src → 0 hits +grep -rn "@agenta/chat" web/ee/src → 0 hits +grep -rn "@agenta/chat" web/mobile/src → 6 hits +``` + +`web/oss/package.json:28` declares the dependency and never imports it. The package was +**copy-extracted**, deliberately, and the OSS re-imports were deferred — this is written down +in `docs/design/agenta-mobile/plans/2026-07-12-wp3a-chat-headless-core.md:7-19`: + +> "OSS re-import" steps were NOT executed (deferred to the desktop re-plumb) … The original +> same-commit OSS re-imports … are DEFERRED to the desktop re-plumb PR, sequenced after the FE +> PR queue drains and the shadcn branch lands — that PR deletes the OSS copies wholesale. + +So the consolidation this document is asked to plan is **already scoped in-repo**, with a +sequencing precondition already stated. The new work is the *session* half (list, liveness, +watch, approvals) that WP3a did not cover. + +Measured drift between the OSS original and the package copy (changed-line counts): + +| OSS file | package counterpart | drift | +| --- | --- | --- | +| `assets/trace.ts`, `assets/rewind.ts` | `agenta-chat/src/assets/*` | 3 lines each | +| `hooks/useAgentChatQueue.ts` | `agenta-chat/src/hooks/useAgentChatQueue.ts` | 4 | +| `hooks/useAgentModelKeyStatus.ts` | same path | 4 | +| `state/expandState.ts` | `agenta-chat/src/state/expandState.ts` | 6 | +| `assets/toolFormat.ts` | `agenta-chat/src/assets/toolFormat.ts` | 6 | +| `assets/AgentChatTransport.ts` | `agenta-chat/src/transport/AgentChatTransport.ts` | 6 | +| `state/sessionEphemera.ts` | `agenta-chat/src/state/sessionEphemera.ts` | 27 | +| `assets/loadSession.ts` | `agenta-chat/src/assets/loadSession.ts` | 27 | +| `assets/transcriptToMessages.ts` | `agenta-chat/src/assets/transcriptToMessages.ts` | 57 | +| `hooks/useComposerAttachments.ts` | `agenta-chat/src/hooks/useComposerAttachments.ts` | 215 (genuinely diverged) | + +**The one that is actually dangerous — `agenta-chat/src/state/sessionMessages.ts`.** Its header +(`:1-7`) says "keep byte-parity on the copied blocks if either side changes", and it has +already broken parity in two behaviourally significant ways while writing **the same +localStorage key**, `agenta:agent-chat:messages` (`sessionMessages.ts:22` vs +`web/oss/src/components/AgentChatSlice/state/sessions.ts:140`): + +1. The package passes `undefined` for the storage impl (`sessionMessages.ts:24`) → default jotai + storage **with** the cross-tab `storage` subscribe. OSS deliberately strips `subscribe` + (`sessions.ts:102-106`) because an incoming cross-tab replacement unmounted a streaming + conversation and orphaned its `useChat` stream mid-turn. +2. The package's `writeMessagesWithQuotaGuard` returns `void` (`sessionMessages.ts:44-46`), so it + has no `{evicted, persisted}` result and no counterpart to + `agenta:agent-chat:record-counts` (`sessions.ts:154-159`). OSS's watermark guard + (`sessions.ts:737-747`) is only correct while the two stores move together. + +Today this is latent (mobile is read-only and never writes messages). It becomes a live bug the +moment either app writes messages through the package while the other reads OSS's watermark. + +### 1.3 `web/oss/src/components/AgentChatSlice` — the desktop app layer + +`state/` (11 files) and `hooks/` (18 files). Session-relevant: + +- **`state/sessions.ts` (881)** — the scope-keyed model: 5 `atomWithStorage` stores + (`agenta:agent-chat:{sessions,open-sessions,active-session,messages,record-counts}`, + `:109-159`), history ordering (`:185-213`), server reconciliation (`:429-505`), rename / + auto-title (`:581-623`, `setSessionHeader` at `:593,621`), archive/unarchive/delete + (`:333-405`), husk pruning (`:178-181,278-296`), quota guard (`:667-692`), watermark writes + (`:725-749`), `nowTick` (`:790-794`), `sessionLabel` (`:808-817`). +- **`state/projectSessions.ts` (103)** — one low-priority `querySessions` per agent scope, key + `["session-list", projectId, appId]` (`:31`), stale 30 s / refetch 60 s (`:39-41`), dedup + + client sort (`:55-59`), `activity()` (`:66-70`), `toSummary()` (`:72-85`), + `useReconcileServerSessions` (`:93-103`). **No windowing is passed** — desktop fetches the + entire unwindowed list with `include_ended`/`include_archived` both defaulting `true` + (`web/packages/agenta-entities/src/session/api/api.ts:285-286,299-302`), every 60 s. +- **`state/liveness.ts` (98)** — one project-scoped `querySessionStreams({isAlive:true})`, key + `["session-liveness","alive",projectId]` (`:33`), stale 10 s (`:40`), + `refetchInterval: data.length > 0 ? 15_000 : false` (`:42`), focus refetch (`:43`). Consumes + `deriveStreamNest`/`deriveSessionLifecycle` from the package — **no local re-implementation**. +- **`hooks/useSessionHydration.ts` (280)** — the hybrid-SWR transcript: one + `adoptServerTranscript` (`:82-129`) shared by hydration / SWR / poll / relay, calling the + package guard (`:86-92`); watermark set **before** `setMessages` (`:103-109`); remote-run poll + `15_000 → 60_000` chained `setTimeout` with growth-reset backoff (`:20,27,218-249`); relay + wiring at `:257-277`, `enabled` scoped to the active session because antd Tabs keeps inactive + panes mounted (`:255-256`). +- **`hooks/useSessionRecordsWatch.ts` (127)** — SSE relay. **Zero `@agenta/*` imports.** Header + at `:37-38` states it mirrors `web/mobile/src/features/chat/useSessionWatch.ts`. +- **`hooks/useAgentChatSession.ts` (431)** — transport + `useChat` + every conversation side + effect, including the approval resume path (`:112-123,140,152-159,417`) and stop/kill + (`:365-388`). +- `state/{sessionEphemera,expandState,scope,turnCaptures,fileLinks,panelLayout,rightPanel,virtualization,firstRunSeed}.ts` — + the first two are the ones duplicated into `@agenta/chat`; the rest are desktop-only. + +### 1.4 `web/mobile/src/features/{sessions,chat}` — the mobile app layer + +24 files. Deps allow `@agenta/{chat,entities,shared,sdk}` and nothing else +(`web/mobile/package.json:23-26`) — no antd, no Lexical, no `@/oss`. + +- `sessions/useSessionsInfinite.ts` (33) — `useInfiniteQuery`, page 30, cursor + `{next: last.id, newest: last.updated_at ?? last.created_at}` (`:26-31`), + `includeArchived:false` (`:21-23`), stale 30 s, **no client sort**. +- `sessions/useLivenessPoll.ts` (37) — hand-rewritten twin of desktop `state/liveness.ts` in + react-query form; identical constants (stale 10 s `:19`, `15_000|false` `:20`, focus `:21`); + local `livenessBySession` (`:28-37`) reading `stream.flags?.is_running` directly instead of + the package's `deriveSessionLifecycle`. Header at `:9-11` says "mirrors the desktop pattern". +- `sessions/useActionableInteractions.ts` (48) — project-wide + `queryInteractions({actionableOnly:true})`, `pendingCountBySession` (`:39-48`); refetch + interval reads the **liveness cache entry cross-query** (`:27-33`). +- `chat/useSessionTranscript.ts` (88) — imports `loadSessionMessages` from `@agenta/chat/assets` + and `revalidateSessionRecordsAtom` from `@agenta/entities/session` (`:3-4`), then hand-rolls + the entire adoption guard: session-switch ref (`:24-25`), `refreshed` race guard (`:34,43-45`), + `inFlight`/`pending` trailing queue (`:26-30,56-59,71-75`), never-adopt-empty (`:65`), + foreground-only (`:55`). **Does not use `shouldAdoptServerTranscript`** (verified: zero hits + under `web/mobile`). +- `chat/useSessionWatch.ts` (133) + `chat/watchRelay.ts` (31) — the same SSE endpoint and the + same retry/throttle constants as desktop's `useSessionRecordsWatch`, plus badge-query + invalidation (`useSessionWatch.ts:64-69`) and the `watchAwarePollMs` fold (`watchRelay.ts:9-15`). +- `chat/useApprovalActions.ts` (140) + `approvalTargets.ts` (20) + `steer.ts` (33) — the + detached `/respond` answering path. +- `ChatHeader.tsx:16-21` — session title comes from its own + `useQuery(["mobile","session-stream",…])` calling `querySessionStreams({sessionId})[0]`. +- Read-only: no rename, no archive, no kill anywhere in `web/mobile` + (`killSession|archiveSession|setSessionHeader|deleteSession` → 0 hits). The only mutations are + `commandSessionStream` (`StopButton.tsx:3,16`) and `respondInteraction`. + +**Copy-headers:** exactly one file in the whole mobile app carries a copy-provenance header +(`web/mobile/src/middleware.ts:9-16`, a `@agenta/shared/utils/mobileGate` subset with an explicit +"delete the twins when WP2 lands"). Everything else is a *silent* re-implementation with +"mirrors the desktop" prose. That is the more dangerous kind: no marker for a future editor. + +--- + +## 2. Duplication table + +`agree?` = do the two implementations produce the same result today. + +| # | Concept | Desktop | Mobile | Shared today | Agree? | +| --- | --- | --- | --- | --- | --- | +| 1 | Session-list fetch | `projectSessions.ts:26-44` — unwindowed, whole list, 60 s poll, `references:[{id:appId}]` | `useSessionsInfinite.ts:10-33` — windowed 30, cursor-paged, no poll | `querySessions` (`api.ts:280-340`) | **No.** Different windowing, different archive filter (`includeArchived` default `true` vs explicit `false`), different `references` scoping. | +| 2 | Last-activity key | `activity()` `projectSessions.ts:66-70` = `Date.parse(updated_at ?? created_at)`; `sessionActivity()` `sessions.ts:185` = `lastMessageAt ?? createdAt ?? 0` | `useSessionsInfinite.ts:29` `last.updated_at ?? last.created_at`; `SessionRow.tsx:35` same | none | Yes semantically, **3 copies** of the same expression. | +| 3 | List ordering | client sort `projectSessions.ts:59` **over the merged local+server model** `sessions.ts:189-194` | none — trusts the server | server `coalesce(updated_at, created_at) DESC, id DESC` (`api/oss/src/dbs/postgres/sessions/streams/dao.py:158-178`) | Yes on the server rows. Desktop's sort is **not** redundant — see §5.1. | +| 4 | localStorage fold + reconciliation | `reconcileServerSessionsAtomFamily` `sessions.ts:429-505` (5 stores, husk model, `Math.max` activity merge `:447-448`) | **none** — pure server pages | none | N/A — mobile has no offline model. | +| 5 | Liveness poll | `state/liveness.ts:29-45`, jotai `atomWithQuery` | `useLivenessPoll.ts:12-23`, react-query | `deriveStreamNest`/`deriveSessionLifecycle` (`core/liveness.ts`) | Constants identical; **desktop derives a lifecycle, mobile reads `flags.is_running` raw** (`useLivenessPoll.ts:34`). Diverges for the parked/zombie cases the lifecycle refinement exists to handle. | +| 6 | Transcript replay | `loadSessionMessages` (`oss/.../assets/loadSession.ts`) | `loadSessionMessages` (`@agenta/chat/assets`) | package copy exists, OSS doesn't import it | 27 lines of drift. | +| 7 | Adoption guard | `shouldAdoptServerTranscript` via `useSessionHydration.ts:86-92` | hand-rolled in `useSessionTranscript.ts:24-30,43-45,65` | `core/transcriptAdoption.ts:28-47` | **No.** Mobile's guard has no record-watermark rule at all. | +| 8 | Records revalidation | `revalidateSessionRecordsAtom` (`useAgentChatSession.ts:165-169`, `useSessionHydration.ts:261-271`) | `revalidateSessionRecordsAtom` (`useSessionTranscript.ts:61-62`) | `state/records.ts` | **Yes** — the one clean case. | +| 9 | Watch relay (SSE) | `useSessionRecordsWatch.ts` (127) | `useSessionWatch.ts` (133) + `watchRelay.ts` (31) | none | Same endpoint, same 3 s throttle, same 1 s→30 s jittered backoff, same visibility rule, same auth-refresh-then-reopen. Mobile adds badge invalidation + poll folding. **Two files, one contract.** | +| 10 | Approval answering | re-invoke: `addToolApprovalResponse` → `sendAutomaticallyWhen`/`agentShouldResumeAfterApproval` → `AgentChatTransport` → `POST {serviceUrl}/invoke` (`useAgentChatSession.ts:112-123,152-159`; `AgentConversation.tsx:323-339`) | `respondInteraction` → `POST /sessions/interactions/{id}/respond` (`useApprovalActions.ts:97-101`) | `respondInteraction` exists in the package and desktop uses it only in `SessionInspector/api.ts:132` | **No — genuinely different, and both are correct for their caller.** §5.2. | +| 11 | Pending-approval detection | 3 detectors: `getPendingApprovals` (`components/ApprovalDock.tsx:35-47`), `getPendingConnectInteraction` (`components/InteractionDock.tsx:41-53`), `isHitlPending` (`@agenta/playground`, via `useAgentChatQueue.ts:76`) | 2 detectors: `getPendingApprovals` (`@agenta/chat/model/approvals.ts:29-41`) for the dock, `pendingCountBySession` (`useActionableInteractions.ts:39-48`) for the badge | `@agenta/chat/model/approvals.ts` | **No.** Transcript-derived (last assistant message only) vs interactions-table-derived (all actionable rows, no `kind` filter). They disagree for elicitation rows: list badges, dock can't answer. | +| 12 | Session title | `sessionLabel` (`sessions.ts:808-817`) title → first user text → `Chat N`; rename/auto-title write `setSessionHeader` (`:593,621`) | `session.name ?? "Untitled session"` (`SessionRow.tsx:41`); chat header re-queries the row (`ChatHeader.tsx:16-21,31`) | `setSessionHeader` (`api.ts:~380`) | **No.** Mobile has no fallback chain and no rename. | +| 13 | Archive / delete / kill | `sessions.ts:333-405` + `killSession` (`SessionHistoryMenu.tsx:69`, `useAgentChatSession.ts:371`) | absent | remotes all in the package | N/A — mobile parity gap, not a duplication. | +| 14 | Stop (cooperative cancel) | `commandSessionStream` (`useAgentChatSession.ts:387`) | `commandSessionStream` (`StopButton.tsx:16`) | `api.ts:398-421` | **Yes.** | +| 15 | Query-key namespaces | `["session-list",…]`, `["session-liveness",…]` (jotai) | `["mobile","sessions"…]`, `["mobile","session-liveness"…]`, `["mobile","actionable-interactions"…]`, `["mobile","session-stream"…]` | `sessionRecordsQueryKey`/`sessionMountsQueryKey` only | **No.** This is why `useSessionWatch.ts:64-68` must invalidate react-query keys *and* set a jotai atom. | +| 16 | Message persistence + watermark | `sessions.ts:139-159,667-692,725-749` | none (read-only) | `@agenta/chat/state/sessionMessages.ts` — same localStorage key, drifted | **No** — see §1.2. Latent, becomes live on adoption. | + +Net: **rows 2, 5, 6, 7, 9, 11, 15 are true duplication.** Rows 1, 3, 10, 12 are *divergence* +(deliberate or accidental — resolved in §5). Rows 4, 13 are asymmetry, not duplication. Row 8 +is the model to copy. Row 16 is a latent defect the consolidation must fix on the way through. + +--- + +## 3. Structural recommendation + +### Verdict: **no new package.** Extend `@agenta/entities/session`, and put conversation-level orchestration in `@agenta/chat`. + +A new `@agenta/sessions` would have to sit between `entities` and `chat` in the hierarchy +`shared ← ui ← entities ← entity-ui ← playground ← playground-ui` +(`web/AGENTS.md:444-445`; `@agenta/chat` depends on `entities`, `playground`, `shared` — +`web/packages/agenta-chat/package.json:29-33`). Everything it would contain either: + +- depends on `session/core/schema.ts` types and `session/api/*` accessors → it would import + `@agenta/entities`, so it must sit **above** entities; or +- is consumed by `@agenta/chat` → it must sit **below** chat. + +That is exactly the slot `@agenta/entities/session` already occupies. Creating a package to +hold code that imports entities and is imported by chat buys one thing (a name) and costs four: +a fifth workspace package to build/lint/typecheck, a new `transpilePackages` entry in two +next.configs, a split of nine already-passing session test files across two suites, and a fresh +boundary argument on every future file ("entities/session or sessions?"). The upstream signal +is unambiguous: `transcriptAdoption.ts` was placed in `entities/session/core` this release, and +desktop imports it from there. + +**Where the real split already is, and should stay:** + +| Layer | Package | Owns | Test suite | +| --- | --- | --- | --- | +| Data | `@agenta/entities/session` | Fern accessors, zod schemas, pure derivations, shared query caches + revalidate atoms, query-key factories | `agenta-entities` unit (~934) | +| Conversation | `@agenta/chat` | Transcript model, view-models, transport, headless hooks, skin registry — everything that needs `ai`/`@ai-sdk/react` as a peer | `agenta-chat` (207) | +| App | `web/oss`, `web/mobile` | Rendering, layout, virtualization, scroll, routing, per-app query-client wiring | oss tsc gate; mobile (83) | + +The rule that keeps them apart: **`@agenta/entities/session` never imports `ai`.** +`@agenta/chat` already declares `ai` and `@ai-sdk/react` as peers +(`agenta-chat/package.json:34-39`). Anything that touches `UIMessage` is chat, not entities. +That is a mechanical test a reviewer can apply without judgement. + +### The one new subpath: `@agenta/entities/session` gains a `state/list.ts` and a `hooks/` seam + +Two things in the inventory have no home under the current split: + +- the **list model** (fetch + windowing + activity key + ordering) — pure data, belongs in + entities `state/`; +- the **SSE watch relay** — a React hook with a DOM `EventSource` and no `ai` dependency. It + belongs in entities too, but entities currently has no React-hook precedent in `session/`. + `session/state/*` is jotai atoms, not hooks. I'd add `session/hooks/useSessionWatch.ts` and a + `"./session/hooks"` export rather than force a hook into `state/`. + +`@agenta/entities` is not React-free (it ships jotai atoms and molecules), so a hook is not a +boundary violation — but it is a new shape for this domain and worth calling out in review. + +### The boundary — what does NOT go into the shared layer + +A package without a stated exclusion list becomes a dumping ground. Excluded, permanently: + +1. **Anything that renders.** No antd, no shadcn, no Lexical, no `react-virtuoso`. Mobile cannot + import antd; desktop will not import shadcn until the migration lands. +2. **Virtualization and scroll engineering.** `useVirtuosoTranscript.tsx`, `useTranscriptScroll.ts`, + `useScrollIntent.ts`, `state/virtualization.ts`, `virtuosoState.ts` (desktop); + `useTranscriptAutoScroll.ts`, `useSessionListScrollRestore.ts` (mobile). These encode + completely different scrollers with different correctness conditions. +3. **Panel/layout/tab state.** `state/panelLayout.ts`, `state/rightPanel.ts`, and the whole + open-tabs model in `sessions.ts:123-135,206-213` — antd `Tabs` semantics (inactive panes stay + mounted, `useSessionHydration.ts:255-256`) are a desktop fact. Mobile is one session per route. +4. **The scope-key concept.** `state/scope.tsx` + `defaultScopeKeyAtom` (`sessions.ts:76-84`, + which reads `window.location.pathname` with an `/apps/:id` regex) is a desktop routing + artifact. Mobile scopes by `projectId` from the route. +5. **The localStorage session cache** (`agenta:agent-chat:*`). Mobile has no offline session + model and does not want one. The *reconciler shape* can be shared as a pure function; the + five `atomWithStorage` stores stay in `web/oss`. +6. **App-specific query-client policy.** Mobile sets `refetchOnWindowFocus:false` globally + (`web/mobile/src/lib/queryClient.ts:7-8`) and each hook opts back in; desktop uses jotai + `atomWithQuery` against a different client. Shared code exposes **query options factories**, + never mounted queries — the caller decides jotai vs react-query, priority, and focus policy. +7. **Auth.** Desktop uses `supertokens-auth-react` (`useSessionRecordsWatch.ts:3`), mobile + `supertokens-web-js` via `@/lib/auth`. A shared watch hook takes an + `onAuthRetry?: () => Promise` option; it never imports supertokens. +8. **Onboarding/first-run, drawer scopes, turn inspector, file links** — desktop product + surfaces with no mobile counterpart. + +Positive form of the same rule, borrowed from `@agenta/chat`'s proven shape: **the shared layer +never branches on "desktop" or "mobile".** If a function needs to know which app called it, the +difference is an option the caller passes, or the code is in the wrong layer. + +--- + +## 4. Migration plan + +Ordered so desktop never breaks and each step is independently reviewable. "Touches `web/oss`?" +is the column that matters for conflict risk — see §6. + +Standing gates for every step: + +```bash +cd web/packages/agenta-entities && pnpm run check && pnpm run test:unit # ~934 +cd web/packages/agenta-chat && pnpm run check && pnpm run test:unit # 207 +cd web/packages/agenta-shared && pnpm run test:unit # 330 +cd web/mobile && pnpm run types:check && pnpm test # 83 +cd web && pnpm --filter @agenta/oss exec tsc # signature diff, not count +cd api && py-run-tests # sessions 238 +``` + +The oss gate is a **signature diff**, never an error count — the count fluctuates with cache +state (`feedback_tsc_count_gate_masks_new_errors`). Gate on "no error mentions a symbol this +step touched". + +| # | Step | Moves | Call sites become | Proof | Touches `web/oss`? | Rollback | +| --- | --- | --- | --- | --- | --- | --- | +| **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 | +| **S3** | Liveness derivation parity | nothing moves — mobile switches `useLivenessPoll.ts:28-37` from raw `flags.is_running` to `deriveStreamNest`/`deriveSessionLifecycle` | mobile badge map derived, not read | mobile `livenessBadge.test.ts` extended with a parked/zombie fixture; entities `session-liveness` already covers the derivation | **No** | revert one file | +| **S4** | Shared watch relay | New `session/hooks/useSessionWatch.ts` + `session/core/watchRelay.ts` (URL builder, `MIN_INTERVAL_MS`, jittered backoff, `watchAwarePollMs`). Options: `{sessionId, projectId, enabled, apiUrl, onRecordsChanged, onLifecycle?, onInteraction?, onAuthRetry?}` | `useSessionRecordsWatch.ts` → thin desktop wrapper passing `Session.attemptRefreshingSession`; `useSessionWatch.ts` → thin mobile wrapper passing `tryRefreshSession` + the two badge invalidations | port `web/mobile/tests/unit/watchRelay.test.ts` into `agenta-entities/tests/unit`; both apps' wrappers typecheck | yes, one hook replaced by a wrapper | revert; both hooks are self-contained | +| **S5** | Adoption guard parity | nothing moves — mobile's `useSessionTranscript.ts` adopts `shouldAdoptServerTranscript`; the trailing-refresh queue (`:26-30,56-59`) stays mobile-local for now | mobile gains the record-watermark rule | new mobile unit test for the guard call; entities `session-transcript-adoption` already covers the rule table | **No** | revert one file | +| **S6** | Transcript replay, one copy | Delete `web/oss/.../assets/loadSession.ts` + `transcriptToMessages.ts`; OSS imports `@agenta/chat/assets` | `useSessionHydration.ts:9` and callers re-point | **This is the WP3a "desktop re-plumb" step.** Diff the 27+57 drifted lines first and decide each one; `agenta-chat` fixture tests (207) are the regression net; live smoke on a session with tools + an approval + attachments | **yes, materially** | revert the import swap; the OSS files come back from git | +| **S7** | Pending-approval, one definition | `session/core/pendingApprovals.ts`: `actionableRowsToPending(rows)` alongside chat's transcript-derived `getPendingApprovals`, plus a documented reconciliation rule (which wins when they disagree) | desktop's three detectors and mobile's two collapse to two named functions with one stated precedence | unit tests for the disagreement cases (elicitation row present, transcript tail stale) | yes | revert | +| **S8** | Answer-an-interaction, one entry point | `@agenta/chat` gains `useInteractionAnswer({strategy})` wrapping target selection (`approvalTargets.ts`), the stale re-read, 409 handling, and the settle state machine; strategy `"detached-respond"` \| `"resume-invoke"` injected by the caller | mobile passes `detached-respond`; desktop passes `resume-invoke` and keeps its transport | mobile 83 + the new hook's tests; desktop live smoke on approve / deny / approve-all | yes | revert; mobile's `useApprovalActions` is intact underneath | +| **S9** | Session title + list-row model | `session/core/sessionLabel.ts` (title → first user text → `Chat N`); mobile gains rename via `setSessionHeader` if wanted | both apps share the fallback chain | entities unit test | yes | revert | +| **S10** | Optional, only if desired | Lift the reconciler as a **pure** function `reconcileSessions(local, remote)` into `session/core/reconcile.ts`; the jotai stores stay in `web/oss` | `sessions.ts:429-505` becomes store-plumbing around a pure call | the merge rules (`:443-455`) get a real unit test they don't have today | yes, the biggest single oss diff | revert | + +**Steps that need no `web/oss` change at all: S0, S3, S5.** Land those first — they are pure +mobile/package work and cannot conflict with the FE PR queue. + +**Steps S1–S2, S4, S7–S9 are small, surgical oss diffs** (one file each, mostly import swaps). +**S6 and S10 are the real re-plumb** and should be their own PR, sequenced last. + +--- + +## 5. The three called-out decisions + +### 5.1 Ordering — server-side wins for the fetch; the client sort stays, but for a different reason + +The server already orders **both** paths. Windowed: +`api/oss/src/dbs/postgres/sessions/streams/dao.py:156-166` (`apply_windowing(attribute="updated_at", +order="descending")`); unwindowed — the branch desktop hits, since `projectSessions.ts` passes no +`limit`/`next`/`newest` and `querySessions` only attaches `windowing` when a caller opts in +(`api.ts:299-302`) — `dao.py:174-178`: + +```python +stmt = stmt.order_by( + func.coalesce(SessionStreamDBE.updated_at, SessionStreamDBE.created_at).desc(), + SessionStreamDBE.id.desc(), +) +``` + +Desktop's own comment says as much (`projectSessions.ts:63-65`: "belt-and-suspenders"). But the +sort at `projectSessions.ts:59` is **not** the sort that orders the desktop sidebar. The sidebar +reads `sessionHistoryAtomFamily` (`sessions.ts:189-194`), which sorts the **merged** model — +local-optimistic rows included. `bumpSessionActivityAtomFamily` stamps `Date.now()` on the +`streaming → settled` transition (`sessions.ts:634-644`) and the reconciler keeps +`Math.max(local, remote)` (`sessions.ts:447-448`). That is what makes a just-finished turn jump +to the top inside the 60 s poll window, before the server row's `updated_at` is refetched. + +**Recommendation:** + +- The **server ordering is canonical** and both clients must consume the server order as-is. Do + not sort server pages client-side — for mobile that would be actively wrong, since sorting a + cursor-paged list breaks the cursor invariant. +- Delete the sort at `projectSessions.ts:59`; keep the dedup (a real need — two rows can share a + `session_id`), which needs `activity()` regardless. +- Keep `sessions.ts:189-194`. Rename it in review to say what it is: **merge ordering over the + optimistic local layer**, not list ordering. It is not a second implementation of the server's + order; it is the price of having an offline cache. If desktop ever drops the localStorage + cache, this sort goes with it. +- Move the one shared expression (`updated_at ?? created_at → epoch ms`) into + `session/core/ordering.ts` (S1). Three copies today: `projectSessions.ts:66-70`, + `useSessionsInfinite.ts:29`, `SessionRow.tsx:35` — plus `sessions.ts:185` on the *local* model, + which stays separate because its input type is different. +- **Also fix the windowing asymmetry (not strictly ordering, but the same call).** Desktop + fetches the entire unwindowed session list with `include_ended` and `include_archived` both + `true`, every 60 s. For a workspace with thousands of sessions that is the scaling cliff of + this whole feature. Mobile already paginates. I did not measure the payload — **what would + settle it: a `querySessions` response size and duration on a large EE project.** + +### 5.2 Approval answering — the premise "only one binds warm" is not what the evidence says + +Both paths bind warm. The evidence: + +- **Desktop re-invoke** — `POST {serviceUrl}/invoke` with the full history carrying the + `{approved, interactionToken}` envelope; runner finds the parked match and calls + `respondPermission("once"|"reject")` on the **same warm sandbox** + (`docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md:70-83`). +- **Detached `/respond`** — CAS `pending → responded`, then a taskiq worker rebuilds the request + and fires a detached invoke (`…steering.md:93-100`). The dispatcher was **deliberately given + fingerprint parity so mobile answers warm-resume** (`…steering.md:452-456`), and the live + measurement table (`…steering.md:457-465`) labels the respond-dispatcher arm `warm`. + +What actually differs — and it is sharper than warm/cold: + +| | desktop re-invoke | detached `/respond` | +| --- | --- | --- | +| What the client must hold | invocation URL + full `UIMessage[]` history + hydrated `workflowMolecule` (`buildAgentRequest`, `@agenta/playground`) | interaction `id` + `projectId` only | +| Exactly-once | **none at answer time.** The row is transitioned by the *runner* after resume (`…steering.md:83`). Two clients answering concurrently both fire an invoke | CAS at the endpoint; second answer is a `409`, surfaced as `isInteractionConflict` (`api.ts:172-174`) | +| Who holds the token stream | the answering client — live tokens render immediately | nobody; the answerer learns the outcome from records/watch | +| Config on resume | client sends `data.parameters` inline, draft-aware (`agentRequest.ts:353-372`) — correct even for a dirty draft | server replays the gate's stamped `data.parameters` (`…2026-07-29-effective-turn-config.md:1-20`) — correct as of this branch, degrades to reference-hydration for pre-stamp rows | +| Failure mode if misused | a client that cannot reproduce the exact history gets `approval-mismatch (history) → evict + cold`, and the row **stays pending** (`web/mobile/src/features/chat/useApprovalActions.ts:40-42`; mirrored `api.ts:181-184`) | none of that class | +| Steer / deny-with-note | delivered (desktop sends the note as its own turn, `AgentConversation.tsx:337`) | **dropped on the warm path** — `run-turn.ts` `if (opts.resume)` never calls `session.prompt`; measured (`…steering.md:462`), tracked as #5444 | + +**Recommendation: `/respond` is the default and the only *durable* answer path; the re-invoke +is a live-attached fast path, and both go through one entry point (S8).** + +Concretely: `useInteractionAnswer({strategy})` in `@agenta/chat` owns everything that is the +same — target selection (`approvalTargets.ts`, including the `token` vs `id` trap documented at +`approvalTargets.ts:6-12`), the pre-answer staleness re-read, 409 tolerance, the +`idle → resuming → idle` settle with the 60 s re-arm. The strategy is the injected difference. +Desktop keeps `resume-invoke` **only while it is the current stream holder** (live gate present, +status streaming/awaiting); a reload-restored cold tail should use `detached-respond` like +mobile, because that is exactly the case where desktop cannot guarantee history fidelity either. + +Two things I could not verify and that should be tested before S8 lands: + +- **Does a desktop re-invoke race a mobile `/respond` on the same gate?** The desktop path has no + client-side CAS. *Settles it:* answer one gate from both clients within a second and inspect + the interactions row + runner log for a double dispatch. +- **Does `/respond` produce a live stream a desktop tab could attach to?** If not, moving desktop + wholesale to `/respond` would regress perceived latency on approve. *Settles it:* answer via + `/respond` from a desktop tab with the chat open and time first-token-repaint vs the re-invoke + path. + +### 5.3 The two watch relays — one shared hook, options for the rest + +`web/oss/.../useSessionRecordsWatch.ts` (127) and `web/mobile/.../useSessionWatch.ts` (133) + +`watchRelay.ts` (31) are the same protocol implementation twice: same endpoint +(`/sessions/streams/watch?session_id=…&project_id=…`), same `withCredentials`, same 3 s +connect-revalidation throttle, same 1 s→30 s jittered backoff, same +`visibilitychange`-close/reopen, same "only a fatal `CLOSED` retries; refresh auth first, since +the usual cause is a 401 at the token boundary". Desktop's own header says so +(`useSessionRecordsWatch.ts:37-38`). + +Differences that become **options**, not forks: + +| Difference | Option | +| --- | --- | +| desktop `supertokens-auth-react` vs mobile `supertokens-web-js` | `onAuthRetry?: () => Promise` | +| desktop `getAgentaApiUrl()` vs mobile `getApiUrl()` | `apiUrl: string` | +| mobile invalidates `livenessQueryKey`/`actionableInteractionsQueryKey`; desktop invalidates nothing | `onLifecycle?`, `onInteraction?` callbacks | +| mobile folds cadence via `watchAwarePollMs`; desktop's poll is in `useSessionHydration` | export `watchAwarePollMs` as a pure helper; neither app's polling moves | +| desktop's `enabled` is scoped to the *active* tab (antd Tabs keeps panes mounted) | already an `enabled` prop | + +`web/mobile/tests/unit/watchRelay.test.ts` becomes the package's test — the pure helpers are +already extracted on the mobile side, which makes this the cheapest high-value step in the plan. + +--- + +## 6. Risk and sequencing + +### Context + +- This branch is 119 commits ahead of `release/v0.106.2` and has no PR yet. +- The FE PR queue is deep (tsc-cleanup #5464, DrillIn dedup/migration, sessions #5486/#5500, + attachments/voice #5458/#5459, cancel-steer, batch-approvals #5470 — most of them touching + `web/oss/src/components/AgentChatSlice`). +- A local antd→shadcn migration branch is in flight against the presentational set. +- Work has twice been stopped for touching OSS agent chat. +- WP3a already recorded the sequencing rule + (`docs/design/agenta-mobile/plans/2026-07-12-wp3a-chat-headless-core.md:15-17`): the re-plumb + lands "after the FE PR queue drains and the shadcn branch lands". + +### Recommended order + +**Wave 0 — now, alongside the current PR.** S0, S3, S5. Zero `web/oss` files. S0 in particular +is a latent-defect fix that should not wait for anything: a package that writes +`agenta:agent-chat:messages` without the watermark, with cross-tab `subscribe` re-enabled, is a +loaded gun pointed at the desktop chat the moment S6 lands. + +**Wave 1 — after this branch's PR merges.** S1, S2, S4, S7. Each is one small oss diff (an +import swap or a deleted local helper). Land them as four separate PRs, not one — they touch +different files and a reviewer can approve each in minutes. + +**Wave 2 — after the FE queue drains AND the shadcn branch lands.** S6, S8, S9, S10. This is the +WP3a desktop re-plumb, and it deletes OSS copies wholesale. + +### Conflict surface per step + +| Step | oss files touched | Conflict risk | +| --- | --- | --- | +| S0, S3, S5 | 0 | **none** | +| S1 | `projectSessions.ts` (~15 lines) | low — small file, few open PRs touch it | +| S2 | `projectSessions.ts` | low, but **serialize with S1** (same file) | +| S4 | `hooks/useSessionRecordsWatch.ts` (whole file → wrapper) | low — the file is new on this branch (`git status` shows it added) and has few other editors | +| S7 | `components/ApprovalDock.tsx`, `components/InteractionDock.tsx`, `hooks/useAgentChatQueue.ts` | **medium-high** — #5470 (batch approvals) and the friendly-approvals work both live here | +| S8 | `AgentConversation.tsx`, `hooks/useAgentChatSession.ts` | **high** — `AgentConversation.tsx` is the most-contended file in the repo; cancel-steer, elicitation-resume and approval-batch branches all edit it | +| S9 | `state/sessions.ts` | medium | +| S6 | `assets/loadSession.ts`, `assets/transcriptToMessages.ts` + every importer | **high**, and it is a semantic merge (57 drifted lines) not a textual one | +| S10 | `state/sessions.ts` (~75 lines) | medium-high | + +### What must wait for the antd→shadcn branch + +Strictly speaking, **nothing in this plan renders**, so none of S0–S10 has a hard shadcn +dependency. But two have a soft one: + +- **S6** deletes OSS transcript-building code whose output feeds `AgentMessage.tsx`, + `ToolActivity.tsx`, `ApprovalDock.tsx` — the exact components the shadcn migration is + rewriting. Landing S6 first means the shadcn branch rebases onto a changed data source; + landing it second means one rebase of S6. Second is cheaper. +- **S8** touches `ApprovalDock.tsx`'s call site. Same argument. + +S7's *pure* half (the `core/pendingApprovals.ts` function + tests) can land in Wave 1; only the +call-site swap needs to wait. + +### Top 3 risks + +1. **S6/S8 land into a contended file and get reverted.** `AgentConversation.tsx` and the + `assets/` transcript pair are edited by at least four in-flight branches. *Mitigation:* Wave 2 + only, one PR per step, and a pre-flight `git log --oneline .. -- + web/oss/src/components/AgentChatSlice` to see who else is in the file that week. +2. **The `sessionMessages.ts` twin silently corrupts a desktop transcript.** Same localStorage + key, no watermark, cross-tab subscribe re-enabled — the exact hazard `sessions.ts:102-106` + was written to prevent. Today it is dormant only because no app mounts both. *Mitigation:* + S0, in Wave 0, before anything else. +3. **Consolidating approvals flattens a difference that is load-bearing.** The re-invoke and + `/respond` paths differ in exactly-once semantics, stream ownership, config source **and** + steer delivery (§5.2). A single "just use `/respond`" would regress live desktop latency and + silently drop deny-with-note on the warm path (measured, `…steering.md:462`). *Mitigation:* + S8 shares the *orchestration* and keeps the strategy injectable; run the two verification + tests in §5.2 before choosing a desktop default. + +Runner-up risk worth naming: session-id-keyed `atomFamily` instances are never `.remove()`d +except `expandedValueAtomFamily` (`state/expandState.ts:65-66`). Every shared atom family this +plan adds inherits that leak. Give each new family a documented eviction call site or don't add +it. + +--- + +## 7. Open questions + +| # | Question | What would settle it | +| --- | --- | --- | +| Q1 | Should desktop paginate the session list like mobile? | Measure `querySessions` payload size + duration on a large EE project. | +| Q2 | Does a desktop re-invoke race a mobile `/respond` on the same gate? | Answer one gate from both clients within a second; inspect the interactions row and the runner dispatch log. | +| Q3 | Can a desktop tab attach to the stream a `/respond` resume produces? | Answer via `/respond` with the desktop chat open; time first-token repaint against the re-invoke path. | +| Q4 | Is a React hook acceptable inside `@agenta/entities/session` (S4)? | Reviewer call. `entities` already ships jotai atoms; a hook is new for this domain but not for the package. | +| Q5 | When two pending-approval detectors disagree (elicitation row badges the list, dock can't answer it), which is authoritative? | Product decision, needed before S7 can state a precedence rule. | +| Q6 | Which of the 57 drifted lines in `transcriptToMessages.ts` are fixes and which are regressions? | Line-by-line diff review during S6 — I did not read them. | diff --git a/web/mobile/scripts/generate-shadcn-tokens.ts b/web/mobile/scripts/generate-shadcn-tokens.ts index 28d99e998b..7a610e3e4b 100644 --- a/web/mobile/scripts/generate-shadcn-tokens.ts +++ b/web/mobile/scripts/generate-shadcn-tokens.ts @@ -57,6 +57,9 @@ const VARS: Record = { accent: [color(p.surface.controlItemBgActive.light), color(p.scales.zinc[2].dark)], "accent-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], destructive: [color(p.semantic.error.light), color(p.semantic.error.dark)], + // Not a shadcn role: session liveness needs a third state between the accent (a turn is + // running, act on it) and muted (ended). Same semantic family the desktop uses for "healthy". + success: [color(p.semantic.success.light), color(p.semantic.success.dark)], // Bright-red fill gets dark text in dark mode, matching the primary treatment. "destructive-foreground": [color(p.surface.white.light), p.componentsDark.Button.primaryColor], border: [color(p.border.secondary.light), color(p.border.secondary.dark)], diff --git a/web/mobile/src/components/AgentaLogo.tsx b/web/mobile/src/components/AgentaLogo.tsx new file mode 100644 index 0000000000..eb816a7e93 --- /dev/null +++ b/web/mobile/src/components/AgentaLogo.tsx @@ -0,0 +1,23 @@ +interface AgentaLogoProps { + className?: string +} + +/** + * Agenta wordmark, inlined from `web/oss/public/assets/logos/Agenta-logo-full-light.svg`. + * + * Inlined rather than served from `public/`: a bare `` misses the app's + * `/m` basePath, and the desktop app swaps two files by theme. `currentColor` gets both for + * free — the mark takes whatever text color it inherits, in either theme. + */ +export const AgentaLogo = ({className}: AgentaLogoProps) => ( + + + + +) diff --git a/web/mobile/src/components/FilterChip.tsx b/web/mobile/src/components/FilterChip.tsx new file mode 100644 index 0000000000..32673eaaac --- /dev/null +++ b/web/mobile/src/components/FilterChip.tsx @@ -0,0 +1,49 @@ +import type {ReactNode} from "react" + +import {ListFilter, X} from "lucide-react" + +import {cn} from "@/lib/utils" + +/** + * A toggleable filter, styled as one chip rather than a label plus a verb phrase — a separate + * "show only these" link reads as a second control, and its demonstrative has no antecedent + * while the filter is off and the rows are not on screen yet. + * + * The icon carries the affordance (funnel to apply, cross to clear) so the label can stay the + * plain count it filters by. + */ +export const FilterChip = ({ + active, + onToggle, + label, + children, +}: { + active: boolean + onToggle: () => void + /** Spoken label — the visible text is only a count, which says nothing on its own. */ + label: string + children: ReactNode +}) => ( + +) diff --git a/web/mobile/src/components/ScreenScaffold.tsx b/web/mobile/src/components/ScreenScaffold.tsx new file mode 100644 index 0000000000..a182595b07 --- /dev/null +++ b/web/mobile/src/components/ScreenScaffold.tsx @@ -0,0 +1,42 @@ +import type {ReactNode, RefObject, UIEventHandler} from "react" + +interface ScreenScaffoldProps { + /** Pinned above the scroller. Never scrolls away. */ + header?: ReactNode + /** Pinned below the scroller (approval dock, composer). */ + footer?: ReactNode + scrollRef?: RefObject + onScroll?: UIEventHandler + children: ReactNode +} + +/** + * The one mobile screen shape: a viewport-height column whose header and footer are pinned and + * whose middle is the ONLY scroller. + * + * `h-dvh` + an `overflow-y-auto` middle keeps scrolling inside the list rather than the + * document — a `min-h-dvh` page scrolls its own header off-screen on iOS, which is the bug this + * exists to prevent. The bottom-most element owns the safe-area inset, so the scroller only + * pads for it when there is no footer. + */ +export const ScreenScaffold = ({ + header, + footer, + scrollRef, + onScroll, + children, +}: ScreenScaffoldProps) => ( +
+ {header} +
+ {children} +
+ {footer} +
+) diff --git a/web/mobile/src/components/StatusTag.tsx b/web/mobile/src/components/StatusTag.tsx new file mode 100644 index 0000000000..31f28d8f3f --- /dev/null +++ b/web/mobile/src/components/StatusTag.tsx @@ -0,0 +1,49 @@ +import type {ReactNode} from "react" + +import {cn} from "@/lib/utils" + +export type StatusTone = "running" | "live" | "attention" | "muted" + +/** + * Three distinct states, distinguishable without reading the label: the accent means "act on + * this", success means "healthy and warm", muted means "nothing is happening here". `live` and + * `muted` must never collapse to the same treatment — a warm session and an ended one are + * opposites, and rendering both as the same grey pill is what made "live" read as inert. + */ +const TONES: Record = { + running: "border-primary/40 text-primary bg-primary/10", + live: "border-success/40 text-success bg-success/10", + attention: "border-primary/40 text-primary bg-primary/10", + muted: "border-border text-muted-foreground bg-muted/40", +} + +/** + * The one status tag across mobile. Statuses used to render as bare coloured words next to a + * title, which read as part of the sentence rather than as state — a pill with its own border + * and background separates them. + * + * `dot` is reserved for the two tones that mean "something is happening", so the tag carries the + * distinction without relying on colour alone. + */ +export const StatusTag = ({ + tone, + dot = false, + children, + className, +}: { + tone: StatusTone + dot?: boolean + children: ReactNode + className?: string +}) => ( + + {dot ? : null} + {children} + +) diff --git a/web/mobile/src/features/auth/AuthDivider.tsx b/web/mobile/src/features/auth/AuthDivider.tsx new file mode 100644 index 0000000000..88bfe9909d --- /dev/null +++ b/web/mobile/src/features/auth/AuthDivider.tsx @@ -0,0 +1,8 @@ +/** "or" rule between the social block and the email block. */ +export const AuthDivider = () => ( +
+ + or + +
+) diff --git a/web/mobile/src/features/auth/EmailOtpForm.tsx b/web/mobile/src/features/auth/EmailOtpForm.tsx new file mode 100644 index 0000000000..41b9c35f9e --- /dev/null +++ b/web/mobile/src/features/auth/EmailOtpForm.tsx @@ -0,0 +1,141 @@ +import {useEffect, useReducer, type FormEvent} from "react" + +import {clearEmailCodeAttempt, requestEmailCode, resendEmailCode, submitEmailCode} from "@/lib/auth" +import {initialOtpState, otpReducer, OTP_RESEND_COOLDOWN_MS} from "@/lib/auth/otpMachine" + +import {authFieldClass, authPrimaryButtonClass, authQuietButtonClass} from "./authStyles" + +interface EmailOtpFormProps { + onSuccess: () => Promise +} + +/** Passwordless sign-in: request a one-time code, then consume it. */ +export const EmailOtpForm = ({onSuccess}: EmailOtpFormProps) => { + const [state, dispatch] = useReducer(otpReducer, undefined, () => initialOtpState()) + const busy = state.phase === "sending" || state.phase === "verifying" + + useEffect(() => { + if (!state.resendBlocked) return + const timer = setTimeout(() => dispatch({type: "resend-allowed"}), OTP_RESEND_COOLDOWN_MS) + return () => clearTimeout(timer) + }, [state.resendBlocked]) + + const sendCode = async (event: FormEvent) => { + event.preventDefault() + if (busy) return + dispatch({type: "send"}) + const outcome = await requestEmailCode(state.email.trim()) + if (outcome.kind === "ok") dispatch({type: "sent"}) + else dispatch({type: "failed", message: outcome.message}) + } + + const verifyCode = async (event: FormEvent) => { + event.preventDefault() + if (busy) return + dispatch({type: "verify"}) + const outcome = await submitEmailCode(state.code.trim()) + if (outcome.kind === "ok") { + await clearEmailCodeAttempt() + await onSuccess() + return + } + if (outcome.kind === "retry") { + dispatch({type: "failed", message: outcome.message}) + return + } + await clearEmailCodeAttempt() + dispatch({type: "restart", message: outcome.message}) + } + + const resend = async () => { + if (busy || state.resendBlocked) return + const outcome = await resendEmailCode() + if (outcome.kind === "ok") { + dispatch({type: "resent"}) + return + } + await clearEmailCodeAttempt() + dispatch({type: "restart", message: outcome.message}) + } + + const useAnotherEmail = async () => { + await clearEmailCodeAttempt() + dispatch({type: "restart"}) + } + + const feedback = ( + <> + {state.error ? ( +

+ {state.error} +

+ ) : null} + {state.notice ? ( +

+ {state.notice} +

+ ) : null} + + ) + + if (state.phase === "email" || state.phase === "sending") { + return ( +
+ dispatch({type: "edit-email", value: event.target.value})} + className={authFieldClass} + /> + {feedback} + +
+ ) + } + + return ( +
+

+ We sent a code to {state.email}. +

+ dispatch({type: "edit-code", value: event.target.value})} + className={`${authFieldClass} tracking-[0.4em]`} + /> + {feedback} + +
+ + +
+
+ ) +} diff --git a/web/mobile/src/features/auth/EmailPasswordForm.tsx b/web/mobile/src/features/auth/EmailPasswordForm.tsx new file mode 100644 index 0000000000..a10d0661ce --- /dev/null +++ b/web/mobile/src/features/auth/EmailPasswordForm.tsx @@ -0,0 +1,61 @@ +import {useState, type FormEvent} from "react" + +import {signInWithEmailPassword} from "@/lib/auth" + +import {authFieldClass, authPrimaryButtonClass} from "./authStyles" + +interface EmailPasswordFormProps { + onSuccess: () => Promise +} + +export const EmailPasswordForm = ({onSuccess}: EmailPasswordFormProps) => { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (event: FormEvent) => { + event.preventDefault() + if (pending) return + setPending(true) + setError(null) + const outcome = await signInWithEmailPassword(email.trim(), password) + if (outcome.kind === "ok") { + await onSuccess() + return + } + setPending(false) + setError(outcome.kind === "rejected" ? outcome.message : "Something went wrong. Try again.") + } + + return ( +
+ setEmail(event.target.value)} + className={authFieldClass} + /> + setPassword(event.target.value)} + className={authFieldClass} + /> + {error ? ( +

+ {error} +

+ ) : null} + +
+ ) +} diff --git a/web/mobile/src/features/auth/OidcCallbackScreen.tsx b/web/mobile/src/features/auth/OidcCallbackScreen.tsx new file mode 100644 index 0000000000..8a392a7ea4 --- /dev/null +++ b/web/mobile/src/features/auth/OidcCallbackScreen.tsx @@ -0,0 +1,60 @@ +import {useEffect, useRef, useState} from "react" + +import Link from "next/link" +import {useRouter} from "next/router" + +import {AgentaLogo} from "@/components/AgentaLogo" +import {completeOidcSignIn} from "@/lib/auth" + +import {useAuthSuccess} from "./useAuthSuccess" + +/** + * Landing for an OIDC redirect the mobile app started. The provider redirected + * to the desktop `/auth/callback/` (the only registered URI) and the device + * gate forwarded it here with the query intact; signInAndUp reads the OAuth + * state from the same-origin sessionStorage and exchanges the code. + */ +export const OidcCallbackScreen = () => { + const router = useRouter() + const onSuccess = useAuthSuccess() + const started = useRef(false) + const [error, setError] = useState(null) + + useEffect(() => { + // Wait for the query so a missing code reads as "not ready", not "failed". + if (!router.isReady || started.current) return + started.current = true + + if (!router.query.code && !router.query.state) { + void router.replace("/auth") + return + } + + void (async () => { + const outcome = await completeOidcSignIn() + if (outcome.kind === "ok") { + await onSuccess() + return + } + setError(outcome.message) + })() + }, [router, router.isReady, onSuccess]) + + return ( +
+ + {error ? ( + <> +

+ {error} +

+ + Back to sign in + + + ) : ( +

Finishing sign-in…

+ )} +
+ ) +} diff --git a/web/mobile/src/features/auth/OidcProviderButtons.tsx b/web/mobile/src/features/auth/OidcProviderButtons.tsx new file mode 100644 index 0000000000..6ce12c50ae --- /dev/null +++ b/web/mobile/src/features/auth/OidcProviderButtons.tsx @@ -0,0 +1,50 @@ +import {useState} from "react" + +import {startOidcSignIn, type OidcProvider} from "@/lib/auth" + +import {authPrimaryButtonClass} from "./authStyles" + +interface OidcProviderButtonsProps { + providers: OidcProvider[] +} + +/** One button per configured provider; clicking leaves for the provider. */ +export const OidcProviderButtons = ({providers}: OidcProviderButtonsProps) => { + const [redirecting, setRedirecting] = useState(null) + const [error, setError] = useState(null) + + const start = async (providerId: string) => { + if (redirecting) return + setRedirecting(providerId) + setError(null) + // Resolves only on failure — success navigates away. + await startOidcSignIn(providerId) + setRedirecting(null) + setError("Could not reach that provider. Try again.") + } + + if (providers.length === 0) return null + + return ( +
+ {providers.map((provider) => ( + + ))} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/web/mobile/src/features/auth/SignInScreen.tsx b/web/mobile/src/features/auth/SignInScreen.tsx index 8f93c90217..6088cd4d45 100644 --- a/web/mobile/src/features/auth/SignInScreen.tsx +++ b/web/mobile/src/features/auth/SignInScreen.tsx @@ -1,89 +1,78 @@ -import {useEffect, useState, type FormEvent} from "react" +import {useEffect, useState} from "react" -import {useRouter} from "next/router" +import {AgentaLogo} from "@/components/AgentaLogo" +import { + getEmailSignInMode, + isOidcEnabled, + listOidcProviders, + type EmailSignInMode, + type OidcProvider, +} from "@/lib/auth" -import {getEmailSignInMode, signInWithEmailPassword, type EmailSignInMode} from "@/lib/auth" -import {queryClient} from "@/lib/queryClient" +import {AuthDivider} from "./AuthDivider" +import {EmailOtpForm} from "./EmailOtpForm" +import {EmailPasswordForm} from "./EmailPasswordForm" +import {OidcProviderButtons} from "./OidcProviderButtons" +import {SsoDiscoveryForm} from "./SsoDiscoveryForm" +import {AuthMethodsSkeleton} from "./states/AuthMethodsSkeleton" +import {NoAuthMethods} from "./states/NoAuthMethods" +import {useAuthSuccess} from "./useAuthSuccess" -/** Raw email/password sign-in (auth-lite). OIDC/social stays a desktop flow. */ -export const SignInScreen = () => { - const router = useRouter() - // Mode reads window.__env — resolve after mount so SSR markup never differs. - const [mode, setMode] = useState(null) - useEffect(() => setMode(getEmailSignInMode()), []) - - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const [pending, setPending] = useState(false) - const [error, setError] = useState(null) +interface ResolvedMethods { + mode: EmailSignInMode + providers: OidcProvider[] + /** Org-SSO discovery is worth offering whenever the deployment enables OIDC. */ + ssoDiscovery: boolean +} - const onSubmit = async (event: FormEvent) => { - event.preventDefault() - if (pending) return - setPending(true) - setError(null) - const outcome = await signInWithEmailPassword(email.trim(), password) - if (outcome.kind === "ok") { - // Drop the cached unauthenticated verdict before the resolver reruns. - await queryClient.invalidateQueries({queryKey: ["mobile", "projects"]}) - void router.replace("/") - return - } - setPending(false) - setError(outcome.kind === "rejected" ? outcome.message : "Something went wrong. Try again.") - } +/** Every method this deployment enables: password OR one-time code, social, org SSO. */ +export const SignInScreen = () => { + const onSuccess = useAuthSuccess() + // Methods read window.__env — resolve after mount so SSR markup never differs. + const [methods, setMethods] = useState(null) + useEffect( + () => + setMethods({ + mode: getEmailSignInMode(), + providers: listOidcProviders(), + ssoDiscovery: isOidcEnabled(), + }), + [], + ) let body - if (mode === null) { - body =

Loading…

- } else if (mode !== "password") { - body = ( -

- {mode === "otp" - ? "Email code sign-in is not available here." - : "Email sign-in is disabled on this deployment."} -

- ) + if (methods === null) { + body = + } else if ( + methods.mode === "disabled" && + methods.providers.length === 0 && + !methods.ssoDiscovery + ) { + body = } else { + const emailBlock = + methods.mode === "password" ? ( + + ) : methods.mode === "otp" ? ( + + ) : null body = ( -
- setEmail(event.target.value)} - className="border-border bg-background rounded-md border px-3 py-2 text-base" - /> - setPassword(event.target.value)} - className="border-border bg-background rounded-md border px-3 py-2 text-base" - /> - {error ?

{error}

: null} - -
+
+ + {methods.providers.length > 0 && emailBlock ? : null} + {emailBlock} + {methods.ssoDiscovery ? : null} +
) } return ( -
-

Sign in to Agenta

- {body} -

- For SSO or social sign-in, use the desktop app. -

+
+
+ +

Sign in or create an account.

+
+
{body}
) } diff --git a/web/mobile/src/features/auth/SsoDiscoveryForm.tsx b/web/mobile/src/features/auth/SsoDiscoveryForm.tsx new file mode 100644 index 0000000000..85de02ea7d --- /dev/null +++ b/web/mobile/src/features/auth/SsoDiscoveryForm.tsx @@ -0,0 +1,120 @@ +import {useState, type FormEvent} from "react" + +import {startOidcSignIn} from "@/lib/auth" +import {discoverSsoProviders, type DiscoveredSsoProvider} from "@/lib/auth/discover" + +import {authFieldClass, authPrimaryButtonClass, authQuietButtonClass} from "./authStyles" + +type Phase = "idle" | "asking" | "checking" | "results" + +/** + * Organization SSO: ask for the work email, let the backend name the org's + * providers, then run the same redirect flow as the social buttons. + */ +export const SsoDiscoveryForm = () => { + const [phase, setPhase] = useState("idle") + const [email, setEmail] = useState("") + const [providers, setProviders] = useState([]) + const [error, setError] = useState(null) + const [redirecting, setRedirecting] = useState(null) + + const check = async (event: FormEvent) => { + event.preventDefault() + if (phase === "checking") return + setPhase("checking") + setError(null) + const result = await discoverSsoProviders(email.trim()) + if (result.kind === "failed") { + setPhase("asking") + setError(result.message) + return + } + if (result.providers.length === 0) { + setPhase("asking") + setError("No SSO is configured for that email.") + return + } + setProviders(result.providers) + setPhase("results") + } + + const start = async (thirdPartyId: string) => { + if (redirecting) return + setRedirecting(thirdPartyId) + setError(null) + // Resolves only on failure — success navigates away. + await startOidcSignIn(thirdPartyId) + setRedirecting(null) + setError("Could not reach that provider. Try again.") + } + + if (phase === "idle") { + return ( + + ) + } + + if (phase === "results") { + return ( +
+ {providers.map((provider) => ( + + ))} + {error ? ( +

+ {error} +

+ ) : null} + +
+ ) + } + + return ( +
+ setEmail(event.target.value)} + className={authFieldClass} + /> + {error ? ( +

+ {error} +

+ ) : null} + +
+ ) +} diff --git a/web/mobile/src/features/auth/authStyles.ts b/web/mobile/src/features/auth/authStyles.ts new file mode 100644 index 0000000000..21d05f6071 --- /dev/null +++ b/web/mobile/src/features/auth/authStyles.ts @@ -0,0 +1,10 @@ +/** Shared raw-element classes for the sign-in surfaces (semantic tokens only). */ + +export const authFieldClass = + "border-border bg-background rounded-md border px-3 py-2 text-base disabled:opacity-50" + +export const authPrimaryButtonClass = + "border-border min-h-11 rounded-md border px-3 py-2 text-sm disabled:opacity-50" + +export const authQuietButtonClass = + "text-muted-foreground min-h-11 px-3 py-2 text-xs underline underline-offset-4 disabled:opacity-50" diff --git a/web/mobile/src/features/auth/states/AuthMethodsSkeleton.tsx b/web/mobile/src/features/auth/states/AuthMethodsSkeleton.tsx new file mode 100644 index 0000000000..8bd4422fe2 --- /dev/null +++ b/web/mobile/src/features/auth/states/AuthMethodsSkeleton.tsx @@ -0,0 +1,8 @@ +/** Mirrors the resolved layout (two buttons + a field) so nothing shifts. */ +export const AuthMethodsSkeleton = () => ( +
+
+
+
+
+) diff --git a/web/mobile/src/features/auth/states/NoAuthMethods.tsx b/web/mobile/src/features/auth/states/NoAuthMethods.tsx new file mode 100644 index 0000000000..fd768092e7 --- /dev/null +++ b/web/mobile/src/features/auth/states/NoAuthMethods.tsx @@ -0,0 +1,6 @@ +/** Every method is disabled by env — say so instead of showing an empty card. */ +export const NoAuthMethods = () => ( +

+ No sign-in method is enabled on this deployment. Contact your administrator. +

+) diff --git a/web/mobile/src/features/auth/useAuthSuccess.ts b/web/mobile/src/features/auth/useAuthSuccess.ts new file mode 100644 index 0000000000..1d13e8303f --- /dev/null +++ b/web/mobile/src/features/auth/useAuthSuccess.ts @@ -0,0 +1,18 @@ +import {useCallback} from "react" + +import {useRouter} from "next/router" + +import {queryClient} from "@/lib/queryClient" + +/** + * Where every successful sign-in lands, whatever the route (password, OTP, + * OIDC): drop the cached unauthenticated verdict so the root context resolver + * re-fetches, then hand over to it. + */ +export function useAuthSuccess() { + const router = useRouter() + return useCallback(async () => { + await queryClient.invalidateQueries({queryKey: ["mobile", "projects"]}) + await router.replace("/") + }, [router]) +} diff --git a/web/mobile/src/features/chat/ApprovalDock.tsx b/web/mobile/src/features/chat/ApprovalDock.tsx index afc5c63e17..bd9c91389d 100644 --- a/web/mobile/src/features/chat/ApprovalDock.tsx +++ b/web/mobile/src/features/chat/ApprovalDock.tsx @@ -1,6 +1,9 @@ +import {useEffect, useState} from "react" + import type {PendingApproval} from "@agenta/chat/model" import {summarizeApprovalInput} from "./approvalInputSummary" +import {isSteerEnabled} from "./steer" import type {ApprovalActions} from "./useApprovalActions" /** @@ -19,10 +22,20 @@ export const ApprovalDock = ({ actions: ApprovalActions }) => { const current = approvals[0] + // Steer-lite: an optional redirect note sent WITH the denial. Flag-gated — see [[isSteerEnabled]]. + const [steerOpen, setSteerOpen] = useState(false) + const [steerMessage, setSteerMessage] = useState("") + const currentId = current?.approvalId + useEffect(() => { + setSteerOpen(false) + setSteerMessage("") + }, [currentId]) + if (!current) return null const count = approvals.length const busy = actions.phase === "resuming" const summary = summarizeApprovalInput(current.input) + const canSteer = isSteerEnabled() return (
@@ -44,38 +57,91 @@ export const ApprovalDock = ({
) : null} -
- {count > 1 ? ( + {/* While steering, the redirect panel replaces the decision row: an explicit + deny+redirect shouldn't leave Approve competing with it. */} + {steerOpen ? ( +
+

+ Deny this step and tell the agent what to do instead. +

+