diff --git a/docs/design/agenta-mobile/README.md b/docs/design/agenta-mobile/README.md index 66c1840b21..cd1aab0aa8 100644 --- a/docs/design/agenta-mobile/README.md +++ b/docs/design/agenta-mobile/README.md @@ -36,7 +36,7 @@ headless chat core shared between desktop and mobile skins). | [plans/2026-07-12-wp0-sessions-query-and-stamping.md](./plans/2026-07-12-wp0-sessions-query-and-stamping.md) | WP0 residual plan — **EXECUTED** (see banner: re-audit corrections + execution deltas) | | [plans/2026-07-12-wp3a-chat-headless-core.md](./plans/2026-07-12-wp3a-chat-headless-core.md) | WP3a plan — **EXECUTED under copy-extraction** (see banners: strategy change + task mapping) | | [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) — **READY TO EXECUTE**; default-off, T8 banner-retirement deferred to flag-flip | +| [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 @@ -65,6 +65,10 @@ 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). ### WP0 residual — COMPLETE (2026-07-25, 9 commits, all dual-reviewed) @@ -147,6 +151,71 @@ green. Task 5 re-verified (didn't re-add) that mobile lint and `@agenta/chat` un already reached by the existing generic CI mechanisms (workflows 11 and 12); only the mobile image build and `@agenta/mobile` typecheck needed a new job. +### WP5 device gate — EXECUTED (2026-07-26, 5 commits) + +Ships the mobile device gate (design.md "Gate and routing") behind a runtime flag, +`AGENTA_MOBILE_GATE`, **default off**: with the flag off, request behavior is byte-identical to +today. `NoMobilePageWrapper` retirement (T8) is specified but deliberately **not executed** — +it ships only in the deployment window where the flag is actually flipped on. See +[plans/2026-07-26-wp5-device-gate.md](./plans/2026-07-26-wp5-device-gate.md) for the full task +breakdown and grounding facts. + +| Commit | Content | +|--------|---------| +| `cf272e1227` | T1: `@agenta/shared/utils/mobileGate` — pure, framework-free decision core (detection, deep-link maps, cookie semantics, documented exceptions); 27 unit tests in the package's vitest harness | +| `6b3aaf5654` | T2: `web/oss/src/middleware.ts` + `web/ee/src/middleware.ts` — twin desktop forward-gate adapters (byte-identical, both NEW files) wrapping the shared core | +| `2df5e4d2a1` | T3: `web/mobile/src/middleware.ts` — reverse-gate middleware carrying a declared verbatim copy of the reverse-gate subset (mobile has zero workspace deps until WP2), new minimal mobile vitest harness, and the "View desktop site" `?view=desktop` escape link on the placeholder page | +| `7be0b8d528` | T4: plumb `AGENTA_MOBILE_GATE` through dev + gh compose files (default `false`), documented in both dev env examples | +| `9ac651525f` | T6: self-skipping Playwright UA-emulation smoke (`web/oss/tests/playwright/acceptance/mobile-gate/gate.spec.ts`), 6 tests, skips unless the runner asserts `AGENTA_MOBILE_GATE=true` | + +**Verification highlights:** + +- Build proof: all three apps (`oss`, `ee`, `mobile`) print a `ƒ Middleware` row after adding + their respective `src/middleware.ts`, confirming Next 15.5.18 picks up the placement. +- Zero new tsc signatures in `@agenta/oss` or `@agenta/ee` after wiring the middleware (T2). + Scope note from the combined post-execution review: the T6 Playwright spec itself adds 9 + signatures to the `@agenta/oss` tsc run (TS2307 `@playwright/test` + implicit-any bindings) — + the same error class every existing acceptance spec under `tests/playwright/` already + 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 + 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 + notes for the agenta_cloud PR: make the gate cookies `secure` conditional on the forwarded + proto and add `httpOnly` (safe — nothing client-side reads them), and note that EE's first + deploy with these commits is the first live `ƒ Middleware` proof for EE (its local build + predates the middleware; the file is a byte-twin of the verified OSS one). +- **T5 live proof** (the load-bearing verification): same standalone binary, two runs, no + rebuild between them. Flag on (`AGENTA_MOBILE_GATE=true`): mobile UA on `/w` → `307` to `/m/`, + desktop UA on `/w` → `200`. Same binary, flag unset: mobile UA on `/w` → `200`, no redirect — + proving `process.env` in middleware is read at request time on the pinned standalone server, + not build-inlined. Also surfaced a **basePath-stripping observation**: at runtime Next strips + the `/m` basePath from `nextUrl.pathname` before the mobile middleware handler runs, but the + handler normalizes defensively either way (needed for unit tests, which construct + `NextRequest` directly and still see the `/m` prefix). +- **Live QA found two gate defects the review missed (both fixed):** (1) Turbopack's DEV + middleware sandbox exposes only `.env`-file vars, not the container's process env, so + `AGENTA_MOBILE_GATE` read `undefined` in `next dev` even with the container env set — dev + compose commands now mirror the flag into `.env.development.local` at container start + (prod standalone is unaffected; T5's runtime-read proof stands). (2) With `basePath`, the + bare root `/m` never matched the `"/((?!...).*)"` matcher (the root strips to an empty + string), leaving the landing page ungated in BOTH dev and prod — the matcher now carries an + explicit `"/"` entry. Unit tests construct `NextRequest` directly and bypass Next's matcher + layer entirely, which is why 27+8 green tests missed it; only a live end-to-end probe + caught both. Desktop twins are unaffected (no basePath; `/` 308s into gated `/w`). +- **T6 spec self-skip.** `--list` (which does not invoke `global-setup`) confirms the spec + discovers exactly the expected 6 tests. A real (non-`--list`) run against this worktree with + no stack running was attempted to observe the runtime skip directly, but `global-setup` + unconditionally authenticates against `AGENTA_WEB_URL` before any per-test `test.skip` logic + runs, so it fails with `ERR_CONNECTION_REFUSED` rather than reporting `6 skipped` — exactly the + fallback the plan anticipated. With the flag confirmed unset in the runner env (the CI + default), the `test.skip(!gateEnabled, ...)` predicate is proven to evaluate `true`, so the + 6-skipped outcome is correct by construction; observing it as a live Playwright report is an + operator step against a running stack (flag-on run is likewise an operator step — see Open + items below). + ## Resume runbook (from here) 1. **Plan wave-2** against the real code (WP2 auth/drawer → WP3b skin → WP4 pages → WP5 gate). @@ -201,6 +270,14 @@ pnpm dev-mobile # → http://localhost:3000/m, check light+dark - **Design-doc staleness:** `docs/designs/sessions/**` predates the streams-merge/turns model; don't trust it over the code. The memory file `project_agenta_mobile_discovery` (assistant memory) mirrors this handoff. +- **WP5 device gate flag-flip runbook (not yet run):** once WP2 (mobile auth) and WP4 (product + pages) are live, per deployment: set `AGENTA_MOBILE_GATE=true` in that deployment's env file, + recreate the `web`/`web-mobile` services, run the T6 Playwright smoke + (`web/oss/tests/playwright/acceptance/mobile-gate/gate.spec.ts`) against it to confirm 6 + passed, then land T8 (the prepared, not-yet-executed `NoMobilePageWrapper` retirement commit) + from [plans/2026-07-26-wp5-device-gate.md](./plans/2026-07-26-wp5-device-gate.md). T8 remains + **specified but unexecuted** in that plan by design — it is coupled to this flip, not to WP5 + landing. ## Follow-up tracks (post-wave-1, explicitly out of scope for now) diff --git a/docs/design/agenta-mobile/chat-headless-contract.md b/docs/design/agenta-mobile/chat-headless-contract.md new file mode 100644 index 0000000000..2c37ad201e --- /dev/null +++ b/docs/design/agenta-mobile/chat-headless-contract.md @@ -0,0 +1,133 @@ +# `@agenta/chat` headless core — dissection & contract + +Companion to [design.md](./design.md) (decision g). Grounded in a line-level dissection of the +current `AgentChatSlice` (2026-07-12). This is the working reference for WP3a. + +Legend: **E** = Engine (packaged/pure, reuse as-is) · **B** = Behavior (belongs in the headless +layer; today inline in components) · **P** = Presentation (skin owns). + +## 1. Classification of current responsibilities + +| Responsibility | Class | Where it lives today | +|---|---|---| +| Stream transport (stream↔batch negotiation, batch replay) | E | `assets/AgentChatTransport.ts` + `createNegotiatingFetch`, `agentChannelModeAtom` (`@agenta/playground`) | +| Request building (config/auth/references → body) | E | `buildAgentRequest` (`@agenta/playground`) | +| Turn capture, resume-after-approval, queue release gates | E | `@agenta/playground` `execution/*` (pure) | +| Render-hint map (`data-render` → `render.kind`) | E | `buildRenderMap`, `renderKindFor` (`execution/renderMap.ts`) | +| Transcript replay (records → `UIMessage[]`) | E | `assets/transcriptToMessages.ts` + `loadSession.ts` | +| Tool display resolution / value formatting | E | `assets/toolDisplay.ts`, `assets/toolFormat.ts` (pure registries) | +| Tool output summarization | B | inline in `ToolActivity.tsx:49-89` — pure logic trapped in a presentational file | +| Attachment validation / encoding | E | `assets/attachments.ts`, `assets/files.ts` (File-based, pure) | +| Attachment **state** shape | B/leak | antd `UploadFile` in `AgentConversation.tsx:358-370` and `state/sessionEphemera.ts:1,29` | +| Trace/usage extraction from metadata | E | `assets/trace.ts` | +| Session model + ephemera + expand state | E (leaks) | `state/sessions.ts`, `state/sessionEphemera.ts` (holds `UploadFile` + virtuoso `StateSnapshot`), `state/expandState.ts` | +| Turn grouping (active turn, lastUserIndex) | B | `AgentConversation.tsx:1677-1685` | +| Empty-turn collapsing predicates | B | `AgentConversation.tsx:167-179,1728` | +| Turn render model (tool folding, superseded-gate dedup, client-tool split) | B | `AgentMessage.tsx:329-416` | +| hasAnswer/noResponse/error derivation | B | `AgentMessage.tsx:270-312` | +| Client-tool dispatch registry | E→registry | `components/clientTools/{registry,meta}.tsx` (widgets are P) | +| Approval extraction (`getPendingApprovals`) | B | `ApprovalDock.tsx:33-45` | +| Approval body registry (by tool name) | E→registry | `components/approvals/registry.tsx` (bodies are P) | +| Queue orchestration | E (hook) | `hooks/useAgentChatQueue.ts` — the template for headless hooks | +| Model-key gate | E (hook) | `hooks/useAgentModelKeyStatus.ts` | +| Hydration sequencing (seed → skeleton vs hero → server hydrate → SWR revalidate) | B | `AgentConversation.tsx:571-603,891-913` | +| Session-status derivation + publish (error>awaiting>running>idle) | B | `AgentConversation.tsx:561,991-1004` | +| Error stamping onto turn | B | `AgentConversation.tsx:1025-1052` + `parseAgentRunError:191` | +| Persist-on-settle + expand-prune | B | `AgentConversation.tsx:1055-1082` | +| Self-commit / committed-revision handling | B | `AgentConversation.tsx:1090-1118` | +| Rewind orchestration | B (+E core, P confirm) | `AgentConversation.tsx:1637-1672`; pure scan in `assets/rewind.ts`; `modal.confirm` is P | +| Client-tool output settle → `addToolOutput` | B | `AgentConversation.tsx:616-635` | +| Elicitation parsing/validation/envelopes | E | `@agenta/shared/utils` (already extracted; only field rendering is antd) | +| Scroll engineering (SC-1..4, anchor, jump pill, virtuoso) | B, desktop-only | `AgentConversation.tsx:465-488,1140-1497` (~350 lines) — mobile uses native scroll | +| Bubble/avatar/toolbar, tool rows, approval chrome, queued chips, tray, empty/skeletons, markdown | P | `components/*`, `assets/markdown.tsx` (antd/x + Prism) | +| Right panel, turn inspector, onboarding hero, template strip | P, desktop/onboarding-only | already null-gated | + +`AgentConversation.tsx` is roughly **65% behavior / 35% presentation**; the behavior is almost +entirely app-agnostic. Onboarding, build mode, inspectors, and virtualization are all cleanly +gated (nullable context / atoms / env flags) — the mobile skin simply omits them. + +## 2. antd/desktop type leaks to neutralize + +1. **`UploadFile` as canonical attachment state** — `AgentConversation.tsx:358-370,499-516`, + `state/sessionEphemera.ts:1,29`, `ComposerAttachments.tsx:13,26`. Core moves to `File[]` (or + neutral `PendingAttachment{file, uid, name}`); `filesToParts`/`validateIncoming` are already + File-based. +2. **`Bubble` prop shaping** in `AgentMessage.tsx:630-648` + the loading-bubble + placeholders (`AgentConversation.tsx:1925-1930`) — stays in the desktop skin. +3. **antd-x `Actions` items as toolbar data** — `AgentMessage.tsx:561-618`. Contract uses neutral + `{key, label, icon, onClick}[]` action descriptors. +4. **react-virtuoso types in shared state** — `StateSnapshot` in `sessionEphemera.ts:2,21`, + `state/virtualization.ts`. Desktop-local; moves out of the shared store. + +Minor: `Modal.useModal` for the rewind confirm (core returns a `RewindPlan`, skin renders the +confirm), `App.useApp` toasts, antd `Form` inside `ElicitationWidget` (contract logic already in +`@agenta/shared/utils` — the cleanest existing example of the desired split). + +## 3. Headless hooks (Layer 2 API sketch) + +```ts +useAgentConversation({entityId, sessionId}): { + messages: UIMessage[] + status: "ready" | "submitted" | "streaming" | "error" + runStatus: "idle" | "running" | "awaiting" | "error" + error?: ParsedRunError + turns: TurnViewModel[] // pre-grouped: active turn, empty-collapse + send(input: {text: string; files?: File[]}): void // routes through the queue + stop(): void + regenerate(id: string): void + rewind(message: UIMessage): RewindPlan // {sideEffects[], confirm()} — skin renders confirm + isHydrating: boolean + isEmpty: boolean +} + +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} +useClientToolDispatch() +useAgentChatQueue(...) // exists — moves in +useAgentModelKeyStatus(...) // exists — moves in +useConversationScroll(ref, {messages, status}) // DESKTOP-ONLY opt-in +``` + +## 4. Skin slot contract (Layer 3) + +Every slot receives data + callbacks only — no antd/x types. Registry keys are the existing +ones: `renderKindFor(...)` → client-tool widget; tool name → approval body; +`resolveToolDisplay(rawName)` → label/source/kind; expand keys from `expandState.ts`. + +| Slot | Props (from behavior layer) | +|---|---| +| `MessageBubble` | `{role, variant, avatar, children, isError}` | +| `TextPart` | `{markdown}` | +| `ReasoningPart` | `{text, streaming, expanded, onToggle}` | +| `FilePart` | `{name, kind: FileKind, url, mediaType}` | +| `SourcesList` | `{sources: {url, title?}[]}` | +| `ToolActivityGroup` | `{parts, mode: "summary"\|"live"\|"detailed", summaryLabel, failedCount, expanded, onToggle, onViewTrace?}` | +| `ToolRow` | `{name, displayLabel, source?, status, midText, io?, expanded, onToggle}` | +| `ApprovalCard` | `{current, count, headline?, approveLabel?, Body?, onApprove, onDeny, onApproveAll, onViewTrace?}` | +| approvals registry entry | tool name → `{Body(input, entityId, fallback), headline?, approveLabel?}` | +| clientTool registry entry | `render.kind` → toolName → widget `{meta, settle, degradedEarlierInTurn}` | +| Elicitation fields | per schema kind (string/number/enum/date/boolean/array); engine parses, skin draws | +| `ErrorPart` | `{text, expanded, onToggle}` | +| `NoResponseNotice` | `{}` | +| `QueuedChip` / `QueuedList` | `{queued, onRemove, onClear}` | +| `Composer` | `{onSubmit(text), disabled, streaming, onStop, placeholder, initialMarkdown, onChange, onPasteFile, prefix, header, trailing}` | +| `AttachmentTray` | `{files, rejections, limits, onAdd, onRemove, onDismissRejections}` | +| `EmptyState` | `{entityId, onStart, firstRunPrompt?, canStart, onPrefill?}` | +| Skeletons | transcript / composer / conversation | +| `MessageToolbar` | `{actions: {key, label, icon, onClick}[]}` | +| `JumpToLatestPill` (desktop) | `{visible, onClick}` | +| `WorkingIndicator`, `MessageTimestamp`, `TraceMetrics` | `{}` / `{createdAt}` / `{traceId?, usage?}` | +| `DropOverlay`, `DockContainer` | layout slots | + +## 5. WP3a extraction order + +1. Neutralize the four type leaks (§2) — behavior-neutral, OSS keeps working. +2. Lift the pure blocks (turn render model, status/error derivation, tool summarization, + approval extraction, hydration) into `@agenta/chat`; OSS re-imports them immediately + (before/after fixture tests prove identical output). +3. Assemble `useAgentConversation` from the lifted blocks + engine (mobile-first consumer). +4. Generalize the three registries so skins register values against shared keys. +5. Desktop re-plumb of the remaining inline host (scroll opt-in, JSX assembly) = follow-up + track, and the contract's acceptance test. diff --git a/docs/design/agenta-mobile/design.md b/docs/design/agenta-mobile/design.md new file mode 100644 index 0000000000..e1702e9705 --- /dev/null +++ b/docs/design/agenta-mobile/design.md @@ -0,0 +1,402 @@ +# Agenta Mobile — Design + +**Status:** v3 approved · **wave-1 COMPLETE** (WP1 + WP0 + WP3a, on PR #5479 tip; WP3a via copy-extraction, OSS untouched) — see [README.md](./README.md) +**Date:** 2026-07-12 (backend revised 2026-07-18; WP0 + WP3a executed 2026-07-25) +**Owner:** Arda (FE + BE slice), no external backend dependency + +## Goal + +A minimal mobile web experience. It shows exactly two product surfaces: + +1. A **sessions list** — all chat sessions in the current project, searchable and filterable. +2. A **chat view** — enter or continue a session, with the same behavior and data flow as the + agent playground's chat (no build mode, no config panel, no inspectors). + +Everything else (apps, observability, settings, evaluations, …) stays desktop-only. + +Beyond the product surfaces, the mobile app is a **greenfield foundation**: a modern stack +(shadcn/ui, Tailwind, `motion`, no antd) whose components are built to be adopted back into the +OSS/EE apps step by step — mobile is the first consumer, not a fork. + +## Non-goals + +- No mobile versions of any dashboard/data-heavy page. +- No native app, no offline support, no push notifications (v1). +- No new chat *capabilities* — same transport, same message vocabulary, same HITL semantics as + the playground; only the render layer is new. +- No cross-project session aggregation (the sessions API is project-scoped by credential). +- No big-bang desktop migration — the playground adopts the new chat package as a follow-up + track, not in the mobile critical path. + +## Locked decisions + +| # | Decision | Choice | +|---|----------|--------| +| a | Foundation | **Separate `web/mobile` Next.js app** (Pages Router), not pages inside the existing app | +| b | Mounting | **Path mount `/m`** behind Traefik (`basePath: "/m"`); subdomain remains a later option for cloud | +| c | List scope | **Project-wide** sessions (not per-user "my sessions") | +| d | Continue-session config | **Latest config used in the session**, resolved from the session→agent linkage stamped at run time, with a trace-derived fallback for pre-stamping sessions | +| e | Backend work | Done in this workstream (new query endpoint + stamping); no dependency on the sessions-continuity track | +| f | Design system | **Greenfield: no antd.** shadcn/ui + Vercel AI Elements for the chat, Tailwind, `motion` for animation. Reused package components get refactored, not dragged along | +| g | Chat architecture | **Headless core + per-app skins.** All stateful/behavioral chat logic lives in a shared `@agenta/chat` package (hooks, view-models, registries — zero markup, zero styles); each app "dresses" it with its own presentational components: mobile in shadcn/AI Elements, the playground with its existing antd rendering re-plumbed onto the core (follow-up track) | + +## Why a separate app + +Findings that drove decision (a): + +- The existing `_app` wraps every page in ~10 providers plus a globally mounted drawer/modal + fleet with **no bundle-level opt-out** (auth routes are visually lighter, not bundle-lighter). + In-app mobile pages can never be light. +- The `@agenta/*` packages have **zero upward imports** into `web/oss`/`web/ee` — a new app can + consume the state/data packages cleanly. +- Auth is free: SuperTokens cookies are parent-domain scoped, so a same-host `/m` app shares the + session automatically (`SuperTokens.init()` with the same `appInfo`, `apiBasePath: "/api/auth"`). +- Traefik already has the pattern: the `services` router uses `PathPrefix` + stripprefix; a + `PathPrefix(`/m`)` router auto-wins over the web catch-all by rule length. + +The mobile app is **edition-agnostic**: built from packages only, one app serving OSS and EE/cloud. +(EE reuses OSS via tsconfig alias shadowing; the mobile app deliberately opts out of that scheme by +never importing app-layer code.) + +**Next version:** scaffold on the workspace's pinned Next 15.5 (Pages Router) to avoid dual-major +friction (pnpm override enforces `>=15.5.18 <16`, several packages pin `next <16` peers). The +mobile app becomes the pilot for the Next 16 upgrade as a follow-up task. + +## Design system (greenfield) + +No antd, no `@ant-design/x`, no Lexical in the mobile app. The stack: + +- **shadcn/ui** primitives (button, input, sheet/drawer, dialog, command, skeleton, …) installed + registry-style, themed via CSS variables. +- **Vercel AI Elements** (shadcn registry components for AI: Conversation, Message, Response, + Reasoning, Tool, PromptInput, …) as the base of the chat render layer — a natural fit since the + chat already runs on AI SDK v6 `useChat`. +- **Markdown:** Streamdown (the AI Elements `Response` renderer) replaces + `@ant-design/x-markdown` + Prism + KaTeX. +- **Composer:** AI Elements `PromptInput` (textarea-based, attachment-ready) replaces the Lexical + `RichChatInput` for mobile. Rich mentions/slash features are not needed in v1. +- **Motion:** the `motion` npm package for all transitions (already a repo dependency in OSS, so + no new design-system fragmentation). +- **Theming:** shadcn CSS variables bridged from the existing source of truth + (`web/oss/src/styles/theme/palette.ts` → generated tokens), light + dark from day one. +- **Tailwind:** the mobile app uses the latest Tailwind + shadcn toolchain, unconstrained. There + is **no cross-toolchain coupling**: the shared `@agenta/chat` package is headless (no markup, + no styles, no Tailwind), so the OSS app's Tailwind v3 and the mobile app's version never meet. + Each skin is styled entirely with its host app's toolchain. + +**Modernization path:** the playground re-plumbs its existing antd chat rendering onto the +headless core in a follow-up track (zero visual change, pure de-duplication), after which OSS +surfaces can swap skin components to shadcn one at a time. This is the "step by step" +modernization vehicle. + +## Gate and routing + +Replace the `NoMobilePageWrapper` banner mechanism with a redirect gate. + +**Detection:** server-side, in Next `middleware.ts` (new — none exists today), using +`sec-ch-ua-mobile` with a User-Agent regex fallback. No client-side viewport sniffing for the +gate (the current banner's ResizeObserver approach causes flash-of-desktop and hydrates the full +provider stack before deciding). + +**Rules:** + +- Mobile device → any desktop route: redirect into `/m`. + - Playground/session deep links map to their mobile equivalent: a link carrying a session + reference lands in that session's mobile chat; a bare playground/agent link lands on the + sessions list filtered to that agent. + - Any other link lands on the sessions list (`/m/w/{ws}/p/{proj}/sessions`), resolving + workspace/project the same way post-login redirect does today. +- Desktop device → `/m/...` link: reverse redirect to the desktop equivalent (mobile session URL → + playground with that session). +- 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. + +**URL scheme (mobile app, under basePath `/m`):** + +- `/auth` (+ `/auth/callback`) — mobile sign-in +- `/w/{workspace_id}/p/{project_id}/sessions` — list +- `/w/{workspace_id}/p/{project_id}/sessions/{session_id}` — chat +- `/` — resolve context (last-used workspace/project, same resolution as post-login) → redirect to list + +## Authentication + +The gate redirects mobile users away from the desktop app — including its `/auth` pages — so the +mobile app needs its own sign-in surface: + +- Built headless on `supertokens-web-js` (not the prebuilt React UI), rendered with shadcn forms. +- Auth-mode discovery via the existing `/auth/discover` endpoint (password vs OTP), matching the + desktop's `usesDynamicLoginMethods` behavior; SSO providers via redirect flow with a mobile + `/auth/callback` page that lands back in `/m`. +- Session already established on desktop → cookies are shared, no sign-in shown. +- Unauthenticated access to any `/m/*` route → `/m/auth?redirectTo=...`. +- Post-auth: same workspace/project resolution as the desktop's post-login redirect, landing on + the sessions list. Invite-acceptance and post-signup surveys stay desktop-only: a brand-new + user signing up on mobile gets the default workspace path; invited-user acceptance links keep + routing to desktop (documented exception in the middleware map). + +## Backend: session list + linkage + +> **REVISED 2026-07-18.** The sessions-extensions work that landed on this branch during the +> week of 2026-07-17 built most of what this section originally proposed, through a different +> (better) architecture. This section now describes the AS-BUILT state plus the small mobile +> residual. The original proposal (heartbeat `tags` stamping, `SessionSummary` projection) is +> superseded — do not implement it. + +### As built (by the sessions-extensions track) + +- **`POST /sessions/query`** exists on a new root `SessionsRootRouter` → `SessionsService` + (`api/oss/src/apis/fastapi/sessions/router.py` ~L1190+), returning `{count, sessions}` of + full `SessionStream` rows, with `Windowing` pagination, a `references` filter (agent/workflow + refs, resolved by joining through `session_turns.references`), and `include_ended` (soft- + deleted rows kept so durable history stays listable). Root ops also exist: delete, archive, + unarchive. +- **Title is a real header:** `session_streams.name`/`description` (`HeaderDBA`, migration 015), + with a rename endpoint (`PUT /sessions/streams/header`) already wired to the FE rename flow. +- **Agent linkage lives on `session_turns.references`** (new turns domain, migration 014; GIN + jsonb_path_ops): the runner appends a turn row per run with + `buildWorkflowReferences(runContext.workflow)` + `trace_id`. This satisfies decision (d) — + latest config used — via the latest turn row, and the dirty-config caveat holds unchanged + (draft runs carry no revision ref). +- **FE data layer exists:** Fern-regenerated `getSessionsClient().querySessions(...)` wrapped in + `@agenta/entities/session` (`querySessions`), plus an app-scoped list atom + (`projectSessionsQueryAtomFamily`) and a server-over-localStorage reconciler in the OSS + AgentChatSlice. `session_states` is gone (merged into streams); spans gained indexed + `session_id`/`user_id`/`agent_id` columns. + +Mobile consumes this as-is: project-wide list = `querySessions` without a `references` filter; +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) + +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 + infinite scroll. Add `updated_at` support to `apply_windowing` + switch the sessions query + to it. +2. **Title search** — `SessionQuery` has no free-text filter; `name` is now a real column, so + this is a plain `ilike`. +3. **References echo on list rows** — `/sessions/query` rows do not carry the session's agent + references, but the mobile list must label each row with its agent and resolve + continue-session without N per-session turn lookups. Hydrate latest-turn references onto the + response rows (the service already joins turns for filtering). +4. **Runtime-shape zod test for `querySessions`** — the wrapper + schema exist but the drift- + pinning unit test was never written (the false-green tsc class). +5. Optional, deferrable: liveness-flags filter on the root query (mobile can filter client-side + from the returned `flags`); Fern regen so `include_ended` stops being a runtime cast. + +## Frontend architecture + +### The chat: headless core + per-app skins + +A code dissection of the current slice grounds this split: roughly 65% of the 2,200-line +`AgentConversation.tsx` is app-agnostic orchestration trapped inline, the presentational layer +is cleanly antd/x, and three registries (clientTools, approvals, toolDisplay) already share one +`Record` + resolver pattern. Three layers: + +**Layer 1 — Engine (exists, reused as-is):** `buildAgentRequest`, stream/batch negotiation, +`agentMessageQueue`/HITL predicates, `agentShouldResumeAfterApproval`, +`buildRenderMap`/`renderKindFor` (all `@agenta/playground`); `@agenta/entities/session` +(records/streams/liveness); pure adapters currently in `assets/*` (`transcriptToMessages`, +`toolDisplay`, `toolFormat`, `rewind` core, `files`, `trace`); elicitation parsing/validation +(already in `@agenta/shared/utils`). The `useChat` (AI SDK v6) engine and message-part +vocabulary. + +**Layer 2 — Behavior (`@agenta/chat`, new headless package: hooks + view-models + registries, +zero markup/styles/Tailwind):** lifts the orchestration blocks currently inline in components: + +- `useAgentConversation({entityId, sessionId})` — the host: `useChat` wiring, transport memo, + hydration sequencing, queue wiring, approval extraction/response, session-status derivation + (`idle|running|awaiting|error`), error stamping, persist-on-settle, self-commit handling, + stop/rewind orchestration. Returns `{turns: TurnViewModel[], status, error, send, stop, + regenerate, rewind → RewindPlan, isHydrating, isEmpty}` — the skin renders, never orchestrates. +- `useTurnRenderModel` — the turn render model (tool-call folding, superseded-gate dedup, + client-tool split, empty-turn collapsing, hasAnswer/noResponse/error derivation) lifted from + `AgentMessage.tsx`. +- `useComposerAttachments` — File-based attachment state (validation, limits, encoding to parts). + This **kills the antd `UploadFile` leak** in `sessionEphemera`/composer state. +- `useSessionHydration`, `useApprovalDock`, `useClientToolDispatch`; `useAgentChatQueue` and + `useAgentModelKeyStatus` already exist as clean headless hooks and move in. +- `useConversationScroll` — the desktop scroll engineering (SC-1..4, anchor preservation, jump + pill, virtuoso variant) as a **desktop-only opt-in**; mobile uses native scroll and never + imports it. Virtuoso types move out of the shared ephemera store. +- The three registries, generalized: skins register component values against the same keys the + core resolves (`renderKindFor` → clientTool widget; tool name → approval body; + `resolveToolDisplay` → label/source/kind). Expand-state keys (`expandState.ts`) stay shared. + +**Layer 3 — Skins (per app, presentational only, props are data + callbacks, no antd/x types in +the contract):** the slot set a skin provides: `MessageBubble`, `TextPart` (markdown), +`ReasoningPart`, `FilePart`, `ToolActivityGroup`/`ToolRow`, `ApprovalCard` + approval-body +registry entries, elicitation field kinds, `ErrorPart`, `QueuedChip`, `Composer`, +`AttachmentTray`, `EmptyState`, skeletons, `MessageToolbar` (neutral action descriptors, not +antd-x `Actions` items), `WorkingIndicator`, timestamps/trace metrics. + +- **Mobile skin** lives in `web/mobile` (shadcn registry style), built on AI Elements + (Conversation, Message, Response/Streamdown, Tool, PromptInput) + `motion`. Elicitation v1 + covers core kinds (text, select, confirm) in shadcn form controls; exotic kinds (e.g. the cron + builder) render a generic fallback with a "finish on desktop" affordance. +- **Desktop skin** is the existing antd/x markup, re-plumbed onto the core hooks in the + follow-up track — zero visual change, pure de-duplication. Until then, OSS immediately + consumes the *lifted pure blocks* (turn render model, status/error derivation, tool + summarization, hydration hook) by importing them back from `@agenta/chat` — cheap, + behavior-neutral moves that prevent core/OSS drift — while its 2,200-line host keeps its + remaining inline JSX until the re-plumb. + +**Known neutralization work** (the four spots where antd/desktop types leak into logic today): +`UploadFile` as attachment state, `Bubble` prop shaping in `AgentMessage`, antd-x `Actions` +items as the toolbar data shape, react-virtuoso `StateSnapshot` in shared session ephemera. +Desktop-only surfaces (build mode, turn inspector, right panel, onboarding hero, template strip, +virtualization) are all already null-gated and simply absent from the mobile skin. + +**Behavior parity is by construction, not eyeballed:** engine + behavior are literally shared +code; skins are contract-tested per slot against recorded session fixtures. + +The full dissection (classification table with file:line references, the four type leaks, the +hook API sketch, the complete slot/prop contract, and the WP3a extraction order) lives in +[chat-headless-contract.md](./chat-headless-contract.md). + +### Component architecture and UX principles + +These are requirements, not suggestions, for every mobile surface: + +- **No slab components.** Pages are thin route shells; each feature is a folder of small + single-purpose components, one component per file. Indicatively: + + ```text + web/mobile/src/ + pages/ # thin route shells only (Pages Router) + middleware.ts # device gate (reverse direction) + features/ + auth/ # SignInForm, OtpForm, SsoButtons, AuthCallback, states/ + sessions/ # SessionListScreen, SessionCard, SessionSearchBar, + # AgentFilterChips, LivenessDot, states/ (Skeleton, Empty, Error) + chat/ # ChatScreen, ChatHeader, states/; conversation itself from @agenta/chat + project-drawer/ # ProjectDrawer, WorkspaceSwitcher, ProjectSwitcher, UserCard + components/ui/ # shadcn registry components + lib/ # motion presets, context resolution, state atoms, api glue + ``` + +- **States are designed, not defaulted.** Every screen and every data-bearing component defines + its loading, empty, error, and partial states as first-class sibling components (a `states/` + folder per feature). Skeletons mirror the final layout geometry so content replaces them + without shift. Errors carry a retry affordance and preserve entered state (a failed send never + loses the draft). +- **Motion design** with the `motion` package, defined once as shared presets in `lib/motion`: + - list → chat: shared-axis push (card → header continuity), back gesture/button reverses it; + - project drawer: spring-based sheet; + - skeleton → content: crossfade, no layout jump; + - message entrance/streaming: subtle, consistent with the playground's feel; + - all presets respect `prefers-reduced-motion`. +- **Seamless flow:** scroll position on the list is preserved across list↔chat navigation; + opening a session renders replayed history instantly from cache when available while records + hydrate; the composer is never blocked by hydration. + +### Mobile screens + +- **Sessions list:** server-driven from `POST /sessions/query` via a new `querySessions` wrapper + in `@agenta/entities/session` (Fern + zod). Search input, agent filter chips, liveness dots + (one project-wide `querySessionStreams(is_alive)` poll, as on desktop). Infinite scroll via + Windowing cursor. +- **Chat:** resolve session → stamped references → hydrate that revision into `workflowMolecule` + (required: the invoke URL is derived from molecule state) → records replay → mount the mobile + skin over `useAgentConversation`. Sending, HITL approvals, and elicitation work with playground + semantics by construction (shared behavior layer, full-history resend model unchanged). + Unresolvable references → read-only replay with a notice. +- **Project drawer:** hamburger → sheet with workspace/project switcher (thin fetchers over the + existing org/project endpoints — minimal mobile-local state, not the desktop app-layer slice), + user info, sign-out, "View desktop site". + +### Deployment + +- Third build target: `build-mobile` turbo filter, standalone output, own Dockerfile stage (or a + second `server.js` in the existing web image — decide in implementation by image-size impact). +- Compose service `web-mobile` with `traefik.http.routers.web-mobile.rule=PathPrefix(`/m`)`, + port 3000; ssl variant adds the `Host()` + certresolver labels like the existing web service. +- `entrypoint.sh` writes `__env.js` for the mobile public dir (same mechanism). + +## Skill and instruction infrastructure (before implementation) + +Set up the guidance layer first so every implementation session (Claude/Codex/Cursor) works to the +same standard, per the repo's instruction-organization model: + +- `web/mobile/AGENTS.md` (+ `CLAUDE.md` symlink): the app's conventions — no antd, states-first + components, one component per file, motion presets usage, shadcn registry workflow, token + bridge rules. +- Skills in `.agents/skills/` (symlinked into `.claude/skills/`): + - `mobile-shadcn-conventions` — how we install/extend registry components, theming via the + palette bridge, AI Elements usage patterns; + - `mobile-motion-patterns` — the shared presets, when to animate, reduced-motion rules; + - `mobile-app-structure` — feature-folder layout, states/ convention, data-flow rules + (entities wrappers only, no app-layer imports). +- Wire the existing plugin skills (Next.js, shadcn, React best practices) into the workflow by + referencing them from the AGENTS.md so sessions load them when working under `web/mobile`. +- Tooling in the same pass: eslint/prettier config for the new app (including an import-ban on + `antd`, `@ant-design/*`, and `@/oss/*`), CI typecheck/lint jobs. + +## Error handling + +- Middleware never hard-fails: on any detection ambiguity, fall through to the requested app. +- Sessions list: empty ("no sessions yet"), error (retry), and offline states are distinct + designed components. +- Chat: unresolvable references → read-only replay + notice; records fetch failure → retry + affordance; send failure preserves the draft and surfaces inline. +- Auth: discovery/SSO failures fall back to password form with an explanatory state. + +## Testing + +- **BE:** pytest coverage for `POST /sessions/query` (pagination, filters, permission gate) and + stamping (references/title present after an invoke) using the standard ephemeral-account + fixtures. +- **FE unit:** `@agenta/chat` behavior hooks are unit-testable without DOM (view-model in/out + against recorded session fixtures — turn render model, status/error derivation, queue/approval + flows); mobile skin gets per-slot render tests against the same fixtures; `querySessions` zod + schema pin (runtime-shape test — tsc goes false-green on wire drift). +- **Lifted-block neutrality:** the pure blocks OSS re-imports from `@agenta/chat` (turn render + model, status derivation, tool summarization, hydration) are covered by before/after fixture + tests proving identical output — the only OSS-facing change in the mobile critical path. +- **E2E:** Playwright mobile-viewport pass: gate redirect (both directions + opt-out cookie), + auth, list → open session → send a turn → approve a HITL request. +- **Playground regression (follow-up track only):** when the playground re-plumbs its skin onto + `useAgentConversation`, full chat-flow regression before merge. + +## Work packages + +| 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 | — | +| 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) | — | +| WP3b | Mobile chat skin: shadcn/AI Elements slot components + `motion`, per-slot fixture tests | WP1, WP3a | +| WP4 | Product pages: sessions list + chat screen, transitions, designed states | WP0, WP2, WP3b | +| WP5 | Gate: UA middleware both directions, deep-link mapping, opt-out cookies, retire `NoMobilePageWrapper` | WP1 (app must exist) | +| — | Follow-up track (separate): playground re-plumbs its antd skin onto the core (zero visual change), then swaps skin slots to shadcn incrementally; Next 16 pilot on `web/mobile` | WP3a | + +WP0, WP1, and WP3a are independent and parallelizable; WP2 and WP3b fan out from WP1. WP3a is +the intellectual core (the seam) but is mostly code *moves* of already-app-agnostic blocks; WP3b +is greenfield. The only OSS-facing change in the mobile critical path is the re-import of lifted +pure blocks, gated by before/after fixture tests. + +## Risks and coordination + +- **The sessions backend is under active development** (the sessions-extensions track landed the + turns domain, root sessions ops, and the states→streams merge the week of 2026-07-17, and more + is coming). The residual WP0 items are small additive changes to that track's surface — + coordinate before building so they land in its style and don't collide with in-flight work. + Re-audit `/sessions/query` and the FE list layer immediately before starting WP4. +- **Two design systems during transition** is deliberate and time-boxed by the adoption track: + new surfaces are shadcn-only; antd surfaces retire as packages get adopted. The line to hold: + no antd in `web/mobile` or `@agenta/chat`, ever (lint-enforced). +- **Contract design is the real risk in WP3a** — a slot contract that leaks presentation + assumptions (or misses a data need) forces churn on both skins. Mitigated by deriving the + contract from the dissection of the real code (the slot list above is grounded in what the antd + components actually consume), and by treating the desktop re-plumb as the contract's acceptance + test in the follow-up track. +- **Drift window until the desktop re-plumb** — the OSS host keeps some inline orchestration + (scroll, JSX assembly) until the follow-up track lands. Bounded by immediately re-importing the + lifted pure blocks (turn model, status/error, hydration) so the semantics that matter cannot + fork; the window should be kept short. +- **Schema drift class:** entities wrappers use local zod; runtime-shape tests are mandatory. +- **Old sessions** without stamped references degrade to read-only or latest-committed-revision + continue — acceptable for v1, self-heals as sessions accrue turns. 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 new file mode 100644 index 0000000000..da8e85aee8 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-12-wp0-sessions-query-and-stamping.md @@ -0,0 +1,253 @@ +# WP0 (REVISED 2026-07-18) — Sessions-list residual for mobile + +> **EXECUTED 2026-07-25 — ALL FOUR TASKS COMPLETE AND DUAL-REVIEWED** on +> `feat/agenta-mobile-wave-1` (9 commits `117cd6e4`…`ab7b09ad`; commit/review table in +> [../README.md](../README.md)). Notable execution deltas vs this plan text: R1 evolved through +> review into `coalesce(updated_at, created_at)` ordering (NULL-safe) plus a direction-matched +> id tiebreak fixed in the SHARED `apply_windowing` (latent dup/skip bug for all 18 call sites); +> R2 additionally trims the search term; R3 chose a core `SessionListItem` DTO (per api layering) +> and added the batch `latest_turn_per_session` DISTINCT-ON helper; R4's fixture was corrected in +> review to be server-faithful (no `status` key, UUID reference ids). + +> **RE-AUDITED 2026-07-25 against PR #5479 tip (`3c78268700`, storage-rework base) — ALL FOUR +> TASKS STILL NEEDED.** Verified: `apply_windowing` still lacks `updated_at`; no `search` +> anywhere; rows still carry no references (service explicitly declines denormalization, B3); +> no zod query test. Corrections to the task details below (authoritative over the older text): +> +> - **R1**: additionally surface `windowing` (cursor params) on the FE `querySessions` wrapper — +> the Fern type already carries it; the wrapper doesn't pass it. R1's value is higher now: +> archive/auto-unarchive bump `updated_at` without changing the uuid7 `id`, so creation-order +> sorting is visibly wrong for active sessions. +> - **R2**: `search` must thread through BOTH the core `SessionQuery` AND `SessionStreamQuery` +> (the service builds the latter for the DAO). FE param needs a Fern regen or a temporary +> typed cast. Coverage caveat: auto-title is FE-only (OSS `autoTitleSessionAtomFamily` → +> `setSessionHeader`), so `name` is NULL for sessions never touched by a titling client. +> - **R3**: `SessionTurnsDAO` has NO batch latest-turn helper (only per-session `latest_turn` and +> `latest_turn_per_harness_kind`) — add `latest_turn_per_session(session_ids)` using +> `DISTINCT ON (session_id) … ORDER BY session_id, turn_index DESC` so hydration stays one query. +> - **R4**: unchanged; also pin `archived_at` (new column) and, post-R3, `references`. +> +> New track capabilities recorded for wave-2 planning (not in this plan): archive/unarchive +> endpoints + `include_archived`/`include_ended` flags (mobile default list must filter +> `archived_at` client-side — wrapper defaults include_archived:true); auto-unarchive on new +> turn; delete-vs-kill semantics for swipe actions; lift the FE auto-title write path into +> `@agenta/entities` so mobile sessions get titles. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **REVISION NOTICE.** The original 2026-07-12 WP0 plan (heartbeat `tags` stamping, a +> `SessionSummary` projection on `SessionStreamsRouter`, an interim axios wrapper) is +> **superseded**: the sessions-extensions track landed on this branch during the week of +> 2026-07-17 and built the list/title/linkage capabilities through a different architecture. +> Do not implement the original plan. This revision contains only the mobile residual. + +**Goal:** Close the four remaining gaps between the as-built sessions surface and what the +mobile sessions list (WP4) needs: last-activity ordering on the server, title search, +agent-references echoed on list rows, and a runtime-shape zod test for `querySessions`. + +**Architecture (as built, verified 2026-07-18):** + +- `POST /sessions/query` lives on `SessionsRootRouter` → `SessionsService` + (`api/oss/src/apis/fastapi/sessions/router.py` ~L1190-1338; `api/oss/src/core/sessions/service.py`), + backed by `SessionStreamsDAO.query` (`api/oss/src/dbs/postgres/sessions/streams/dao.py` ~L118-154). + Filters: `references` (joined through `session_turns.references`, `service.py` ~L57-76) and + `include_ended`. Returns full `SessionStream` rows (title = `name` header from migration 015). +- Agent linkage: `session_turns.references` (migration 014, GIN jsonb_path_ops), stamped by the + runner's per-turn `appendSessionTurn` with `buildWorkflowReferences(runContext.workflow)` + + `trace_id` (`services/runner/src/engines/sandbox_agent.ts` ~L2255-2273). Heartbeats carry no tags. +- FE: Fern-backed `querySessions` in `web/packages/agenta-entities/src/session/api/api.ts` + (~L256-279) over `sessionsQueryResponseSchema` (`.../session/core/schema.ts` ~L90-122); + app-scoped list atom `projectSessionsQueryAtomFamily` + (`web/oss/src/components/AgentChatSlice/state/projectSessions.ts`) + server-over-localStorage + reconciler (`.../state/sessions.ts` ~L317+). + +**Tech Stack:** FastAPI/Pydantic v2/SQLAlchemy async + pytest (`cd api && uv run pytest`), ruff; +vitest for `@agenta/entities`. + +**Grounding rule:** the cited line numbers are from a 2026-07-18 audit of an actively developed +track — before each task, READ the cited files and re-anchor; if the surface moved again, +adapt in place rather than following stale line refs. Never include Claude/Anthropic/Co-Authored-By +in commit messages. + +--- + +## Task R1 — `updated_at` ordering + cursor on the sessions query + +The DAO currently windows on `id` (uuid7 ≈ creation order) and the FE compensates with a +client-side per-page sort — which breaks last-activity ordering across pages for infinite +scroll. `session_streams.updated_at` is heartbeat-fed last activity. + +**Files** +- Modify: `api/oss/src/dbs/postgres/shared/utils.py` (`apply_windowing` attribute resolution) +- Modify: `api/oss/src/dbs/postgres/sessions/streams/dao.py` (`query` — switch windowing attribute to `updated_at`) +- Test (create): `api/oss/tests/pytest/unit/sessions/test_query_sessions_windowing.py` + +**Steps** +- [ ] Write the failing statement-compilation test (no DB): + +```python +"""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. +""" + +from datetime import datetime, timezone + +import uuid_utils.compat as uuid +from sqlalchemy import select + +from oss.src.core.shared.dtos import Windowing +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE +from oss.src.dbs.postgres.shared.utils import apply_windowing + + +def _sql(stmt) -> str: + return str(stmt.compile()) + + +def test_orders_by_updated_at_descending_with_id_tiebreak(): + stmt = apply_windowing( + stmt=select(SessionStreamDBE), + DBE=SessionStreamDBE, + attribute="updated_at", + order="descending", + windowing=Windowing(limit=20), + ) + sql = _sql(stmt) + assert "ORDER BY session_streams.updated_at DESC, session_streams.id" in sql + + +def test_cursor_filters_ride_updated_at(): + windowing = Windowing( + newest=datetime(2026, 7, 17, tzinfo=timezone.utc), + next=uuid.uuid7(), + limit=20, + ) + stmt = apply_windowing( + stmt=select(SessionStreamDBE), + DBE=SessionStreamDBE, + attribute="updated_at", + order="descending", + windowing=windowing, + ) + sql = _sql(stmt) + assert "session_streams.updated_at <" in sql + assert "session_streams.id <" in sql + assert "session_streams.created_at" not in sql +``` + +- [ ] Run: `cd api && uv run pytest oss/tests/pytest/unit/sessions/test_query_sessions_windowing.py -v` — expect FAIL (attribute map falls back to `created_at`/`id`). +- [ ] Extend `apply_windowing`'s attribute-resolution map with `updated_at` (add + `updated_at_attribute = DBE.updated_at if getattr(DBE, "updated_at", None) else None`, register + it in the lookup dict, and when `attribute == "updated_at"` make the cursor `time_attribute` + ride it too). Read the current function first — do not disturb the existing `id`/`span_id`/ + `created_at`/`start_time` behavior; add a regression assertion for `created_at` if the file + changed since the audit. +- [ ] In `SessionStreamsDAO.query`, switch the windowing call to `attribute="updated_at", + order="descending"` and make the no-windowing fallback `ORDER BY updated_at DESC, id DESC`. + Check for other callers of this DAO method first (`grep -rn "streams_dao.query\|\.query(" api/oss/src/core/sessions/`) + — if the streams-scoped `/sessions/streams/query` route shares it, confirm the ordering change + is acceptable there too (it is a liveness index; ordering is not load-bearing) or thread an + `order_by` parameter instead. +- [ ] Run the new test (PASS) + the whole sessions unit suite: + `cd api && uv run pytest oss/tests/pytest/unit/sessions/ -v` — all green. +- [ ] Remove the now-redundant client-side sort note: in + `web/oss/src/components/AgentChatSlice/state/projectSessions.ts`, keep the `activity()` sort + (harmless belt-and-suspenders for mixed pages) but update its comment to note the server now + orders by `updated_at`. +- [ ] `cd api && ruff format . && ruff check --fix .`; commit: + `feat(api): order /sessions/query by last activity (updated_at windowing)` + +--- + +## Task R2 — Title search on `SessionQuery` + +`session_streams.name` is a real column now — search is a plain escaped `ilike`, not the JSONB +gymnastics the original plan needed. + +**Files** +- Modify: `api/oss/src/core/sessions/dtos.py` (`SessionQuery` — add `search: Optional[str] = None`) +- Modify: `api/oss/src/core/sessions/service.py` + the DAO path it uses for the stream query + (thread `search` down; apply `SessionStreamDBE.name.ilike(f"%{escaped}%", escape="\\")` with + `%`/`_`/`\\` escaped) +- Modify: `web/packages/agenta-entities/src/session/api/api.ts` (`querySessions` — add optional + `search` param, pass through) +- Test (create): `api/oss/tests/pytest/unit/sessions/test_query_sessions_search.py` (service-level + with a fake DAO asserting the filter is forwarded, plus a DAO statement-compilation test + asserting the `ilike` + escaping appears in SQL and absent when `search` is None) + +**Steps** +- [ ] Failing tests first (both assertions above), run, expect FAIL. +- [ ] Implement DTO + service/DAO threading + FE param. Match the track's style (read + `SessionQuery`'s current shape first — it may have grown since the audit). +- [ ] Tests PASS; sessions suite green; `ruff format`/`check`; FE `cd web && pnpm lint-fix` + + `pnpm turbo run types:check --filter=@agenta/entities`. +- [ ] Commit: `feat(api): free-text title search on /sessions/query` + +--- + +## Task R3 — Echo latest-turn references on session list rows + +List rows carry no agent linkage, but the mobile list must label each row with its agent and +resolve continue-session without N per-session turn lookups. The service already joins turns for +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) +- 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) +- Modify: `web/packages/agenta-entities/src/session/core/schema.ts` (extend the session row + schema with nullish `references`) +- Test: extend/service-level unit test with a fake turns DAO (rows with turns get references; + rows without turns get null; ONE batch call, not N) + +**Steps** +- [ ] Failing service test (assert the fake turns DAO is called once with all session ids and the + mapping lands per row), run, FAIL. +- [ ] Implement; tests PASS; suite green; ruff; FE schema updated + typecheck. +- [ ] Commit: `feat(api): include latest-turn references on /sessions/query rows` + +--- + +## Task R4 — Runtime-shape zod test for `querySessions` + +The wrapper + schema exist but the drift-pinning test was never written — this is the exact +false-green-tsc class that has bitten the session schemas twice before. + +**Files** +- Test (create): `web/packages/agenta-entities/tests/unit/session-query-schema.test.ts` + +**Steps** +- [ ] Mirror `tests/unit/session-record-schema.test.ts`'s structure: pin a realistic wire row + (id, session_id, `name`/`description`, flags nest, tags, timestamps — capture one from a live + `POST /sessions/query` response if a stack is running, else hand-author from the current + Pydantic model) through `sessionsQueryResponseSchema`, asserting the parsed shape the FE + consumes (title from `name`, flags normalized, timestamps present). Add a case for an + `include_ended` soft-deleted row (`deleted_at` set) and — after Task R3 — a row with + `references`. +- [ ] Run: `cd web/packages/agenta-entities && pnpm vitest run tests/unit/session-query-schema.test.ts` — PASS. +- [ ] Commit: `test(web): pin the /sessions/query wire shape in @agenta/entities` + +--- + +## Not in this plan + +- **Everything the sessions-extensions track already built** — the list endpoint, root + delete/archive/unarchive, the turns domain and runner turn-append stamping, the + `name`/`description` header + rename endpoint, the Fern regen, `querySessions`, + `projectSessionsQueryAtomFamily`, and the server-over-localStorage reconciler. +- **Liveness-flags filter on the root query** — mobile filters client-side from the returned + `flags` in v1; add server-side only if list sizes demand it. +- **Fern regen for `include_ended`** (currently a runtime cast in the wrapper) — ride the next + scheduled client regen; not worth a standalone one. +- **Mobile UI consumption** (WP4) and the project-wide list atom for mobile (the OSS atom is + app-scoped by design; mobile calls `querySessions` without `references`). +- **Trace-derived references fallback for pre-turns sessions** — sessions with no turn rows + degrade to read-only replay; self-heals as sessions accrue turns. 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 new file mode 100644 index 0000000000..8ed51ba583 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-12-wp1-mobile-foundation.md @@ -0,0 +1,1325 @@ +# Agenta Mobile — WP1 Foundation Implementation Plan + +> **EXECUTED 2026-07-18 — all 6 phases complete and dual-reviewed** on branch +> `feat/agenta-mobile-wave-1` (commits `1aa915fa`…`40cdf3c1`; see +> [../README.md](../README.md) for the commit/review table). Deviations from this plan as +> written: viewport meta lives in `_app` (Next warns against `_document` placement); Phase 2 +> shipped a placeholder `globals.css` so the app compiled pre-Phase-3; the token bridge gained a +> `--check` drift-guard mode chained into mobile `lint` and web `generate:tailwind-tokens` +> (review finding); the role map's dark `accent` is `scales.zinc[2].dark` and dark +> `destructive-foreground` is `componentsDark.Button.primaryColor` (review findings — NOT the +> values written below); eslint additionally bans `lexical`/`@lexical/*` and enforces +> `react-hooks/rules-of-hooks` (plan gaps found in review); the shadcn CLI emits the +> consolidated `radix-ui` dep. Phase 6's image build remains unverified (needs a gh CI run). + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal** +Stand up the foundation for the Agenta mobile web app per `docs/design/agenta-mobile/design.md` (WP1 row): (1) the skill/instruction infrastructure first, so every subsequent implementation session works to the same standard; (2) a `web/mobile` Next.js 15.5 Pages Router app mounted at `basePath: "/m"` with standalone output and a proof-of-life page; (3) the greenfield design-system foundation — Tailwind v4 + shadcn/ui CSS variables bridged from the workspace palette source of truth, plus a `motion` presets module; (4) lint tooling that hard-bans `antd`, `@ant-design/*`, and app-layer imports; (5) dev deployment wiring — a `web-mobile` compose service behind Traefik `PathPrefix(/m)` with the `__env.js` runtime-config mechanism. **No auth (WP2), no product pages (WP4), no device gate (WP5).** + +**Architecture** +- `web/mobile` is a new pnpm workspace member `@agenta/mobile`, sibling of `oss`/`ee`, built with its own turbo task graph (no package deps in WP1 — it consumes `@agenta/*` packages starting WP2+). It is edition-agnostic: one app serves OSS and EE. +- Mounting is path-based: the app is built with `basePath: "/m"`; Traefik routes `` PathPrefix(`/m`) `` to it (auto-wins over the web catch-all `` PathPrefix(`/`) `` by rule length; no stripprefix). +- Theming: `web/oss/src/styles/theme/palette.ts` stays the single source of truth. A small generator (`web/mobile/scripts/generate-shadcn-tokens.ts`, modeled on `web/scripts/generate-tailwind-tokens.ts`) maps palette roles → shadcn CSS variables for `:root` and `.dark`, emitting a committed `theme.generated.css`. Dark mode uses the same `agenta-theme` localStorage key + pre-paint init script as the desktop `_document`, so the theme follows the user across `/m` and the desktop app. +- Runtime config: `web/entrypoint.sh` (shared by dev and gh images) gains a guarded block that mirrors the generated `__env.js` into `mobile/public/`, served at `/m/__env.js` (basePath applies to public assets). +- Instruction layering follows the repo model (root `AGENTS.md` → nested `web/mobile/AGENTS.md` + `CLAUDE.md` symlink → skills in `.agents/skills/` symlinked into `.claude/skills/`). + +**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. + +--- + +## Phase 1 — Skill and instruction infrastructure + +### Task 1.1 — Create `web/mobile/AGENTS.md` + +- [ ] Create the directory and file `web/mobile/AGENTS.md` with exactly this content: + +````markdown +# Agenta Mobile (`web/mobile`) conventions + +Greenfield mobile web app served at `/m` (Next.js Pages Router, `basePath: "/m"`, +standalone output). This is the always-loaded instruction layer for work under +`web/mobile`. The general frontend conventions in `web/AGENTS.md` (Fern client, +state management, React practices) still apply EXCEPT where this file overrides +them — styling and import rules here are deliberately different. Design doc: +`docs/design/agenta-mobile/design.md`. + +## Hard rules (lint-enforced — see `eslint.config.mjs`) + +- **No antd. Ever.** No `antd`, `@ant-design/*`, `@ant-design/x`, no Lexical. + UI comes from shadcn/ui components in `src/components/ui/` and (for chat, + WP3b+) Vercel AI Elements. Icons come from `lucide-react`. +- **No app-layer imports.** Never import `@/oss/*`, `@agenta/oss`, or + `@agenta/ee`. Data and state come from the `@agenta/*` packages only + (`@agenta/entities`, `@agenta/shared`, `@agenta/chat` when it exists). +- **One component per file**, exported with a name matching the file name. +- **No slab components.** Pages under `src/pages/` are thin route shells only; + each feature is a folder of small single-purpose components. + +## Structure + +```text +web/mobile/src/ + pages/ # thin route shells only (Pages Router) + features// # SessionCard.tsx, SessionSearchBar.tsx, ... one component per file + states/ # Skeleton.tsx, Empty.tsx, Error.tsx — designed sibling states + components/ui/ # shadcn registry components (installed, then owned) + lib/ # motion presets, cn util, api glue — no JSX except tiny helpers + styles/ # globals.css + theme.generated.css (generated, committed) +``` + +## States are designed, not defaulted + +Every screen and every data-bearing component defines loading, empty, error +(and partial, where relevant) states as first-class sibling components in the +feature's `states/` folder. Skeletons mirror the final layout geometry so +content replaces them without shift. Errors carry a retry affordance and never +lose entered state (a failed send never loses the draft). + +## Motion + +All animation uses the `motion` package through the shared presets in +`src/lib/motion/presets.ts`, consumed via `useMotionPresets()` (reduced-motion +aware). Never hardcode durations, easings, or springs in components. Load the +`mobile-motion-patterns` skill before writing any animation code. + +## Styling and theming + +- Tailwind v4, CSS-first config in `src/styles/globals.css`. No + `tailwind.config.*` file exists on purpose. +- Style exclusively with the semantic tokens (`bg-background`, + `text-muted-foreground`, `border-border`, ...). Never hardcode hex/rgb values + in components. +- The color source of truth is `web/oss/src/styles/theme/palette.ts`, bridged + by `scripts/generate-shadcn-tokens.ts` into `src/styles/theme.generated.css` + (committed, never hand-edited). To change a color: edit `palette.ts` or the + role map in the script, then run `pnpm --filter @agenta/mobile generate:tokens`. +- Dark mode is class-based (`.dark` on ``), keyed off the same + `agenta-theme` localStorage value as the desktop app. + +## shadcn registry workflow + +Install or update registry components with `pnpm dlx shadcn@latest add ` +run from `web/mobile/`. Installed components live in `src/components/ui/` and +are owned code — adapt them, but keep diffs from upstream minimal and +token-driven. Load the `mobile-shadcn-conventions` skill for the full workflow. + +## Skills to load when working here + +- `mobile-app-structure` — feature folders, `states/` convention, data-flow rules. +- `mobile-shadcn-conventions` — registry workflow, theming bridge, AI Elements. +- `mobile-motion-patterns` — shared presets, when to animate, reduced motion. + +Also use the plugin skills when relevant: `vercel:nextjs` (Pages Router +specifics), `vercel:shadcn`, `vercel:react-best-practices`. + +## Commands (run from `web/`) + +- Dev: `pnpm dev-mobile` (→ http://localhost:3000/m) +- Build: `pnpm build-mobile` +- Lint / types: `pnpm --filter @agenta/mobile lint` / `pnpm --filter @agenta/mobile types:check` +- Token bridge: `pnpm --filter @agenta/mobile generate:tokens` +```` + +### Task 1.2 — Symlink `web/mobile/CLAUDE.md` + +- [ ] Create the symlink (same pattern as `web/CLAUDE.md → AGENTS.md`): + ```bash + ln -s AGENTS.md web/mobile/CLAUDE.md + ``` +- [ ] Verify: `ls -la web/mobile/CLAUDE.md` → shows `CLAUDE.md -> AGENTS.md`. + +### Task 1.3 — Create skill `mobile-app-structure` + +- [ ] Create `.agents/skills/mobile-app-structure/SKILL.md` with exactly this content (frontmatter format matches `.agents/skills/agenta-package-practices/SKILL.md`): + +````markdown +--- +name: mobile-app-structure +description: Feature-folder layout, states/ convention, and data-flow rules for the Agenta mobile app (web/mobile). Use when creating or moving files under web/mobile, deciding where a component lives, adding a new feature or screen, or wiring data into mobile components. +--- + +# Mobile app structure + +The source of truth for how code is organized in `web/mobile`. Load it before +creating any file there. + +## Layout + +```text +web/mobile/ + src/ + pages/ # Pages Router route shells ONLY — no logic, no layout JSX + features/ + / # e.g. sessions/, chat/, auth/, project-drawer/ + .tsx # one component per file, named export = file name + states/ # designed states for this feature + Skeleton.tsx # mirrors the final layout geometry (no shift on swap) + Empty.tsx # designed empty state with a call to action + Error.tsx # error + retry affordance; preserves user input + components/ui/ # shadcn registry components (see mobile-shadcn-conventions) + lib/ # cn util, motion presets, api glue, context resolution + styles/ # globals.css, theme.generated.css (generated) + scripts/ # generate-shadcn-tokens.ts (token bridge) +``` + +## Rules + +- **Pages are thin shells.** A page file resolves route params and renders one + feature screen component. Anything else belongs in `features/`. +- **One component per file.** No secondary exported components; small private + helpers inside a file are fine if they never leave it. +- **Every data-bearing component has designed states.** Before writing the + happy path, create the `states/` siblings (skeleton, empty, error). A screen + is not done if any of its states is a browser default or an unstyled string. +- **Data flow:** components get data via hooks from `@agenta/*` packages + (`@agenta/entities`, `@agenta/shared`, later `@agenta/chat`) or thin fetchers + in `lib/`. NEVER import `@/oss/*`, `@agenta/oss`, `@agenta/ee` — the mobile + app has zero app-layer imports (lint enforces this). +- **No provider fleet.** `_app.tsx` stays minimal; add a provider only when a + concrete feature needs it, scoped as narrowly as possible. + +## Adding a new feature (checklist) + +1. Create `src/features//` with the screen component. +2. Create `states/` siblings for every data-bearing component. +3. Add the route shell in `src/pages/` that renders the screen. +4. Use `useMotionPresets()` for any transitions (see mobile-motion-patterns). +5. `pnpm --filter @agenta/mobile lint && pnpm --filter @agenta/mobile types:check`. +```` + +### Task 1.4 — Create skill `mobile-shadcn-conventions` + +- [ ] Create `.agents/skills/mobile-shadcn-conventions/SKILL.md` with exactly this content: + +````markdown +--- +name: mobile-shadcn-conventions +description: How the Agenta mobile app (web/mobile) installs and extends shadcn/ui registry components, themes them via the palette token bridge, and uses Vercel AI Elements. Use when adding UI components under web/mobile, changing theme colors, editing components.json or globals.css, or building chat UI with AI Elements. +--- + +# Mobile shadcn conventions + +`web/mobile` uses shadcn/ui on Tailwind v4 with CSS variables. No antd, ever. + +## Installing registry components + +- Always install via the CLI from `web/mobile/`: + `pnpm dlx shadcn@latest add ` (e.g. `button`, `sheet`, `dialog`, + `command`, `skeleton`, `input`). +- Components land in `src/components/ui/` (aliases in `components.json`). They + are owned code: you may adapt them, but keep diffs minimal and expressed in + semantic tokens so upstream refreshes stay cheap. +- The CLI adds any peer deps (e.g. `@radix-ui/react-slot`) to + `web/mobile/package.json` — commit the manifest and `web/pnpm-lock.yaml` + changes together with the component. +- Never copy component source from the shadcn website by hand; the CLI resolves + the Tailwind v4 variant correctly. + +## Theming — the token bridge + +- shadcn variables (`--background`, `--primary`, ...) are NOT hand-maintained. + They are generated into `src/styles/theme.generated.css` from + `web/oss/src/styles/theme/palette.ts` by `scripts/generate-shadcn-tokens.ts`. +- To change a color: edit `palette.ts` (if the design-system value is wrong) or + the ROLE MAP in the script (if the mapping is wrong), then run + `pnpm --filter @agenta/mobile generate:tokens` and commit the regenerated CSS. +- Never edit `theme.generated.css` directly; never introduce raw hex values in + components — if a needed role is missing, extend the bridge. +- Dark mode is the `.dark` class on `` (`@custom-variant dark` in + `globals.css`), set pre-paint by the `_document.tsx` init script from the + shared `agenta-theme` localStorage key. Both themes must be checked for every + new surface. + +## Extending components + +- Wrap, don't fork: feature-specific variants live in `src/features/*` as thin + wrappers over `components/ui/*` primitives (cva variants where appropriate). +- Use the `cn` util from `@/lib/utils` for all class merging. + +## Vercel AI Elements (chat render layer, WP3b+) + +- AI Elements are shadcn registry components; install them the same way + (`pnpm dlx shadcn@latest add `), landing in + `src/components/ui/` / `src/components/ai-elements/` per the registry config. +- They are the base of the chat skin (Conversation, Message, Response, + Reasoning, Tool, PromptInput); behavior comes from `@agenta/chat` hooks — + never re-implement orchestration inside a rendered component. +```` + +### Task 1.5 — Create skill `mobile-motion-patterns` + +- [ ] Create `.agents/skills/mobile-motion-patterns/SKILL.md` with exactly this content: + +````markdown +--- +name: mobile-motion-patterns +description: Motion design rules for the Agenta mobile app (web/mobile) — the shared presets in src/lib/motion, when to animate, and reduced-motion requirements. Use when adding any animation or transition under web/mobile, animating navigation, sheets, skeletons, or list/chat surfaces. +--- + +# Mobile motion patterns + +All animation in `web/mobile` uses the `motion` package through the shared +presets module `src/lib/motion/presets.ts`. Components never define their own +durations, easings, or springs. + +## The presets + +Consume via the hook (reduced-motion aware — this is mandatory): + +```tsx +import {useMotionPresets} from "@/lib/motion/presets" + +const {sharedAxisPush, sheetSlideUp, crossfade, reduced} = useMotionPresets() +``` + +- **`sharedAxisPush`** — list → chat navigation (and any parent → child screen + push). Forward uses `custom={1}`, back uses `custom={-1}`; the back + gesture/button reverses the same preset. Wrap sibling screens in + ``. +- **`sheetSlideUp`** — spring-based bottom sheets (project drawer). Pair with a + `crossfade` scrim. +- **`crossfade`** — skeleton → content swaps. Skeleton and content must occupy + identical geometry so the fade causes zero layout shift. + +## Rules + +- **Animate navigation, containment, and state swaps — not decoration.** No + attention-seeking motion, no animating properties that trigger layout + (animate `transform`/`opacity` only). +- **Reduced motion is not optional.** `useMotionPresets()` returns instant + variants when `prefers-reduced-motion` is set; any animation built outside + the presets module must justify itself in review AND handle reduced motion + itself (prefer extending the presets module instead). +- **Message entrance/streaming** (WP3b+): subtle and consistent with the + playground's feel — entrance is a small fade/rise on the preset tokens; text + streaming is never per-character animated. +- New shared patterns go INTO `presets.ts` (one exported preset + doc comment), + not into a component file. +```` + +### Task 1.6 — Symlink skills into `.claude/skills/` and commit + +- [ ] Create the three symlinks (same relative-target pattern as the existing `agenta-package-practices` symlink): + ```bash + ln -s ../../.agents/skills/mobile-app-structure .claude/skills/mobile-app-structure + ln -s ../../.agents/skills/mobile-shadcn-conventions .claude/skills/mobile-shadcn-conventions + ln -s ../../.agents/skills/mobile-motion-patterns .claude/skills/mobile-motion-patterns + ``` +- [ ] Verify: `ls -la .claude/skills/ | grep mobile` → three lines, each `... -> ../../.agents/skills/mobile-...`. +- [ ] Verify frontmatter parses: `head -4 .agents/skills/mobile-app-structure/SKILL.md` → shows `---`, `name: mobile-app-structure`, `description: ...`. +- [ ] Commit: + ```bash + git add web/mobile/AGENTS.md web/mobile/CLAUDE.md .agents/skills/mobile-* .claude/skills/mobile-* + git commit -m "docs(mobile): add agent instructions and skills for the mobile app" + ``` + +--- + +## Phase 2 — `web/mobile` app scaffold and workspace wiring + +### Task 2.1 — Add `mobile` to the pnpm workspace + +- [ ] Edit `web/pnpm-workspace.yaml`: in the `packages:` list, add `- 'mobile'` after `- 'ee'`: + ```yaml + packages: + - 'oss' + - 'ee' + - 'mobile' + - 'tests' + - 'packages/*' + ``` + +### Task 2.2 — Wire `web/package.json` (workspaces + turbo filter scripts) + +- [ ] In `web/package.json`, add `"mobile"` to the `workspaces` array (after `"ee"`): + ```json + "workspaces": ["ee", "oss", "mobile", "tests", "variants-state", "packages/*"], + ``` +- [ ] In the same file's `scripts`, add (next to `build-oss`/`dev-oss`): + ```json + "build-mobile": "turbo run build --filter=@agenta/mobile", + "dev-mobile": "turbo run dev --filter=@agenta/mobile", + ``` + +### Task 2.3 — Add turbo tasks for `@agenta/mobile` + +- [ ] In `web/turbo.json`, add these three task entries inside `"tasks"` (place after `"@agenta/ee#build"`; the generic `dev` task already covers `pnpm dev-mobile`): + ```json + "@agenta/mobile#build": { + "inputs": [ + "src/**", + "public/**", + "next.config.ts", + "postcss.config.mjs", + "tsconfig.json", + "components.json" + ], + "outputs": [".next/**", "!.next/cache/**"], + "env": ["NODE_ENV", "NEXT_PUBLIC_*"] + }, + "@agenta/mobile#lint": { + "inputs": ["src/**/*.ts", "src/**/*.tsx", "eslint.config.*"], + "outputs": [] + }, + "@agenta/mobile#types:check": { + "inputs": ["src/**", "next.config.ts", "tsconfig.json"], + "outputs": [] + }, + ``` + Note: no `dependsOn` — WP1 mobile has zero `@agenta/*` package deps; add them when WP2+ introduces package imports. + +### Task 2.4 — Create `web/mobile/package.json` + +- [ ] Create `web/mobile/package.json` with exactly this content (Next pinned to the workspace `15.5.18`; `lucide-react`/`motion`/`typescript`/`@types/*` match `web/oss/package.json` versions so pnpm dedupes; the `build` script mirrors `@agenta/oss`'s standalone copy step — with `outputFileTracingRoot: ".."` the standalone server lands at `.next/standalone/mobile/server.js`): + ```json + { + "name": "@agenta/mobile", + "version": "0.1.0", + "private": true, + "engines": { + "node": "24.x" + }, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build && cp -r public/. .next/standalone/mobile/public && cp -r .next/static .next/standalone/mobile/.next", + "start": "next start", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "format": "prettier --check .", + "format-fix": "prettier --write .", + "types:check": "tsc", + "generate:tokens": "tsx scripts/generate-shadcn-tokens.ts" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.479.0", + "motion": "^12.0.0", + "next": "15.5.18", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/postcss": "^4.1.0", + "@types/node": "^20.19.20", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.7.4", + "tailwindcss": "^4.1.0", + "tsx": "^4.22.4", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0" + } + } + ``` + +### Task 2.5 — Create `tsconfig.json`, `next-env.d.ts`, `.gitignore` + +- [ ] Create `web/mobile/tsconfig.json` (mirrors `web/oss/tsconfig.json` conventions; the `@/*` path is what `components.json` aliases and the shadcn CLI resolve against): + ```json + { + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] + } + ``` +- [ ] Create `web/mobile/next-env.d.ts` (Next regenerates this; committing it keeps `tsc` green before first dev run): + ```ts + /// + /// + + // NOTE: This file should not be edited + // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. + ``` +- [ ] Create `web/mobile/.gitignore`: + ```gitignore + .next/ + .turbo/ + tsconfig.tsbuildinfo + # runtime config written by web/entrypoint.sh (dev mounts this dir from the host) + public/__env.js + ``` + +### Task 2.6 — Create `next.config.ts` and `postcss.config.mjs` + +- [ ] Create `web/mobile/next.config.ts`: + ```ts + import path from "path" + + import type {NextConfig} from "next" + + const isDevelopment = process.env.NODE_ENV === "development" + + const nextConfig: NextConfig = { + // Path mount: Traefik routes PathPrefix(`/m`) here with NO stripprefix — + // the app itself owns the prefix (assets, links, and routes all under /m). + basePath: "/m", + output: "standalone", + reactStrictMode: true, + pageExtensions: ["ts", "tsx"], + productionBrowserSourceMaps: true, + // Workspace root, so standalone output nests as .next/standalone/mobile/ + // (same pattern as web/oss). + outputFileTracingRoot: path.resolve(__dirname, ".."), + // Same policy as web/oss: lint/type gates run as dedicated turbo tasks, + // not inside `next build`. + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + async headers() { + return [ + { + // `__env.js` is per-deployment RUNTIME config (regenerated on each + // container start by web/entrypoint.sh), not an immutable build + // asset — force it uncacheable. `source` is basePath-relative, + // so this matches /m/__env.js. Mirrors web/oss/next.config.ts. + source: "/__env.js", + headers: [{key: "Cache-Control", value: "no-store, must-revalidate"}], + }, + ] + }, + ...(isDevelopment + ? { + turbopack: { + root: path.resolve(__dirname, ".."), + }, + } + : {}), + } + + export default nextConfig + ``` +- [ ] Create `web/mobile/postcss.config.mjs` (Tailwind v4 uses its own PostCSS plugin; no autoprefixer needed): + ```js + /** @type {import('postcss-load-config').Config} */ + const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, + } + + export default config + ``` + +### Task 2.7 — Create `_document.tsx`, `_app.tsx`, proof-of-life index page, and `public/` + +- [ ] Create `web/mobile/src/pages/_document.tsx` (theme init script is byte-identical to `web/oss/src/pages/_document.tsx` so the theme choice is shared across apps; the `__env.js` script src must carry the basePath explicitly — `next/script` does not auto-prefix): + ```tsx + import {Html, Head, Main, NextScript} from "next/document" + import Script from "next/script" + + // Runs synchronously before paint to apply the persisted theme, preventing a + // flash of the wrong theme on load. Same localStorage key as the desktop app + // ("agenta-theme", JSON-encoded by usehooks-ts, default "system") so the + // user's theme follows them between /m and the desktop app. + const themeInitScript = `(function(){try{var r=localStorage.getItem('agenta-theme');var m=r?(r.charAt(0)==='"'?JSON.parse(r):r):'system';var d=m==='dark'||(m==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d){document.documentElement.classList.add('dark');document.documentElement.style.colorScheme='dark';}}catch(e){}})();` + + export default function Document() { + return ( + + +