diff --git a/.github/workflows/17-check-mobile.yml b/.github/workflows/17-check-mobile.yml new file mode 100644 index 0000000000..3656c84073 --- /dev/null +++ b/.github/workflows/17-check-mobile.yml @@ -0,0 +1,271 @@ +name: "17 - check mobile" + +on: + pull_request: + # `ready_for_review` is not a default activity type. Both entry jobs skip while a PR is a + # draft, so without it the commits pushed during draft are never checked — marking the PR + # ready would leave an unvalidated head. + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'web/mobile/**' + - 'web/entrypoint.sh' + - 'web/docker/**' + - 'web/package.json' + - 'web/pnpm-lock.yaml' + - 'web/pnpm-workspace.yaml' + - 'web/turbo.json' + - 'web/patches/**' + - '.github/workflows/17-check-mobile.yml' + workflow_dispatch: + inputs: + push: + description: "Push the built image to GHCR" + type: boolean + default: false + image_tag: + description: "Image tag; leave empty for manual-" + type: string + default: "" + push_latest: + description: "Also tag :latest (requires push)" + type: boolean + default: false + +# Least privilege at the top; the two jobs that log in to the registry raise it themselves. +permissions: + contents: read + +concurrency: + group: check-mobile-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + typecheck: + name: Mobile types + if: github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: web/package.json + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install dependencies + run: cd web && pnpm install --frozen-lockfile + + - name: Typecheck @agenta/mobile + run: cd web && pnpm turbo run types:check --filter=@agenta/mobile --filter=@agenta/chat + + prepare: + name: prepare + if: github.event_name == 'workflow_dispatch' || !github.event.pull_request.draft + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + cache_scope: ${{ steps.meta.outputs.cache_scope }} + push: ${{ steps.meta.outputs.push }} + steps: + - uses: actions/checkout@v6 + + - name: Determine build metadata + id: meta + env: + # Never interpolate a dispatch input straight into the script: the expression is + # substituted before bash parses it, so the value could close the quote and run + # commands. Through env it is only ever data. + PR: ${{ github.event.pull_request.number }} + INPUT_TAG: ${{ inputs.image_tag }} + INPUT_PUSH: ${{ inputs.push }} + run: | + PR="$PR" + INPUT_TAG="$INPUT_TAG" + SHA="$(git rev-parse --short HEAD)" + + if [ -n "$PR" ]; then + TAG="pr-${PR}-${SHA}" + PUSH=false + CACHE_SCOPE="pr-${PR}" + else + TAG="${INPUT_TAG:-manual-${SHA}}" + PUSH="$INPUT_PUSH" + REF="$(printf "%s" "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')" + REF="${REF#-}" + REF="${REF%-}" + CACHE_SCOPE="${REF:-manual}" + fi + + CACHE_SCOPE="${CACHE_SCOPE:0:80}" + + # `image_tag` is interpolated into later `run:` blocks, so it must be a Docker tag and + # nothing else. `case` matches the WHOLE value, including any newline — a line-based + # check (grep) would pass "good\nimage_tag=evil", which forges a second step output. + case "$TAG" in + "" | [!A-Za-z0-9_]* | *[!A-Za-z0-9_.-]*) + echo "::error::image_tag is not a valid Docker tag" + exit 1 + ;; + esac + if [ "${#TAG}" -gt 128 ]; then + echo "::error::image_tag is longer than 128 characters" + exit 1 + fi + + echo "image_tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "cache_scope=${CACHE_SCOPE}" >> "$GITHUB_OUTPUT" + echo "push=${PUSH}" >> "$GITHUB_OUTPUT" + + build-image: + permissions: + contents: read + packages: write + name: build-image + # Also `typecheck`, not just `prepare`: `web/mobile/next.config.ts` sets + # `typescript.ignoreBuildErrors: true`, so the build and the smoke test can both pass while + # the dedicated typecheck fails. Without this, a dispatch with `push=true` publishes that + # tag and `merge-manifests` can move `latest` onto it. + needs: [prepare, typecheck] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + arch: + - amd64 + - arm64 + include: + - arch: amd64 + runner: ubuntu-24.04 + platform: linux/amd64 + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + steps: + - uses: actions/checkout@v6 + + - name: Log in to GHCR + if: needs.prepare.outputs.push == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build per-arch image + uses: docker/build-push-action@v6 + with: + context: web + file: web/mobile/docker/Dockerfile.gh + push: ${{ needs.prepare.outputs.push == 'true' }} + load: ${{ needs.prepare.outputs.push != 'true' }} + platforms: ${{ matrix.platform }} + # Mirror 42-railway-build: no provenance/SBOM attestations (they add + # unknown/unknown manifest entries we don't consume). + provenance: false + sbom: false + tags: ghcr.io/agenta-ai/agenta-web-mobile:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }} + # Per-arch cache refs so amd64 and arm64 don't clobber each other. + # cache-to is push-gated: PR runs (incl. forks) have no registry write. + cache-from: | + type=registry,ref=ghcr.io/agenta-ai/agenta-web-mobile:buildcache-shared-${{ matrix.arch }} + type=registry,ref=ghcr.io/agenta-ai/agenta-web-mobile:buildcache-${{ needs.prepare.outputs.cache_scope }}-${{ matrix.arch }} + cache-to: ${{ needs.prepare.outputs.push == 'true' && format('type=registry,ref=ghcr.io/agenta-ai/agenta-web-mobile:buildcache-shared-{0},mode=max', matrix.arch) || '' }} + + - name: Smoke-test the image serves /m + run: | + IMAGE="ghcr.io/agenta-ai/agenta-web-mobile:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }}" + if [ "${{ needs.prepare.outputs.push }}" = "true" ]; then + docker pull "$IMAGE" + fi + docker run -d --name mobile-smoke -p 3000:3000 "$IMAGE" + # Every request is bounded. A container that accepts the connection but never answers + # would otherwise hang until the job timeout, with no diagnostic. + CURL_TIMEOUTS="--connect-timeout 5 --max-time 15" + for i in $(seq 1 30); do + if curl -sf $CURL_TIMEOUTS http://127.0.0.1:3000/m >/dev/null; then break; fi + sleep 1 + done + # 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 + # `|| true` on purpose: under `set -e` a transport failure here would exit the step + # before `docker logs` runs, losing the only evidence of what went wrong. Turn it + # into a sentinel status so the mismatch branch reports and dumps the log. + got="$(curl -s $CURL_TIMEOUTS -o /dev/null -w '%{http_code}' "http://127.0.0.1:3000${path}" || true)" + [ -n "$got" ] || got="no-response" + 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 + run: | + IMAGE="ghcr.io/agenta-ai/agenta-web-mobile:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }}" + USER=$(docker inspect --format='{{.Config.User}}' "$IMAGE") + if [ -z "$USER" ] || [ "$USER" = "root" ] || [ "$USER" = "0" ]; then + echo "::error::agenta-web-mobile (${{ matrix.arch }}) runs as root (User='${USER}')" + exit 1 + fi + echo "PASS: runs as User='${USER}'" + + - name: Summary + run: | + IMAGE="ghcr.io/agenta-ai/agenta-web-mobile:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }}" + SIZE="$(docker image ls --format '{{.Size}}' "$IMAGE" | head -1)" + echo "- Built \`agenta-web-mobile:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }}\` (${SIZE}), smoke on /m passed" >> "$GITHUB_STEP_SUMMARY" + + merge-manifests: + permissions: + contents: read + packages: write + name: merge-manifest + needs: [prepare, build-image] + if: needs.prepare.outputs.push == 'true' + runs-on: ubuntu-latest + steps: + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Stitch per-arch tags into manifest list + env: + IMAGE: ghcr.io/agenta-ai/agenta-web-mobile + TAG: ${{ needs.prepare.outputs.image_tag }} + PUSH_LATEST: ${{ inputs.push_latest }} + run: | + set -euo pipefail + docker buildx imagetools create \ + -t "${IMAGE}:${TAG}" \ + "${IMAGE}:${TAG}-amd64" \ + "${IMAGE}:${TAG}-arm64" + if [ "${PUSH_LATEST}" = "true" ]; then + docker buildx imagetools create \ + -t "${IMAGE}:latest" \ + "${IMAGE}:${TAG}-amd64" \ + "${IMAGE}:${TAG}-arm64" + fi + echo "- Merged \`agenta-web-mobile:${TAG}\` (linux/amd64 + linux/arm64; latest=${PUSH_LATEST})" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index e46a345268..924ec38626 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ services/runner/tests/results/ !.agents/skills/write-pr-description/ !.agents/skills/write-social-announcement/ !.agents/skills/write-template-playbooks/ +!.agents/skills/mobile-app-structure/ +!.agents/skills/mobile-shadcn-conventions/ +!.agents/skills/mobile-motion-patterns/ !.claude/ .claude/* !.claude/skills/ @@ -124,6 +127,9 @@ services/runner/tests/results/ !.claude/skills/resolve-findings !.claude/skills/sync-findings !.claude/skills/test-codebase +!.claude/skills/mobile-app-structure +!.claude/skills/mobile-shadcn-conventions +!.claude/skills/mobile-motion-patterns # Temporary SDK copies created by run.sh --local api/sdks diff --git a/docs/design/agenta-mobile/README.md b/docs/design/agenta-mobile/README.md new file mode 100644 index 0000000000..66c1840b21 --- /dev/null +++ b/docs/design/agenta-mobile/README.md @@ -0,0 +1,211 @@ +# Agenta Mobile — project index & handoff + +**Status: WAVE-1 COMPLETE (2026-07-25)** — WP1 (mobile foundation), WP0 (sessions backend +residual), and WP3a (`@agenta/chat` headless package, copy-extraction mode with OSS +byte-untouched) all built and dual-reviewed on `feat/agenta-mobile-wave-1`, which sits on +**PR #5479's tip (`3c78268700`)**. 35 commits, final coherence sweep clean, not pushed. +Next: wave-2 planning (WP2 auth/drawer → WP3b mobile chat skin → WP4 pages → WP5 gate). +This file is the entry point: what exists, what's done, what's next. + +## What this is + +A minimal mobile web experience: a **sessions list** (searchable/filterable) + a **chat view** +with playground-identical behavior — nothing else. Served as a separate greenfield Next.js app +at `/m`, doubling as the modernization vehicle for the OSS/EE frontend (shadcn instead of antd, +headless chat core shared between desktop and mobile skins). + +### Locked decisions (see design.md for rationale) + +| # | Decision | +|---|----------| +| a | Separate `web/mobile` app (Pages Router), not pages in the existing app | +| b | Path mount `/m` behind Traefik (`basePath: "/m"`, no stripprefix) | +| c | Session list is project-wide (not per-user) | +| d | Continue-session uses the latest config used (latest `session_turns.references`) | +| e | Backend residual owned here; no external dependency | +| f | Greenfield design system: shadcn/ui + AI Elements + Tailwind v4 + `motion`; no antd/Lexical, lint-enforced | +| g | Chat = headless core (`@agenta/chat`: hooks/view-models/registries, zero markup) + per-app skins | + +## Document map + +| Doc | What it is | +|-----|-----------| +| [design.md](./design.md) | The spec (v3). Backend section REVISED 2026-07-18 to the as-built sessions-extensions architecture | +| [chat-headless-contract.md](./chat-headless-contract.md) | Line-level dissection of the chat slice: engine/behavior/presentation classification, the four type leaks, hook APIs, slot contract, WP3a extraction order | +| [plans/2026-07-12-wp1-mobile-foundation.md](./plans/2026-07-12-wp1-mobile-foundation.md) | WP1 plan — **EXECUTED** (see banner in file for deviations) | +| [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 | + +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. + +## Execution state + +Branch **`feat/agenta-mobile-wave-1`** (off `feat/sessions-continuity-fixes`, worktree +`.claude/worktrees/big-agents-sessions`). Not pushed. Executed subagent-driven: every task got a +fresh implementer + spec-compliance review + code-quality review; fix rounds were re-reviewed. + +### WP1 — foundation (COMPLETE, 8 commits) + +| Commit | Content | Review outcome | +|--------|---------|----------------| +| `d0f5c1c0` | `web/mobile/AGENTS.md` + CLAUDE.md symlink; 3 skills (`mobile-app-structure`, `mobile-shadcn-conventions`, `mobile-motion-patterns`) + `.claude/skills` symlinks | spec ✅ (byte-identical to plan) | +| `c914e333` | `@agenta/mobile` scaffold: workspace/turbo wiring, Next 15.5.18, `basePath /m`, standalone output, minimal `_app`/`_document` (shared `agenta-theme` init script), proof-of-life page. Live-verified: `/m`→200, `/`→404 | spec ✅ quality ✅ | +| `629d6e51` | shadcn (components.json/cn/globals.css Tailwind-v4 CSS-first), palette→shadcn token bridge (`scripts/generate-shadcn-tokens.ts` → committed `theme.generated.css`), motion presets, CLI-installed button+skeleton. Tokens verified in compiled CSS | spec ✅ quality **fix-first** | +| `b53094b5` | Review fixes: dark `--accent` olive→`#2a2a2a` (zinc[2]), token **drift guard** (`--check` mode, chained into mobile `lint` AND web `generate:tailwind-tokens`; tamper-tested), `--destructive-foreground` dark→`#141414`, new-token warning, `useMemo`, transitions exposed reduced-aware | re-review ✅ | +| `342cacef` | `eslint.config.mjs`: flat config + hard bans (antd/`@ant-design/*`/app-layer), canary-proven; first full `build-mobile` + standalone check | spec ✅ (byte-identical) quality: 2 plan gaps | +| `54a404c4` | Plan-gap fixes: `react-hooks/rules-of-hooks: error` (+`exhaustive-deps: warn`), lexical ban. All 4 canaries proven | ✅ | +| `bd4671ad` | Dev deployment: entrypoint `__env.js` mirror (guarded), mobile COPY layers in both dev Dockerfiles, `web-mobile` compose service (OSS+EE) behind Traefik `` PathPrefix(`/m`) `` + cache volumes. `docker compose config` validated both editions | spec ✅ quality ✅ | +| `b44cbee4` | Prod `web/mobile/docker/Dockerfile.gh` (mirrors oss gh image; baked `CMD` justified — no compose gh wiring yet). **Image build deferred to a gh CI run** | ✅ | + +What works right now: `cd web && pnpm dev-mobile` → http://localhost:3000/m renders the themed +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). + +### WP0 residual — COMPLETE (2026-07-25, 9 commits, all dual-reviewed) + +Executed after the rebase onto PR #5479, re-audited first (all four tasks were still needed; the +plan banner carries the corrections). Commits, in order: + +| Commit | Content | +|--------|---------| +| `117cd6e4` | R1: `apply_windowing` learns `updated_at`; sessions query + FE wrapper cursor params | +| `11b722f8` | R1 fix: order/cursor ride `coalesce(updated_at, created_at)` (NULL-safe; matches FE `activity()`) | +| `c2ad2393` | R1 fix: **direction-matched id tiebreak in shared windowing** — fixes a latent dup/skip pagination bug for ALL 18 `apply_windowing` call sites (cursor predicates always assumed DESC) | +| `dc7d8499` | R2: `search` (escaped `ilike` on `session_streams.name`) threaded request→core→service→stream-query→DAO; FE param w/ TODO(fern-regen) cast | +| `0afb4125` | R2 polish: trim search term (whitespace ⇒ no filter), tightened tests | +| `c757ca9d` | R3: latest-turn `references` batch-hydrated onto `/sessions/query` rows (new `latest_turn_per_session` DISTINCT-ON helper; core `SessionListItem` DTO; FE `sessionReferenceSchema`) | +| `a159baae` | R3 polish: `.get()` enrichment loop, full-field test pin, IN-list docstring | +| `0c906849` | R4: `session-query-schema.test.ts` — 8-case zod wire pin (stamped/minimal/ended/archived/no-refs/envelope/2 drift-guards) | +| `ab7b09ad` | R4 fix: fixture made server-faithful (phantom `status` removed; reference id → UUID string) | + +The mobile list's backend needs are now fully served: true last-activity ordering with working +keyset pagination, title search, agent-labeled rows (references echoed, one batch query per +page), and a drift-pinned wire contract. Suites at close: api sessions 152 passed; +`@agenta/entities` 928 passed; entities typecheck clean. + +### WP3a `@agenta/chat` — COMPLETE (2026-07-25, COPY-EXTRACTION mode, 18 commits, all reviewed) + +**Mid-execution strategy pivot (Arda's direction):** too many in-flight FE PRs + a local +antd→shadcn branch overlap the OSS `AgentChatSlice`, so the original move-extraction (same-commit +OSS re-imports) was replaced by **copy-extraction: the package copies behavior verbatim +(byte-parity-audited, copy-headers with declared adaptations) and OSS is byte-untouched** — the +branch's only non-package files are the T1 wiring five. Three early move-mode commits were +rewound off the branch after the pivot. The desktop re-plumb (OSS consuming the package, +deleting its local copies) is a follow-up PR sequenced after the FE queue drains. + +| Commits | Content | +|---------|---------| +| `3984282642` | T1 scaffold: package + forbidden-deps contract test + workspace/turbo wiring | +| `1d0721e9` `21dee91e` `259e7de1` | C1: neutral types (PendingAttachment, MessageAction) + parts predicates/toolIdentity/partToolName + parseAgentRunError | +| `a1dffc2e` `367af807` `7c369789` `6537a379` `88e4cd88` | C2/C3: toolSummary, approvals, turnStatus, renderModel (predicate-injected), grouping + sessionStatus | +| `2d622f48` `ccc77f4a` `eda687d7` | C4a: asset chain (toolFormat/trace/attachmentRules/files/rewind; transcriptToMessages/loadSession/transport) + state stores (expandState; sessionEphemera — PendingAttachment-based, windowed-list map omitted) | +| `1230499b` `707910d1` `cd341019` | C4b: queue+model-key hooks (verbatim); useComposerAttachments/useApprovalDock; **useAgentConversation** host (+ sessionMessages store copy, turnViewModel with per-mount identity cache). 5 real integration tests: real useChat + real transport over mocked SSE | +| `f9a6155d` | C5: skin registration (registerChatSkin) + resolvers mirroring OSS precedence/fallback chains | +| `f3a50772` `032b125b` | Review polish (updater purity, kind-override header note) + mobile-skills gitignore allowlist | + +Package at close: **190 tests / 27 files green**, `pnpm run check` clean, banned-literals grep +empty (no antd/ant-design/virtuoso/lexical/app-layer strings anywhere in src/ incl. comments), +zero-OSS-edit gate proven. The C4/C5 review ran FULL byte-parity diffs (copies differ from OSS +only by headers) and found no undeclared behavioral divergence; the localStorage message key, +quota eviction, throttle, and persist ordering are identical to OSS — no history fork. + +### ⚠️ WP3b (mobile skin) gotchas — from the C4/C5 review, read before building the skin + +1. **Mount `useAgentConversation` with `key={sessionId}`** — initialMessages, stopped-state, + hydration flags, and the executed-identity cache seed once per mount; prop-switching the + sessionId without a remount leaves them stale. +2. **Call `attachments.clear()` after a successful send** — the host clears the draft, not the + attachment stage (documented in the hook). +3. **Trace-side error refinement is skin-side** — `turns[].status` derives with + `traceError: null`; layer the per-turn trace summary in the skin (matches where OSS does it). +4. **`useApprovalDock` returns no renderer** — resolve friendly bodies via + `resolveApprovalBody(toolName)` and names via `resolveToolDisplay` from `./skin`. +5. **The model-key composer gate is not composed into the host** — wire + `useAgentModelKeyStatus` in the skin's composer. + +### WP1 infra tail — EXECUTED (2026-07-26, 3 commits) + +Closed the deferred items from WP1's "Chores pending" (prod image CI, compose wiring, +`run.sh` awareness); see +[plans/2026-07-25-wp1-infra-tail.md](./plans/2026-07-25-wp1-infra-tail.md) for the full task +breakdown and grounding facts. + +| Commit | Content | +|--------|---------| +| `b93b936bc5` | Fix the mobile gh image's entrypoint env-dir crash (G8): pre-create+chown `/app/oss/public` and `/app/ee/public` so the shared `web/entrypoint.sh` can write `__env.js` as the non-root runtime user | +| `af93751a16` | New `.github/workflows/17-check-mobile.yml`: `@agenta/mobile` typecheck job + per-arch (amd64/arm64) `agenta-web-mobile` image build with PR smoke (`/m`, `/m/__env.js`, non-root check) and `workflow_dispatch` push/merge-manifest/`:latest` publication | +| `03b16ae45a` | `web-mobile` service in all five prod/gh compose files (oss gh/ssl/local, ee gh/local) behind the opt-in `with-web-mobile` profile + `run.sh --with-mobile` | + +The image's **first-ever build+serve smoke passed**: ~500MB standalone image, `/m` and +`/m/__env.js` both 200, `/` 404 (basePath), runs as the non-root `agenta` user — all checks +green. Task 5 re-verified (didn't re-add) that mobile lint and `@agenta/chat` unit tests were +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. + +## Resume runbook (from here) + +1. **Plan wave-2** against the real code (WP2 auth/drawer → WP3b skin → WP4 pages → WP5 gate). + Planning inputs: the WP0 plan banner (archived_at client filter, auto-title lift into + `@agenta/entities`, delete-vs-kill swipe semantics, Fern regen for search/include_ended) + + the WP3b gotchas above + the WP1 gotcha (mobile compose/Dockerfile need `@agenta/*` wiring + the moment WP2 adds package imports). +2. **Follow-up track (separate, after the FE PR queue + shadcn branch):** desktop re-plumb — + OSS consumes `@agenta/chat`, deletes its local copies; the copy-headers mark every site. + +### Verify-the-foundation smoke (run after any rebase) + +```bash +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 dev-mobile # → http://localhost:3000/m, check light+dark +``` + +## Open items & gotchas (do not relearn these) + +- **`@agenta/*` deps are deliberately unwired for mobile.** The `web-mobile` compose service has + **no `web/packages` mount**, and `Dockerfile.gh` copies **no package manifests** — correct + today (mobile has zero workspace deps) but MUST be added the moment WP2+ imports + `@agenta/entities`/`@agenta/shared`/`@agenta/chat` (compose volumes + Dockerfile manifest + copies + turbo `dependsOn` for `@agenta/mobile#build`). +- **Token workflow:** palette changes go through `web/oss/src/styles/theme/palette.ts` → the + chained `generate:tailwind-tokens` now also regenerates mobile; `theme.generated.css` is + committed and lint fails if stale. Never hand-edit it. Installing a shadcn component that + references a NEW token requires extending the VARS role map + `@theme inline` first. +- **shadcn CLI** emits the consolidated `radix-ui` package (not `@radix-ui/react-slot`) — expected. +- **`exhaustive-deps` is `warn`** by choice (rules-of-hooks is the error-class check). +- **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. +- **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` + image (both arches) on mobile-path PRs and typechecks `@agenta/mobile`; lint (`turbo run lint` + 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 + 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. +- **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. + +## Follow-up tracks (post-wave-1, explicitly out of scope for now) + +- Desktop playground re-plumb onto `useAgentConversation` (the contract's acceptance test), + then incremental antd→shadcn skin swaps in OSS/EE. +- Scroll-hook extraction (`useConversationScroll`) — desktop-only, deferred from WP3a. +- Next 16 pilot on `web/mobile`. +- Subdomain (`m.`) mounting option for cloud; session-rename UX coordination (PR #5202). diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index dfd36acfdb..890e06e0ce 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -25,6 +25,35 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + # Opt-in while the mobile app is pre-GA (run.sh --with-mobile). + profiles: + - with-web-mobile + # === IMAGE ================================================ # + # The mobile app is edition-agnostic OSS-repo code: one public image + # serves both editions (no internal-ee variant). + build: + context: ../../../web + dockerfile: mobile/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: sh -c "node ./mobile/server.js" + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + # === LABELS =============================================== # + # (Path(`/m`) || PathPrefix(`/m/`)) auto-wins over the web catch-all PathPrefix(`/`) + # by rule length; no stripprefix — the app is built with basePath /m. + labels: + - "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 ============================================ # + restart: always + api: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 8a623b885a..12b8720091 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -29,6 +29,33 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + # Opt-in while the mobile app is pre-GA (run.sh --with-mobile). + profiles: + - with-web-mobile + # === IMAGE ================================================ # + # The mobile app is edition-agnostic OSS-repo code: one public image + # serves both editions (no internal-ee variant). + image: ghcr.io/agenta-ai/${AGENTA_WEB_MOBILE_IMAGE_NAME:-agenta-web-mobile}:${AGENTA_WEB_MOBILE_IMAGE_TAG:-latest} + # === EXECUTION ============================================ # + command: sh -c "node ./mobile/server.js" + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.gh} + # === NETWORK ============================================== # + networks: + - agenta-ee-gh-network + # === LABELS =============================================== # + # (Path(`/m`) || PathPrefix(`/m/`)) auto-wins over the web catch-all PathPrefix(`/`) + # by rule length; no stripprefix — the app is built with basePath /m. + labels: + - "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 ============================================ # + restart: always + api: # === IMAGE ================================================ # image: ghcr.io/agenta-ai/${AGENTA_API_IMAGE_NAME:-internal-ee-agenta-api}:${AGENTA_API_IMAGE_TAG:-latest} diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index af8814ef44..24b52cdbfa 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -14,6 +14,8 @@ AGENTA_LICENSE=ee # AGENTA_SERVICES_IMAGE_TAG=latest # AGENTA_RUNNER_IMAGE_NAME=internal-ee-agenta-runner # AGENTA_RUNNER_IMAGE_TAG=latest +# AGENTA_WEB_MOBILE_IMAGE_NAME=agenta-web-mobile +# AGENTA_WEB_MOBILE_IMAGE_TAG=latest # ================================================================== # # Agenta - Core endpoints diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index 849f7237f4..b13288c2f5 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -25,6 +25,33 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + # Opt-in while the mobile app is pre-GA (run.sh --with-mobile). + profiles: + - with-web-mobile + # === IMAGE ================================================ # + build: + context: ../../../web + dockerfile: mobile/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: sh -c "node ./mobile/server.js" + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + # === LABELS =============================================== # + # (Path(`/m`) || PathPrefix(`/m/`)) auto-wins over the web catch-all PathPrefix(`/`) + # by rule length; no stripprefix — the app is built with basePath /m. + labels: + - "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 ============================================ # + restart: always + api: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index a6a7aebc60..c0741c5fdb 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -27,6 +27,35 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + # Opt-in while the mobile app is pre-GA (run.sh --with-mobile). + profiles: + - with-web-mobile + # === IMAGE ================================================ # + build: + context: ../../../web + dockerfile: mobile/docker/Dockerfile.gh + # === EXECUTION ============================================ # + command: sh -c "node ./mobile/server.js" + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + # === NETWORK ============================================== # + networks: + - agenta-gh-ssl-network + # === LABELS =============================================== # + # (Path(`/m`) || PathPrefix(`/m/`)) auto-wins over the web catch-all PathPrefix(`/`) + # by rule length; no stripprefix — the app is built with basePath /m. + labels: + - "traefik.http.routers.web-mobile.rule=Host(`${TRAEFIK_DOMAIN}`) && (Path(`/m`) || PathPrefix(`/m/`))" + - "traefik.http.routers.web-mobile.entrypoints=web,web-secure" + - "traefik.http.services.web-mobile.loadbalancer.server.port=3000" + - "traefik.http.routers.web-mobile.tls=true" + - "traefik.http.routers.web-mobile.tls.certresolver=myResolver" + # === LIFECYCLE ============================================ # + restart: always + api: # === IMAGE ================================================ # build: diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index c7687df7f9..d4152a520a 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -26,6 +26,34 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + # Opt-in while the mobile app is pre-GA (run.sh --with-mobile). + profiles: + - with-web-mobile + # === IMAGE ================================================ # + # build: + # context: ../../../web + # dockerfile: mobile/docker/Dockerfile.gh + image: ghcr.io/agenta-ai/${AGENTA_WEB_MOBILE_IMAGE_NAME:-agenta-web-mobile}:${AGENTA_WEB_MOBILE_IMAGE_TAG:-latest} + # === EXECUTION ============================================ # + command: sh -c "node ./mobile/server.js" + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.gh} + # === NETWORK ============================================== # + networks: + - agenta-oss-gh-network + # === LABELS =============================================== # + # (Path(`/m`) || PathPrefix(`/m/`)) auto-wins over the web catch-all PathPrefix(`/`) + # by rule length; no stripprefix — the app is built with basePath /m. + labels: + - "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 ============================================ # + restart: always + api: # === IMAGE ================================================ # # build: diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index d5d6d2619c..8c791cb5fc 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -14,6 +14,8 @@ AGENTA_LICENSE=oss # AGENTA_SERVICES_IMAGE_TAG=latest # AGENTA_RUNNER_IMAGE_NAME=agenta-runner # AGENTA_RUNNER_IMAGE_TAG=latest +# AGENTA_WEB_MOBILE_IMAGE_NAME=agenta-web-mobile +# AGENTA_WEB_MOBILE_IMAGE_TAG=latest # ================================================================== # # Agenta - Core endpoints diff --git a/hosting/docker-compose/oss/nginx/nginx.conf b/hosting/docker-compose/oss/nginx/nginx.conf index 6ad6c6459d..4a4ec83218 100644 --- a/hosting/docker-compose/oss/nginx/nginx.conf +++ b/hosting/docker-compose/oss/nginx/nginx.conf @@ -50,6 +50,34 @@ http { proxy_read_timeout 30s; } + # Proxy to the mobile web app, which only runs when the stack was started with + # --with-mobile. `location = /m` plus `location /m/` is nginx's spelling of the Traefik + # rule the same service carries: segment-exact, so `/models` still falls through to the + # catch-all below instead of being swallowed by a bare `/m` prefix. + # + # The upstream goes through a variable ON PURPOSE. nginx resolves a literal upstream + # host at startup, so without the with-web-mobile profile the whole proxy would fail to + # boot on an unresolvable name. Through a variable the lookup happens per request (via + # the http-level resolver above), so a stack without the mobile app is unaffected and + # /m alone answers 502. + location = /m { + set $mobile_upstream http://web-mobile:3000; + proxy_pass $mobile_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /m/ { + set $mobile_upstream http://web-mobile:3000; + proxy_pass $mobile_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Proxy to frontend location / { proxy_pass http://web:3000; diff --git a/hosting/docker-compose/run.sh b/hosting/docker-compose/run.sh index fa4dd65977..ab90ebb33c 100755 --- a/hosting/docker-compose/run.sh +++ b/hosting/docker-compose/run.sh @@ -21,6 +21,7 @@ NUKE=false # Default to not nuking volumes DOWN=false # Default to up; --down only stops containers WITH_TUNNEL=true # Composio trigger-event tunnel; disable with --no-tunnel LOCAL_OVERRIDES=true # Auto-include docker-compose..*.local.yml; disable with --no-local-overrides +WITH_MOBILE=false # Start the mobile web app (/m) via the with-web-mobile profile; enable with --with-mobile declare -a EXTRA_COMPOSE_FILES=() # Extra -f files from --compose-file (repeatable) declare -a RECREATE_SERVICES=() # Services to surgically recreate via --recreate (repeatable) declare -a REBUILD_SERVICES=() # Services to surgically rebuild + recreate via --rebuild (repeatable) @@ -50,6 +51,8 @@ show_usage() { echo " --web-local Alias for --web-mode local" echo " --web-mode Web mode: docker|local|none (default: docker)" echo " --web-url Override AGENTA_WEB_URL" + echo " --with-mobile Also start the mobile web app at /m (gh/ssl/gh.local stacks;" + echo " no-op on --dev, where web-mobile rides the with-web profile)" echo "" echo "Environment:" echo " -e, --env Use explicit env file (otherwise stage default)" @@ -253,6 +256,9 @@ while [[ "$#" -gt 0 ]]; do --no-tunnel) WITH_TUNNEL=false ;; + --with-mobile) + WITH_MOBILE=true + ;; --no-local-overrides) LOCAL_OVERRIDES=false ;; @@ -438,6 +444,10 @@ if $WITH_TUNNEL; then COMPOSE_CMD+=" --profile with-tunnel" fi +if $WITH_MOBILE; then + COMPOSE_CMD+=" --profile with-web-mobile" +fi + # -------------------------------------------------------------------------------------------- # Surgical service lifecycle: --recreate / --rebuild # @@ -523,7 +533,7 @@ fi echo "Stopping existing Docker containers..." # Include all profiles to ensure clean shutdown -SHUTDOWN_CMD="$COMPOSE_CMD --profile with-web --profile with-nginx --profile with-traefik --profile with-tunnel down" +SHUTDOWN_CMD="$COMPOSE_CMD --profile with-web-mobile --profile with-web --profile with-nginx --profile with-traefik --profile with-tunnel down" if $NUKE; then SHUTDOWN_CMD+=" --volumes"