diff --git a/.agents/skills/mobile-app-structure/SKILL.md b/.agents/skills/mobile-app-structure/SKILL.md new file mode 100644 index 0000000000..fc3a629191 --- /dev/null +++ b/.agents/skills/mobile-app-structure/SKILL.md @@ -0,0 +1,52 @@ +--- +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`. diff --git a/.agents/skills/mobile-motion-patterns/SKILL.md b/.agents/skills/mobile-motion-patterns/SKILL.md new file mode 100644 index 0000000000..28d03e5ae2 --- /dev/null +++ b/.agents/skills/mobile-motion-patterns/SKILL.md @@ -0,0 +1,44 @@ +--- +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. diff --git a/.agents/skills/mobile-shadcn-conventions/SKILL.md b/.agents/skills/mobile-shadcn-conventions/SKILL.md new file mode 100644 index 0000000000..a1e8144e87 --- /dev/null +++ b/.agents/skills/mobile-shadcn-conventions/SKILL.md @@ -0,0 +1,56 @@ +--- +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. +- Installing a shadcn component that references a NEW token (e.g. `bg-sidebar`, + `chart-*`) requires extending VARS in `scripts/generate-shadcn-tokens.ts` + + the `@theme inline` map in `globals.css` first — Tailwind v4 silently + generates nothing for unmapped tokens. + +## 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. diff --git a/.claude/skills/mobile-app-structure b/.claude/skills/mobile-app-structure new file mode 120000 index 0000000000..e10ee776ad --- /dev/null +++ b/.claude/skills/mobile-app-structure @@ -0,0 +1 @@ +../../.agents/skills/mobile-app-structure \ No newline at end of file diff --git a/.claude/skills/mobile-motion-patterns b/.claude/skills/mobile-motion-patterns new file mode 120000 index 0000000000..e5bf94d376 --- /dev/null +++ b/.claude/skills/mobile-motion-patterns @@ -0,0 +1 @@ +../../.agents/skills/mobile-motion-patterns \ No newline at end of file diff --git a/.claude/skills/mobile-shadcn-conventions b/.claude/skills/mobile-shadcn-conventions new file mode 120000 index 0000000000..4ed8cec086 --- /dev/null +++ b/.claude/skills/mobile-shadcn-conventions @@ -0,0 +1 @@ +../../.agents/skills/mobile-shadcn-conventions \ No newline at end of file diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 2df60b88b0..c4bc868c84 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -75,6 +75,41 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + profiles: + - with-web + # === IMAGE ================================================ # + image: agenta-ee-dev-web:latest + # === EXECUTION ============================================ # + command: sh -c "pnpm dev-mobile" + # === STORAGE ============================================== # + volumes: + - ../../../web/mobile/src:/app/mobile/src + - ../../../web/mobile/public:/app/mobile/public + - nextjs-mobile-cache:/app/mobile/.next/cache + - turbo-mobile-cache:/app/.turbo + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.ee.dev} + environment: + DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + WATCHPACK_POLLING: "true" + # === NETWORK ============================================== # + networks: + - agenta-network + # === LABELS =============================================== # + # Path(`/m`) || PathPrefix(`/m/`), not PathPrefix(`/m`): the bare prefix is + # greedy and would also swallow /mfoo from the web catch-all PathPrefix(`/`). + # Still auto-wins over that catch-all by rule length; no stripprefix — the app + # is built with basePath /m. + labels: + - "traefik.http.routers.agenta-web-mobile.rule=Path(`/m`) || PathPrefix(`/m/`)" + - "traefik.http.routers.agenta-web-mobile.entrypoints=web" + - "traefik.http.services.agenta-web-mobile.loadbalancer.server.port=3000" + # === LIFECYCLE ============================================ # + restart: always + api: # === IMAGE ================================================ # image: agenta-ee-dev-api:latest @@ -772,3 +807,5 @@ volumes: nextjs-ee-cache: nextjs-oss-cache: turbo-ee-cache: + nextjs-mobile-cache: + turbo-mobile-cache: diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 4343d85287..35abf4bcb5 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -74,6 +74,41 @@ services: # === LIFECYCLE ============================================ # restart: always + web-mobile: + # === ACTIVATION =========================================== # + profiles: + - with-web + # === IMAGE ================================================ # + image: agenta-oss-dev-web:latest + # === EXECUTION ============================================ # + command: sh -c "pnpm dev-mobile" + # === STORAGE ============================================== # + volumes: + - ../../../web/mobile/src:/app/mobile/src + - ../../../web/mobile/public:/app/mobile/public + - nextjs-mobile-cache:/app/mobile/.next/cache + - turbo-mobile-cache:/app/.turbo + # === CONFIGURATION ======================================== # + env_file: + - ${ENV_FILE:-./.env.oss.dev} + environment: + DOCKER_NETWORK_MODE: ${DOCKER_NETWORK_MODE:-bridge} + WATCHPACK_POLLING: "true" + # === NETWORK ============================================== # + networks: + - agenta-network + # === LABELS =============================================== # + # Path(`/m`) || PathPrefix(`/m/`), not PathPrefix(`/m`): the bare prefix is + # greedy and would also swallow /mfoo from the web catch-all PathPrefix(`/`). + # Still auto-wins over that catch-all by rule length; no stripprefix — the app + # is built with basePath /m. + labels: + - "traefik.http.routers.agenta-web-mobile.rule=Path(`/m`) || PathPrefix(`/m/`)" + - "traefik.http.routers.agenta-web-mobile.entrypoints=web" + - "traefik.http.services.agenta-web-mobile.loadbalancer.server.port=3000" + # === LIFECYCLE ============================================ # + restart: always + api: # === IMAGE ================================================ # image: agenta-oss-dev-api:latest @@ -747,3 +782,5 @@ volumes: seaweed-data: nextjs-oss-cache: turbo-oss-cache: + nextjs-mobile-cache: + turbo-mobile-cache: diff --git a/web/ee/docker/Dockerfile.dev b/web/ee/docker/Dockerfile.dev index 3bef37356d..fe4fcf9aba 100644 --- a/web/ee/docker/Dockerfile.dev +++ b/web/ee/docker/Dockerfile.dev @@ -27,6 +27,7 @@ RUN PNPM_VERSION=$(node -p "require('./package.json').packageManager.split('@')[ # Copy app package manifests COPY ee/package.json ./ee/yarn.lock* ./ee/package-lock.json* ./ee/pnpm-lock.yaml* ./ee/.npmrc* ./ee/ COPY oss/package.json ./oss/yarn.lock* ./oss/package-lock.json* ./oss/pnpm-lock.yaml* ./oss/.npmrc* ./oss/ +COPY mobile/package.json ./mobile/ # Copy workspace package manifests (required for workspace:* resolution) COPY packages/agenta-shared/package.json ./packages/agenta-shared/ @@ -57,7 +58,7 @@ COPY ./entrypoint.sh /app/entrypoint.sh RUN groupadd --gid 10001 agenta && \ useradd --uid 10001 --gid 10001 --shell /bin/false --create-home agenta && \ - mkdir -p /app/.turbo /app/ee/.next/cache /app/oss/.next/cache && \ + mkdir -p /app/.turbo /app/ee/.next/cache /app/oss/.next/cache /app/mobile/.next/cache && \ chown -R agenta:agenta /app USER 10001 @@ -107,6 +108,9 @@ COPY --chown=agenta:agenta oss/next.config.ts ./oss/next.config.ts COPY --chown=agenta:agenta ee/tailwind.config.ts ./ee/tailwind.config.ts COPY --chown=agenta:agenta oss/tailwind.config.ts ./oss/tailwind.config.ts +COPY --chown=agenta:agenta mobile/src ./mobile/src +COPY --chown=agenta:agenta mobile/public ./mobile/public +COPY --chown=agenta:agenta mobile/tsconfig.json mobile/next-env.d.ts mobile/next.config.ts mobile/postcss.config.mjs mobile/components.json ./mobile/ ENTRYPOINT ["./entrypoint.sh"] EXPOSE 3000 diff --git a/web/entrypoint.sh b/web/entrypoint.sh index aa3c841f54..92952e6d3d 100755 --- a/web/entrypoint.sh +++ b/web/entrypoint.sh @@ -221,4 +221,13 @@ EOF cat "${ENTRYPOINT_DIR}/${AGENTA_LICENSE}/public/__env.js" >&2 +# Mirror the runtime config into the mobile app's public dir (served at +# /m/__env.js — the mobile app is built with basePath /m). The mobile app is +# edition-agnostic: one file, same content for oss and ee. Guarded so images +# without the mobile app (current gh images) are unaffected. +if [ -d "${ENTRYPOINT_DIR}/mobile" ]; then + mkdir -p "${ENTRYPOINT_DIR}/mobile/public" + cp "${ENTRYPOINT_DIR}/${AGENTA_LICENSE}/public/__env.js" "${ENTRYPOINT_DIR}/mobile/public/__env.js" +fi + exec "$@" diff --git a/web/mobile/.gitignore b/web/mobile/.gitignore new file mode 100644 index 0000000000..9d49267270 --- /dev/null +++ b/web/mobile/.gitignore @@ -0,0 +1,5 @@ +.next/ +.turbo/ +tsconfig.tsbuildinfo +# runtime config written by web/entrypoint.sh (dev mounts this dir from the host) +public/__env.js diff --git a/web/mobile/AGENTS.md b/web/mobile/AGENTS.md new file mode 100644 index 0000000000..94566b7690 --- /dev/null +++ b/web/mobile/AGENTS.md @@ -0,0 +1,84 @@ +# 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` diff --git a/web/mobile/CLAUDE.md b/web/mobile/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/web/mobile/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/web/mobile/components.json b/web/mobile/components.json new file mode 100644 index 0000000000..2ccd5e30a8 --- /dev/null +++ b/web/mobile/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/web/mobile/docker/Dockerfile.gh b/web/mobile/docker/Dockerfile.gh new file mode 100644 index 0000000000..fdf12abe3e --- /dev/null +++ b/web/mobile/docker/Dockerfile.gh @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1.20 +FROM node:24-slim AS base + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 \ + TURBO_TELEMETRY_DISABLED=1 \ + PNPM_HOME="/pnpm" \ + PATH="/pnpm:$PATH" + +RUN npm install -g corepack@0.31.0 && \ + corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY patches/ ./patches/ + +RUN PNPM_VERSION=$(node -p "require('./package.json').packageManager.split('@')[1]") && \ + corepack prepare "pnpm@${PNPM_VERSION}" --activate + + +FROM base AS builder + +ENV NODE_OPTIONS="--max_old_space_size=4096" +ENV TURBO_CACHE_DIR="/app/.turbo" + +COPY docker/run-turbo-build.sh /usr/local/bin/run-turbo-build.sh + +RUN chmod +x /usr/local/bin/run-turbo-build.sh + +# Manifests first (change less often than source). @agenta/mobile has no +# workspace package deps in WP1; add packages/*/package.json copies here when +# WP2+ introduces @agenta/* imports. +COPY mobile/package.json ./mobile/package.json + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch --frozen-lockfile + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --offline + +COPY mobile/ ./mobile/ + +RUN --mount=type=cache,id=turbo-mobile,target=/app/.turbo \ + --mount=type=cache,id=nextjs-mobile,target=/app/mobile/.next/cache \ + --mount=type=secret,id=turbo_team,required=false \ + --mount=type=secret,id=turbo_token,required=false \ + /usr/local/bin/run-turbo-build.sh @agenta/mobile + + +FROM node:24-slim AS runner + +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION=0.0.0 + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 \ + NODE_ENV=production + +RUN groupadd --gid 10001 agenta && \ + useradd --uid 10001 --gid 10001 --shell /bin/false --create-home agenta + +# web/entrypoint.sh (set -e) does `mkdir -p /app//public` before +# writing __env.js and mirroring it into mobile/public. This image ships only +# mobile/ and /app is root-owned, so pre-create both license dirs writable +# for the runtime user or the container exits at startup. +RUN mkdir -p /app/oss/public /app/ee/public && \ + chown -R agenta:agenta /app/oss /app/ee + +COPY --chown=agenta:agenta --from=builder /app/mobile/.next/standalone /app +COPY --chown=agenta:agenta --from=builder /app/mobile/.next/static /app/mobile/.next/static +COPY --chown=agenta:agenta --from=builder /app/mobile/public /app/mobile/public +COPY --chown=agenta:agenta ./entrypoint.sh /app/entrypoint.sh + +USER 10001 + +LABEL org.opencontainers.image.title="agenta-web-mobile" \ + org.opencontainers.image.description="Agenta Mobile Web GH runtime image" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.created="${BUILD_DATE}" + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "mobile/server.js"] +EXPOSE 3000 diff --git a/web/mobile/eslint.config.mjs b/web/mobile/eslint.config.mjs new file mode 100644 index 0000000000..073cc3f36f --- /dev/null +++ b/web/mobile/eslint.config.mjs @@ -0,0 +1,121 @@ +/** + * ESLint config for @agenta/mobile. + * + * Mirrors web/packages/eslint.config.mjs (flat config: tseslint + import + * order + prettier, strict no-explicit-any) and adds the mobile hard bans: + * no antd, no @ant-design/*, no Lexical, no app-layer imports (@/oss/*, + * @agenta/oss, @agenta/ee). See web/mobile/AGENTS.md. + */ +import eslint from "@eslint/js" +import importPlugin from "eslint-plugin-import" +import eslintPluginPrettier from "eslint-plugin-prettier/recommended" +import reactHooks from "eslint-plugin-react-hooks" +import tseslint from "typescript-eslint" + +const includePrettierRule = process.env.DISABLE_PRETTIER !== "true" + +const config = [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + { + plugins: { + import: importPlugin, + "react-hooks": reactHooks, + }, + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["antd", "antd/*"], + message: + "antd is banned in web/mobile. Use shadcn/ui components (src/components/ui) instead.", + }, + { + group: ["@ant-design/*"], + message: + "@ant-design/* is banned in web/mobile. Use shadcn/ui + lucide-react instead.", + }, + { + group: ["lexical", "lexical/*", "@lexical/*"], + message: + "Lexical is banned in web/mobile. The mobile composer uses AI Elements PromptInput (WP3b).", + }, + { + group: [ + "@/oss/*", + "@agenta/oss", + "@agenta/oss/*", + "@agenta/ee", + "@agenta/ee/*", + ], + message: + "web/mobile never imports app-layer code. Consume @agenta/* packages only.", + }, + ], + }, + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "none", + caughtErrors: "none", + ignoreRestSiblings: true, + destructuredArrayIgnorePattern: "none", + varsIgnorePattern: "^_|^_.*", + }, + ], + "import/order": [ + "error", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + "newlines-between": "always", + groups: ["builtin", "external", "internal", "parent", "sibling", "index"], + pathGroupsExcludedImportTypes: ["react"], + pathGroups: [ + { + pattern: "react", + group: "builtin", + position: "before", + }, + { + pattern: "@/**", + group: "internal", + }, + ], + }, + ], + ...(includePrettierRule + ? { + "prettier/prettier": [ + "error", + { + printWidth: 100, + tabWidth: 4, + useTabs: false, + semi: false, + bracketSpacing: false, + }, + ], + } + : {}), + }, + }, + ...(includePrettierRule ? [eslintPluginPrettier] : []), + { + ignores: [".next/**", "node_modules/**", "next-env.d.ts"], + }, +] + +export default config diff --git a/web/mobile/next-env.d.ts b/web/mobile/next-env.d.ts new file mode 100644 index 0000000000..254b73c165 --- /dev/null +++ b/web/mobile/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/web/mobile/next.config.ts b/web/mobile/next.config.ts new file mode 100644 index 0000000000..e2b0a59f29 --- /dev/null +++ b/web/mobile/next.config.ts @@ -0,0 +1,47 @@ +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 diff --git a/web/mobile/package.json b/web/mobile/package.json new file mode 100644 index 0000000000..3ecb995373 --- /dev/null +++ b/web/mobile/package.json @@ -0,0 +1,49 @@ +{ + "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 && pnpm run tokens:check", + "lint:fix": "eslint src --fix", + "format": "prettier --check .", + "format-fix": "prettier --write .", + "types:check": "tsc", + "generate:tokens": "tsx scripts/generate-shadcn-tokens.ts", + "tokens:check": "tsx scripts/generate-shadcn-tokens.ts --check" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.479.0", + "motion": "^12.0.0", + "next": "15.5.18", + "radix-ui": "^1.6.2", + "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", + "eslint-plugin-react-hooks": "^7.1.1", + "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" + } +} diff --git a/web/mobile/postcss.config.mjs b/web/mobile/postcss.config.mjs new file mode 100644 index 0000000000..9866e27af5 --- /dev/null +++ b/web/mobile/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/web/mobile/public/.gitkeep b/web/mobile/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/mobile/scripts/generate-shadcn-tokens.ts b/web/mobile/scripts/generate-shadcn-tokens.ts new file mode 100644 index 0000000000..28d99e998b --- /dev/null +++ b/web/mobile/scripts/generate-shadcn-tokens.ts @@ -0,0 +1,97 @@ +/** + * generate-shadcn-tokens.ts — bridge the workspace theme source of truth + * (web/oss/src/styles/theme/palette.ts) into shadcn/ui CSS variables for the + * mobile app, light + dark. + * + * Follows the pattern of web/scripts/generate-tailwind-tokens.ts: palette.ts + * is the ONLY color input; this file only chooses which palette role feeds + * which shadcn variable. The output (src/styles/theme.generated.css) is + * committed and must never be edited by hand — change palette.ts or the ROLE + * MAP below and rerun. + * + * Run (from web/mobile): pnpm generate:tokens + */ +import {readFileSync, writeFileSync} from "fs" +import {dirname, resolve} from "path" +import {fileURLToPath} from "url" + +import {palette, type ColorValue} from "../../oss/src/styles/theme/palette" + +const HERE = dirname(fileURLToPath(import.meta.url)) // web/mobile/scripts +const OUT = resolve(HERE, "../src/styles/theme.generated.css") + +/** Palette values are plain color strings except antd() shadow refs (never used here). */ +const color = (v: ColorValue): string => { + if (typeof v !== "string") { + throw new Error(`Palette value is an antd() ref, not a color: ${JSON.stringify(v)}`) + } + return v +} + +const p = palette + +// ROLE MAP — which palette role feeds which shadcn variable. The single place +// to retune the bridge. Values are [light, dark]. +// Installing a shadcn component that references a NEW token (e.g. bg-sidebar, +// chart-*) requires extending VARS + the @theme inline map first — Tailwind v4 +// silently generates nothing for unmapped tokens. +const VARS: Record = { + background: [color(p.surface.base.light), color(p.surface.base.dark)], + foreground: [color(p.text.primary.light), color(p.text.primary.dark)], + card: [color(p.surface.container.light), color(p.surface.container.dark)], + "card-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], + popover: [color(p.surface.elevated.light), color(p.surface.elevated.dark)], + "popover-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], + primary: [color(p.accent.primary.light), color(p.accent.primary.dark)], + // Light primary is brand navy → white text; dark primary is brand yellow → + // dark text (mirrors componentsDark.Button.primaryColor in palette.ts). + "primary-foreground": [color(p.surface.white.light), p.componentsDark.Button.primaryColor], + secondary: [color(p.scales.zinc[1].light), color(p.scales.zinc[1].dark)], + "secondary-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], + muted: [color(p.scales.zinc[1].light), color(p.scales.zinc[1].dark)], + "muted-foreground": [color(p.text.secondary.light), color(p.text.secondary.dark)], + // Dark accent is a NEUTRAL step, not controlItemBgActive.dark (#57572a): + // shadcn `accent` drives ghost/outline hover fills and the skeleton base. + // The selection-tint yellow stays desktop-only; introduce a separate token + // if a selected-state tint is needed later. + accent: [color(p.surface.controlItemBgActive.light), color(p.scales.zinc[2].dark)], + "accent-foreground": [color(p.text.primary.light), color(p.text.primary.dark)], + destructive: [color(p.semantic.error.light), color(p.semantic.error.dark)], + // Bright-red fill gets dark text in dark mode, matching the primary treatment. + "destructive-foreground": [color(p.surface.white.light), p.componentsDark.Button.primaryColor], + border: [color(p.border.secondary.light), color(p.border.secondary.dark)], + input: [color(p.border.default.light), color(p.border.default.dark)], + ring: [color(p.accent.primary.light), color(p.accent.primary.dark)], +} + +const block = (selector: string, side: 0 | 1) => + `${selector} {\n${Object.entries(VARS) + .map(([name, pair]) => ` --${name}: ${pair[side]};`) + .join("\n")}\n}\n` + +const css = `/* GENERATED by scripts/generate-shadcn-tokens.ts — DO NOT EDIT. + * Source of truth: web/oss/src/styles/theme/palette.ts + * Regenerate: pnpm --filter @agenta/mobile generate:tokens + */ +${block(":root", 0)} +${block(".dark", 1)}` + +// --check: drift guard — fail (without writing) if the committed file is stale. +if (process.argv.includes("--check")) { + let existing: string | null = null + try { + existing = readFileSync(OUT, "utf8") + } catch { + existing = null + } + if (existing !== css) { + console.error( + "theme.generated.css is stale — run pnpm --filter @agenta/mobile generate:tokens", + ) + process.exit(1) + } + process.exit(0) +} + +writeFileSync(OUT, css) +console.log(`wrote ${OUT}`) diff --git a/web/mobile/src/components/ui/button.tsx b/web/mobile/src/components/ui/button.tsx new file mode 100644 index 0000000000..d1334d3d1b --- /dev/null +++ b/web/mobile/src/components/ui/button.tsx @@ -0,0 +1,63 @@ +import * as React from "react" + +import {cva, type VariantProps} from "class-variance-authority" +import {Slot} from "radix-ui" + +import {cn} from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export {Button, buttonVariants} diff --git a/web/mobile/src/components/ui/skeleton.tsx b/web/mobile/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000..1e2daef7cc --- /dev/null +++ b/web/mobile/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import {cn} from "@/lib/utils" + +function Skeleton({className, ...props}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export {Skeleton} diff --git a/web/mobile/src/lib/motion/presets.ts b/web/mobile/src/lib/motion/presets.ts new file mode 100644 index 0000000000..429323cad9 --- /dev/null +++ b/web/mobile/src/lib/motion/presets.ts @@ -0,0 +1,114 @@ +/** + * Shared motion presets — the ONLY place transition values live in this app. + * Components consume presets via useMotionPresets() (reduced-motion aware); + * they never define their own durations, easings, or springs. + * See the mobile-motion-patterns skill for usage rules. + */ +import {useMemo} from "react" + +import {useReducedMotion} from "motion/react" +import type {Transition, Variants} from "motion/react" + +/** Spring for screen-level shared-axis pushes (list → chat). */ +export const pushTransition: Transition = { + type: "spring", + stiffness: 380, + damping: 38, + mass: 1, +} + +/** Spring for bottom/side sheets (project drawer). */ +export const sheetTransition: Transition = { + type: "spring", + stiffness: 300, + damping: 32, + mass: 0.9, +} + +/** Tween for skeleton → content crossfades (no layout jump). */ +export const crossfadeTransition: Transition = { + duration: 0.18, + ease: "easeOut", +} + +/** + * Shared-axis horizontal push. `custom` is the direction: +1 forward + * (list → chat), -1 back. Use inside . + */ +export const sharedAxisPush: Variants = { + initial: (direction: number) => ({x: `${direction * 30}%`, opacity: 0}), + animate: {x: 0, opacity: 1, transition: pushTransition}, + exit: (direction: number) => ({ + x: `${direction * -30}%`, + opacity: 0, + transition: pushTransition, + }), +} + +/** Spring-based sheet slide-up (project drawer, bottom sheets). */ +export const sheetSlideUp: Variants = { + initial: {y: "100%"}, + animate: {y: 0, transition: sheetTransition}, + exit: {y: "100%", transition: sheetTransition}, +} + +/** Crossfade for skeleton → content swaps (geometry must match). */ +export const crossfade: Variants = { + initial: {opacity: 0}, + animate: {opacity: 1, transition: crossfadeTransition}, + exit: {opacity: 0, transition: crossfadeTransition}, +} + +/** Instant variants used when the user prefers reduced motion. */ +const instant: Variants = { + initial: {opacity: 0}, + animate: {opacity: 1, transition: {duration: 0}}, + exit: {opacity: 0, transition: {duration: 0}}, +} + +/** Zero-duration transition returned for every raw transition when reduced. */ +const instantTransition: Transition = {duration: 0} + +export interface MotionPresets { + reduced: boolean + sharedAxisPush: Variants + sheetSlideUp: Variants + crossfade: Variants + /** Raw transitions for imperative use (e.g. drag-settle on sheets). */ + pushTransition: Transition + sheetTransition: Transition + crossfadeTransition: Transition +} + +/** + * Reduced-motion-aware presets — the only sanctioned consumption path in + * components (variants AND raw transitions; everything collapses to + * zero-duration when the user prefers reduced motion). Import the raw exports + * above only in tests. + */ +export function useMotionPresets(): MotionPresets { + const reduced = useReducedMotion() ?? false + return useMemo( + () => + reduced + ? { + reduced, + sharedAxisPush: instant, + sheetSlideUp: instant, + crossfade: instant, + pushTransition: instantTransition, + sheetTransition: instantTransition, + crossfadeTransition: instantTransition, + } + : { + reduced, + sharedAxisPush, + sheetSlideUp, + crossfade, + pushTransition, + sheetTransition, + crossfadeTransition, + }, + [reduced], + ) +} diff --git a/web/mobile/src/lib/utils.ts b/web/mobile/src/lib/utils.ts new file mode 100644 index 0000000000..d1a4eb4ee5 --- /dev/null +++ b/web/mobile/src/lib/utils.ts @@ -0,0 +1,6 @@ +import {clsx, type ClassValue} from "clsx" +import {twMerge} from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/web/mobile/src/pages/_app.tsx b/web/mobile/src/pages/_app.tsx new file mode 100644 index 0000000000..5f9c7c31bd --- /dev/null +++ b/web/mobile/src/pages/_app.tsx @@ -0,0 +1,21 @@ +import type {AppProps} from "next/app" +import Head from "next/head" + +import "@/styles/globals.css" + +// Deliberately minimal: no provider fleet (the desktop _app's ~10 providers +// are the reason this app exists as a separate bundle). Providers are added +// per concern when a feature needs them (auth/session first, in WP2). +export default function App({Component, pageProps}: AppProps) { + return ( + <> + + + + + + ) +} diff --git a/web/mobile/src/pages/_document.tsx b/web/mobile/src/pages/_document.tsx new file mode 100644 index 0000000000..e760362923 --- /dev/null +++ b/web/mobile/src/pages/_document.tsx @@ -0,0 +1,25 @@ +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 ( + + +