diff --git a/.github/workflows/17-check-mobile.yml b/.github/workflows/17-check-mobile.yml index 3656c84073..eea13ab2ca 100644 --- a/.github/workflows/17-check-mobile.yml +++ b/.github/workflows/17-check-mobile.yml @@ -8,6 +8,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'web/mobile/**' + - 'web/packages/**' - 'web/entrypoint.sh' - 'web/docker/**' - 'web/package.json' diff --git a/docs/design/agenta-mobile/plans/2026-07-26-mobile-flows-lite.md b/docs/design/agenta-mobile/plans/2026-07-26-mobile-flows-lite.md new file mode 100644 index 0000000000..d9b8534f01 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-26-mobile-flows-lite.md @@ -0,0 +1,382 @@ +# Mobile flows — LITE phase (raw UI, real navigation + data) + +**Status:** PLANNED · **Date:** 2026-07-26 · **Branch:** `feat/agenta-mobile-wave-1` +**Scope:** make the mobile app WORK — root context resolution → sessions list → read-only +chat replay — with deliberately RAW markup. A parallel agent owns radix primitives/package +conversions: invest NOTHING in component polish. Plain divs/buttons/lists, existing Tailwind +tokens (`text-xs`, `text-muted-foreground`, `border-border`, …), the two installed shadcn +components (`button`, `skeleton`) at most. No new shadcn installs, no motion work, no designed +skeleton states (raw "Loading…" / "No sessions" / "Something went wrong" text is correct for +this phase). One component per file and thin page shells still apply. + +## Constraints (bake into every task) + +- **No OSS/EE app-source edits.** `web/oss/src/**` and `web/ee/src/**` are read-only reference. + Package edits (`web/packages/**`) are allowed and expected. +- Mobile eslint bans `antd`, `@ant-design/*`, lexical, `@/oss/*`, `@agenta/oss|ee` — consume + `@agenta/*` packages only. +- Routes must match the gate's URL map: `/m/` → resolution → `/m/w/{ws}/p/{proj}/sessions` → + `/m/w/{ws}/p/{proj}/sessions/{id}`. Under `basePath: "/m"` the pages are + `pages/index.tsx`, `pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx`, + `pages/w/[workspace_id]/p/[project_id]/sessions/[session_id].tsx` (next/link handles the + `/m` prefix automatically). +- Commit messages: conventional, never mention Claude/Anthropic, no Co-Authored-By trailers. +- Live stack: the dev stack is RUNNING. `docker restart agenta-ee-dev-web-mobile-1` is allowed; + **NEVER** run `compose up`/`down`/`--build` in-session. Anything needing an image rebuild or + service recreate is an **operator step** (Arda) — write it down, don't run it. + +## Grounded facts (verified 2026-07-26, don't re-derive) + +1. **No packages mount for web-mobile.** Both dev composes + (`hosting/docker-compose/{oss,ee}/docker-compose.dev.yml`) mount only + `web/mobile/src` + `web/mobile/public` into `web-mobile`; the `web` service mounts + `../../../web/packages:/app/packages` — web-mobile must gain the same line. +2. **`@agenta/chat` is entirely absent from the dev images.** Both dev Dockerfiles + (`web/oss/docker/Dockerfile.dev`, `web/ee/docker/Dockerfile.dev`) COPY every package + manifest and src EXCEPT `packages/agenta-chat` (it postdates them). `pnpm i` runs at image + build, so **any mobile dependency change requires an operator image rebuild** — src mounts + don't cover `package.json`/lockfile/node_modules. +3. **Transpile mechanism:** packages ship TS source (`main: ./src/index.ts`); consumers list + them in `next.config` `transpilePackages` (see `web/oss/next.config.ts:90`). Mobile has none + today. +4. **Auth verdict: desktop cookies ride along.** SuperTokens is cookie-auth; + `buildClientOptions()` (`@agenta/sdk/config`) strips the empty `Authorization` header for + the browser cookie case. Probes go through Traefik at `localhost/m` → API `localhost/api` + is same-origin → cookies sent automatically. Fallback UX on 401: raw "open the desktop app + and sign in" message; NO auth page this phase. Caveat: no supertokens-web-js on mobile ⇒ no + token auto-refresh; an expired access token 401s until the user touches the desktop app. +5. **API base:** `_document.tsx` already loads `/m/__env.js` (`beforeInteractive`) which sets + `window.__env.NEXT_PUBLIC_AGENTA_API_URL` (dev: `http://localhost/api`). Mirror OSS: + `configureAgentaSdk({host})` once at module scope of the provider file. +6. **Jotai/query wiring** (mirrors `web/oss/src/state/Providers.tsx`): `@agenta/entities` + atoms read `queryClientAtom` (jotai-tanstack-query) and `projectIdAtom` from + `@agenta/shared/state` (a plain writable atom the APP must set). + `loadSessionMessages` uses `getDefaultStore()` — the jotai `` MUST be + `store={getDefaultStore()}`. +7. **Sessions list:** `querySessions({projectId, search?, limit?, next?, newest?, includeEnded, + includeArchived, references?})` from `@agenta/entities/session` → `SessionStream[] | null`. + Cursor pair = last row's `id` (`next`) + `updated_at ?? created_at` (`newest`). Row fields: + `id`, `name`, `flags` (`alive`/`running`/`attached`), `created_at`, `updated_at`, + `deleted_at` (=ended), `archived_at`, `references[] {id,slug,version}` (agent label = + `references[0]?.slug`). Hide `archived_at` rows client-side (WP0 planning input). +8. **Read-only replay path** (no transport, no workflowMolecule): + `loadSessionMessages(sessionId, onRefreshed)` (`@agenta/chat/assets`) → `UIMessage[]` → + `buildTurnViewModels(messages, {busy: false, executedFor: + createExecutedToolIdentityCache()})` (`@agenta/chat/model`) → `TurnViewModel[]` with + pre-folded `items`: `{kind:"part"|"tools"|"clientTool"}`. Tool rows: `partToolName(part)` + + `rowSummary(part)`. Records are IndexedDB-persisted with guaranteed revalidation — + `onRefreshed` re-delivers the fresh transcript. +9. **Live send is NOT nearly free** — `useAgentConversation` requires `entityId` and + `buildAgentRequest` reads the hydrated `workflowMolecule`. **Out of scope**; no severable + task. The chat screen ships a raw "read-only on mobile for now" notice. +10. **Workspace/project resolution:** Fern has a `ProjectsClient.getProjects()` (resource + `projects`), no accessor in `@agenta/sdk/resources.ts` yet (sessions accessor at line 88 is + the pattern). Rows: `{project_id, project_name, workspace_id, workspace_name, + organization_id, is_demo, is_default_project}` — one call yields the whole + workspace→project tree. Desktop persists last-used in localStorage + `lastUsedProjectsByWorkspace` (`{[workspaceId]: projectId}`) — read it for continuity; + write mobile's own `agenta:mobile:last-context`. +11. **Versions to pin (match oss/chat):** `ai 6.0.0-beta.150`, `@ai-sdk/react + 3.0.0-beta.153`, `jotai ^2.16.1`, `jotai-tanstack-query ^0.11.0`, `@tanstack/react-query + ^5.90.21`, `zod ^4.3.6`. Workspace dep syntax: `"@agenta/chat": + "workspace:../packages/agenta-chat"` (oss pattern). +12. **Dependency closure** for install/transpile: chat → entities + playground + shared; + entities → sdk + api-client + shared + ui. Runtime for THIS phase never touches + playground/ui/antd (chat `/model` + `/assets` import only entities/shared + type-only + `ai`), but the workspace links and transpile list must carry the closure. +13. **`hosting/docker-compose/ee/docker-compose.dev.yml` is dirty with Arda's PROTECTED + uncommitted `CLAUDE_*` lines** — stage that file with `git add -p` (or but-hunk staging) + and verify `git diff --cached | grep -c CLAUDE_` is 0 before committing. + +--- + +## T1 — Wire `@agenta/*` packages into `@agenta/mobile` + +**Files:** `web/mobile/package.json`, `web/mobile/next.config.ts`, `web/turbo.json`, +`web/pnpm-lock.yaml` (regenerated). + +- `package.json` dependencies, add: + + ```json + "@agenta/chat": "workspace:../packages/agenta-chat", + "@agenta/entities": "workspace:../packages/agenta-entities", + "@agenta/sdk": "workspace:../packages/agenta-sdk", + "@agenta/shared": "workspace:../packages/agenta-shared", + "@ai-sdk/react": "3.0.0-beta.153", + "@tanstack/react-query": "^5.90.21", + "ai": "6.0.0-beta.150", + "jotai": "^2.16.1", + "jotai-tanstack-query": "^0.11.0", + "zod": "^4.3.6" + ``` + +- `next.config.ts`: + + ```ts + transpilePackages: [ + "@agenta/sdk", + "@agentaai/api-client", + "@agenta/shared", + "@agenta/ui", + "@agenta/entities", + "@agenta/playground", + "@agenta/chat", + ], + ``` + +- `web/turbo.json`: `@agenta/mobile#build` gains + `"dependsOn": ["@agenta/shared#build", "@agenta/entities#build", "@agenta/chat#build"]`; + `@agenta/mobile#types:check` gains `"dependsOn": ["^types:check"]`. +- `cd web && pnpm install` (host; refreshes the lockfile). + +**Verify:** `pnpm --filter @agenta/mobile types:check` and `lint` pass; a throwaway probe +import of `querySessions` + `loadSessionMessages` typechecks (delete it after); +`grep -r "antd" web/mobile/src` empty. +**Commit:** `feat(mobile): wire @agenta/* workspace packages into the mobile app` + +## T2 — Container wiring (dev compose + Dockerfiles) + live-stack runbook + +**Files:** `hosting/docker-compose/oss/docker-compose.dev.yml`, +`hosting/docker-compose/ee/docker-compose.dev.yml`, `web/oss/docker/Dockerfile.dev`, +`web/ee/docker/Dockerfile.dev`, `web/mobile/docker/Dockerfile.gh`. + +- Both dev composes, `web-mobile.volumes`, add (first position, matching `web`): + `- ../../../web/packages:/app/packages`. **EE file: filtered staging per fact 13.** +- Both dev Dockerfiles: with the other manifests + `COPY packages/agenta-chat/package.json ./packages/agenta-chat/`; with the other sources + `COPY --chown=agenta:agenta packages/agenta-chat/src ./packages/agenta-chat/src` + + `COPY --chown=agenta:agenta packages/agenta-chat/tsconfig.json ./packages/agenta-chat/`. +- `web/mobile/docker/Dockerfile.gh`: mirror the oss gh image's manifest+source COPY block for + the closure (shared, ui, entities, playground, chat, sdk, api-client full-dir for its + `prepare` build) so `pnpm i` resolves the new workspace deps. CI workflow + `17-check-mobile.yml` build-smokes this on mobile-path PRs — that run is the verify. +- **Operator runbook (document in the commit body / README open items, do NOT run):** the live + `web-mobile` container has neither the new deps nor `@agenta/chat` (facts 1–2). Applying this + task = rebuild dev web image + recreate `web-mobile` (Arda: + `run.sh --ee --dev --with-mobile --build`). Interim bootstrap of the RUNNING container, if + needed before the rebuild: + + ```bash + docker exec agenta-ee-dev-web-mobile-1 ls /app/packages # confirm what's baked + docker cp web/packages/agenta-chat agenta-ee-dev-web-mobile-1:/app/packages/ + docker cp web/mobile/package.json agenta-ee-dev-web-mobile-1:/app/mobile/package.json + docker cp web/pnpm-lock.yaml agenta-ee-dev-web-mobile-1:/app/pnpm-lock.yaml + docker exec agenta-ee-dev-web-mobile-1 pnpm install + docker restart agenta-ee-dev-web-mobile-1 + ``` + +**Verify:** `docker compose -f hosting/docker-compose/ee/docker-compose.dev.yml config` and the +oss twin both validate; `git diff --cached | grep -c CLAUDE_` → 0; after the container has the +packages (bootstrap or rebuild): `curl -s -o /dev/null -w "%{http_code}" http://localhost/m/` +→ 200. +**Commit:** `chore(mobile): mount and bake @agenta/* packages for the web-mobile containers` + +## T3 — Runtime glue: env, SDK host, providers, route→projectId sync + +**Files:** `web/mobile/src/lib/env.ts`, `web/mobile/src/lib/queryClient.ts`, +`web/mobile/src/features/app/AppProviders.tsx`, `web/mobile/src/features/app/ContextSync.tsx`, +`web/mobile/src/pages/_app.tsx` (edit). + +- `lib/env.ts`: `getEnv(key)` = `window.__env?.[key] ?? process.env[key] ?? ""`; + `getApiUrl()` = `getEnv("NEXT_PUBLIC_AGENTA_API_URL")` (dev `__env.js` is already mirrored + into `web/mobile/public/`). +- `lib/queryClient.ts`: one `new QueryClient({defaultOptions: {queries: {retry: 1, + refetchOnWindowFocus: false}}})` singleton. +- `AppProviders.tsx` (the ONE non-obvious wiring — mirror oss `GlobalStateProvider`): + + ```tsx + import {configureAgentaSdk} from "@agenta/sdk/config" + import {QueryClientProvider} from "@tanstack/react-query" + import {Provider, getDefaultStore} from "jotai" + import {useHydrateAtoms} from "jotai/react/utils" + import {queryClientAtom} from "jotai-tanstack-query" + + // Module scope, like oss _app: __env.js is beforeInteractive, so window.__env is set. + configureAgentaSdk({host: getApiUrl()}) + + const HydrateAtoms = ({children}: PropsWithChildren) => { + useHydrateAtoms([[queryClientAtom, queryClient]]) + return children + } + + export const AppProviders = ({children}: PropsWithChildren) => ( + + {/* default store — loadSessionMessages writes through getDefaultStore() */} + + + + {children} + + + + ) + ``` + +- `ContextSync.tsx`: null-rendering; reads `useRouter().query.project_id`, and + `useEffect`-sets `setProjectIdAtom` (`@agenta/shared/state`); when both `workspace_id` and + `project_id` are present, persists `agenta:mobile:last-context` + (`{workspaceId, projectId}`) to localStorage. +- `_app.tsx`: wrap `` in ``. + +**Verify:** types:check + lint; browser `localhost/m` still renders the shell; no console +errors from provider wiring. +**Commit:** `feat(mobile): app providers, sdk host pinning, and route-scoped project state` + +## T4 — Root context resolution (`/m/` → workspace → project → sessions) + +**Files:** `web/packages/agenta-sdk/src/resources.ts` (add accessor), +`web/mobile/src/lib/context.ts`, `web/mobile/src/features/context/ContextResolver.tsx`, +`web/mobile/src/features/context/WorkspaceProjectList.tsx`, +`web/mobile/src/features/context/states/SignedOutNotice.tsx`, +`web/mobile/src/pages/index.tsx` (replace proof-of-life). + +- SDK accessor (pattern = `getSessionsClient`, same file): + + ```ts + import {ProjectsClient} from "@agentaai/api-client/resources/projects" + let _projects: ProjectsClient | undefined + export function getProjectsClient(): ProjectsClient { + return (_projects ??= new ProjectsClient(buildClientOptions())) + } + ``` + +- `lib/context.ts`: `fetchProjects()` → `getProjectsClient().getProjects()`, zod-parsed with a + minimal schema (`project_id`, `project_name`, `workspace_id`, `workspace_name` nullish, + `is_demo` nullish); catch → `{kind: "unauthenticated"}` when the error's `statusCode` is + 401/403, else `{kind: "error"}`. Plus `readLastContext()`/`readDesktopLastUsed()` helpers + (`agenta:mobile:last-context`, then desktop's `lastUsedProjectsByWorkspace`). +- `ContextResolver.tsx` flow: (1) stored mobile last-context → `router.replace` to its + sessions URL; (2) else fetch projects: exactly one → auto-forward; several → group by + `workspace_id` and render `WorkspaceProjectList` (raw nested tappable list: workspace name + header, project buttons; tap → replace to `/w/{ws}/p/{proj}/sessions`); (3) unauthenticated → + `SignedOutNotice` (raw text: "Sign in on the desktop app first, then reload this page."); + (4) error/empty → raw retry button. +- `pages/index.tsx`: thin shell rendering `ContextResolver`. + +**Verify:** types:check + lint; browser: signed-in desktop session at `localhost` → +`localhost/m/` forwards to a sessions URL (or shows the picker with real names); private +window → the signed-out notice, no crash. +**Commit:** `feat(mobile): workspace and project resolution at the mobile root` + +## T5 — Sessions list + +**Files:** `web/mobile/src/features/sessions/useSessionsInfinite.ts`, +`SessionListScreen.tsx`, `SessionRow.tsx`, `SessionSearchBar.tsx`, +`states/SessionListStates.tsx` (raw loading/empty/error/end-of-list, one tiny component each is +overkill this phase — a single file of small named exports is acceptable within the states/ +convention), `web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx`. + +- Hook (the non-obvious cursor pairing): + + ```ts + const PAGE_SIZE = 30 + export const useSessionsInfinite = (projectId: string, search: string) => + useInfiniteQuery({ + queryKey: ["mobile", "sessions", projectId, search], + enabled: Boolean(projectId), + initialPageParam: null as null | {next: string; newest: string}, + queryFn: ({pageParam, signal}) => + querySessions({ + projectId, + search: search || undefined, + limit: PAGE_SIZE, + next: pageParam?.next, + newest: pageParam?.newest, + abortSignal: signal, + }), + getNextPageParam: (lastPage) => { + if (!lastPage || lastPage.length < PAGE_SIZE) return undefined + const last = lastPage[lastPage.length - 1] + const newest = last.updated_at ?? last.created_at + return last.id && newest ? {next: last.id, newest} : undefined + }, + staleTime: 30_000, + }) + ``` + +- Screen: search input (plain ``, 300 ms debounce via `useEffect`+timeout), rows = + pages flattened, **client-filtered to `!archived_at`**, raw "Load more" ` + + ) + } + + return ( +
+ {body} + {/* WP5 gate escape hatch: plain (next/link would prefix /m) to a + desktop URL; ?view=desktop sets the agenta-mobile-optout cookie. */} + +
+ ) +} diff --git a/web/mobile/src/features/context/WorkspaceProjectList.tsx b/web/mobile/src/features/context/WorkspaceProjectList.tsx new file mode 100644 index 0000000000..6a8f3f2a53 --- /dev/null +++ b/web/mobile/src/features/context/WorkspaceProjectList.tsx @@ -0,0 +1,41 @@ +import {useRouter} from "next/router" + +import type {MobileProject} from "@/lib/context" + +export interface WorkspaceGroup { + workspaceId: string + workspaceName: string + projects: MobileProject[] +} + +/** Raw nested picker: workspace headers with tappable project rows. */ +export const WorkspaceProjectList = ({groups}: {groups: WorkspaceGroup[]}) => { + const router = useRouter() + return ( +
+

Choose a project

+ {groups.map((group) => ( +
+

{group.workspaceName}

+ {group.projects.map((project) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/web/mobile/src/features/context/states/SignedOutNotice.tsx b/web/mobile/src/features/context/states/SignedOutNotice.tsx new file mode 100644 index 0000000000..85f52ff2d7 --- /dev/null +++ b/web/mobile/src/features/context/states/SignedOutNotice.tsx @@ -0,0 +1,8 @@ +export const SignedOutNotice = () => ( +
+

You are signed out

+

+ Sign in on the desktop app first, then reload this page. +

+
+) diff --git a/web/mobile/src/features/sessions/SessionListScreen.tsx b/web/mobile/src/features/sessions/SessionListScreen.tsx new file mode 100644 index 0000000000..dab1ac9b85 --- /dev/null +++ b/web/mobile/src/features/sessions/SessionListScreen.tsx @@ -0,0 +1,82 @@ +import {useEffect, useState} from "react" + +import {clearLastContext} from "@/lib/context" + +import {classifyPageFailure} from "./pageFailure" +import {SessionRow} from "./SessionRow" +import {SessionSearchBar} from "./SessionSearchBar" +import {SessionListEmpty, SessionListError, SessionListLoading} from "./states/SessionListStates" +import {useSessionsInfinite} from "./useSessionsInfinite" + +/** Sessions list: server-side search, id+activity cursor paging, archived rows hidden. */ +export const SessionListScreen = ({ + workspaceId, + projectId, +}: { + workspaceId: string + projectId: string +}) => { + const [input, setInput] = useState("") + const [search, setSearch] = useState("") + useEffect(() => { + const handle = setTimeout(() => setSearch(input.trim()), 300) + return () => clearTimeout(handle) + }, [input]) + + const query = useSessionsInfinite(projectId, search) + const pages = query.data?.pages ?? [] + const {failed, laterPageFailed} = classifyPageFailure(pages, query.isError) + + // A stored pair pointing at a deleted project would forward `/m/` straight back here + // on every launch. Drop it as soon as its project will not load; `ContextSync` + // restores the fast path from the next project that does, so a transient failure + // costs one visit to the picker. + useEffect(() => { + if (failed) clearLastContext() + }, [failed]) + const rows = pages.flatMap((page) => page ?? []).filter((session) => !session.archived_at) + + let body + if (query.isPending) { + body = + } else if (failed) { + body = void query.refetch()} /> + } else if (rows.length === 0) { + body = + } else { + body = ( +
+ {rows.map((session) => ( + + ))} + {laterPageFailed || query.hasNextPage ? ( + + ) : null} +
+ ) + } + + return ( +
+
+ +
+ {body} +
+ ) +} diff --git a/web/mobile/src/features/sessions/SessionRow.tsx b/web/mobile/src/features/sessions/SessionRow.tsx new file mode 100644 index 0000000000..ed4902f9ff --- /dev/null +++ b/web/mobile/src/features/sessions/SessionRow.tsx @@ -0,0 +1,38 @@ +import type {SessionStream} from "@agenta/entities/session" +import Link from "next/link" + +/** Raw relative time ("3h ago") — enough for the LITE phase, no dayjs. */ +const timeAgo = (iso: string | null | undefined): string | null => { + if (!iso) return null + const then = Date.parse(iso) + if (Number.isNaN(then)) return null + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)) + if (seconds < 60) return "just now" + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + +export const SessionRow = ({session, href}: {session: SessionStream; href: string}) => { + const agentLabel = session.references?.[0]?.slug ?? session.references?.[0]?.id ?? "—" + const activity = timeAgo(session.updated_at ?? session.created_at) + return ( + + + {session.name ?? "Untitled session"} + {session.flags?.is_alive ? ( + live + ) : null} + {session.deleted_at ? ( + ended + ) : null} + + + {agentLabel} + {activity ? ` · ${activity}` : ""} + + + ) +} diff --git a/web/mobile/src/features/sessions/SessionSearchBar.tsx b/web/mobile/src/features/sessions/SessionSearchBar.tsx new file mode 100644 index 0000000000..94e57ff986 --- /dev/null +++ b/web/mobile/src/features/sessions/SessionSearchBar.tsx @@ -0,0 +1,15 @@ +export const SessionSearchBar = ({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) => ( + onChange(event.target.value)} + placeholder="Search sessions" + className="border-border bg-background text-foreground placeholder:text-muted-foreground w-full rounded-md border px-3 py-2 text-xs" + /> +) diff --git a/web/mobile/src/features/sessions/pageFailure.ts b/web/mobile/src/features/sessions/pageFailure.ts new file mode 100644 index 0000000000..41be20f425 --- /dev/null +++ b/web/mobile/src/features/sessions/pageFailure.ts @@ -0,0 +1,23 @@ +/** Which part of a paged session list failed. `querySessions` resolves null rather than throwing. */ +export interface PageFailure { + /** Nothing to show: the query errored, or the first page never arrived. */ + failed: boolean + /** Rows are on screen but the list stopped growing partway down. */ + laterPageFailed: boolean +} + +/** + * A failed first page and a failed fifth page are different problems. The first leaves an empty + * screen and wants the full-screen error; the second should keep what the reader already scrolled + * through and offer the retry where it stopped. + */ +export function classifyPageFailure( + pages: readonly (unknown[] | null)[], + isError: boolean, +): PageFailure { + const failed = isError || pages[0] === null + return { + failed, + laterPageFailed: !failed && pages.some((page) => page === null), + } +} diff --git a/web/mobile/src/features/sessions/states/SessionListStates.tsx b/web/mobile/src/features/sessions/states/SessionListStates.tsx new file mode 100644 index 0000000000..b39d99b691 --- /dev/null +++ b/web/mobile/src/features/sessions/states/SessionListStates.tsx @@ -0,0 +1,23 @@ +// Raw one-liner states for the LITE phase (designed states come with the skin pass); +// a single file of small named exports is the sanctioned shape for this phase. + +export const SessionListLoading = () => ( +

Loading…

+) + +export const SessionListEmpty = () => ( +

No sessions.

+) + +export const SessionListError = ({onRetry}: {onRetry: () => void}) => ( +
+

Something went wrong.

+ +
+) diff --git a/web/mobile/src/features/sessions/useSessionsInfinite.ts b/web/mobile/src/features/sessions/useSessionsInfinite.ts new file mode 100644 index 0000000000..d5625d524b --- /dev/null +++ b/web/mobile/src/features/sessions/useSessionsInfinite.ts @@ -0,0 +1,33 @@ +import {querySessions} from "@agenta/entities/session" +import {useInfiniteQuery} from "@tanstack/react-query" + +const PAGE_SIZE = 30 + +type SessionsCursor = {next: string; newest: string} | null + +/** Windowed session list — cursor pair = last row's `id` + its activity timestamp. */ +export const useSessionsInfinite = (projectId: string, search: string) => + useInfiniteQuery({ + queryKey: ["mobile", "sessions", projectId, search], + enabled: Boolean(projectId), + initialPageParam: null as SessionsCursor, + queryFn: ({pageParam, signal}) => + querySessions({ + projectId, + search: search || undefined, + limit: PAGE_SIZE, + next: pageParam?.next, + newest: pageParam?.newest, + // Server-side filter: an all-archived first page would otherwise + // render "No sessions." while live rows sit behind the cursor. + includeArchived: false, + abortSignal: signal, + }), + getNextPageParam: (lastPage): SessionsCursor | undefined => { + if (!lastPage || lastPage.length < PAGE_SIZE) return undefined + const last = lastPage[lastPage.length - 1] + const newest = last.updated_at ?? last.created_at + return last.id && newest ? {next: last.id, newest} : undefined + }, + staleTime: 30_000, + }) diff --git a/web/mobile/src/lib/context.ts b/web/mobile/src/lib/context.ts new file mode 100644 index 0000000000..62634fb5c4 --- /dev/null +++ b/web/mobile/src/lib/context.ts @@ -0,0 +1,97 @@ +import {safeParseWithLogging} from "@agenta/entities/shared" +import {getProjectsClient} from "@agenta/sdk/resources" +import {z} from "zod" + +/** Mobile's own last-visited workspace/project, for `/m/` root resolution. */ +export const LAST_CONTEXT_KEY = "agenta:mobile:last-context" + +/** Desktop's continuity map ({[workspaceId]: projectId}) — read-only here. */ +const DESKTOP_LAST_USED_KEY = "lastUsedProjectsByWorkspace" + +export interface LastContext { + workspaceId: string + projectId: string +} + +export function writeLastContext(context: LastContext): void { + try { + localStorage.setItem(LAST_CONTEXT_KEY, JSON.stringify(context)) + } catch { + // storage unavailable (private mode / quota) — continuity is best-effort + } +} + +/** + * Forget the fast-path pair. Called when the stored project will not load, so `/m/` stops + * forwarding into a route that cannot render. Safe to over-call: `ContextSync` rewrites the + * pair on the next project that does load. + */ +export function clearLastContext(): void { + try { + localStorage.removeItem(LAST_CONTEXT_KEY) + } catch { + // storage unavailable (private mode / quota) — continuity is best-effort + } +} + +export function readLastContext(): LastContext | null { + try { + const raw = localStorage.getItem(LAST_CONTEXT_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial | null + if ( + parsed && + typeof parsed.workspaceId === "string" && + typeof parsed.projectId === "string" + ) { + return {workspaceId: parsed.workspaceId, projectId: parsed.projectId} + } + return null + } catch { + return null + } +} + +export function readDesktopLastUsed(): Record { + try { + const raw = localStorage.getItem(DESKTOP_LAST_USED_KEY) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + if (!parsed || typeof parsed !== "object") return {} + const entries = Object.entries(parsed as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "", + ) + return Object.fromEntries(entries) + } catch { + return {} + } +} + +// Minimal boundary schema: Fern's compile-time types under-declare backend +// extra="allow" fields, so the local schema is the independent drift check. +const projectRowSchema = z.object({ + project_id: z.string(), + project_name: z.string(), + workspace_id: z.string().nullish(), + workspace_name: z.string().nullish(), + is_demo: z.boolean().nullish(), +}) + +export type MobileProject = z.infer + +export type ProjectsResult = + | {kind: "ok"; projects: MobileProject[]} + | {kind: "unauthenticated"} + | {kind: "error"} + +export async function fetchProjects(): Promise { + try { + const data = await getProjectsClient().getProjects() + const projects = safeParseWithLogging(z.array(projectRowSchema), data, "[fetchProjects]") + if (!projects) return {kind: "error"} + return {kind: "ok", projects} + } catch (error) { + const status = (error as {statusCode?: number} | null)?.statusCode + if (status === 401 || status === 403) return {kind: "unauthenticated"} + return {kind: "error"} + } +} diff --git a/web/mobile/src/lib/env.ts b/web/mobile/src/lib/env.ts new file mode 100644 index 0000000000..eba2894b85 --- /dev/null +++ b/web/mobile/src/lib/env.ts @@ -0,0 +1,34 @@ +/** + * Runtime env access. `__env.js` (regenerated by web/entrypoint.sh on each + * container start, loaded beforeInteractive in _document) populates + * `window.__env` before hydration, so browser reads always see live config. + */ +declare global { + interface Window { + __env?: Record + } +} + +/** + * Build-time values, read through LITERAL keys. + * + * `process.env[key]` with a computed key is not inlined by Next, so a flag that is not also in + * `__env.js` reads as empty in a built image no matter how the container is configured. Naming + * each key here keeps it statically bundled — the same shape as the desktop's `processEnv`. + */ +const buildEnv: Record = { + NEXT_PUBLIC_AGENTA_API_URL: process.env.NEXT_PUBLIC_AGENTA_API_URL, + NEXT_PUBLIC_AGENT_CHAT_STEER: process.env.NEXT_PUBLIC_AGENT_CHAT_STEER, +} + +export function getEnv(key: string): string { + if (typeof window !== "undefined" && window.__env?.[key]) { + return window.__env[key] ?? "" + } + // `__env.js` first (runtime config wins), then the build-time value. + return buildEnv[key] ?? process.env[key] ?? "" +} + +export function getApiUrl(): string { + return getEnv("NEXT_PUBLIC_AGENTA_API_URL") +} diff --git a/web/mobile/src/lib/queryClient.ts b/web/mobile/src/lib/queryClient.ts new file mode 100644 index 0000000000..585f1f82d8 --- /dev/null +++ b/web/mobile/src/lib/queryClient.ts @@ -0,0 +1,11 @@ +import {QueryClient} from "@tanstack/react-query" + +/** Single app-wide client; also hydrated into `queryClientAtom` in AppProviders. */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) diff --git a/web/mobile/src/pages/_app.tsx b/web/mobile/src/pages/_app.tsx index 5f9c7c31bd..d3feced259 100644 --- a/web/mobile/src/pages/_app.tsx +++ b/web/mobile/src/pages/_app.tsx @@ -1,11 +1,14 @@ import type {AppProps} from "next/app" import Head from "next/head" +import {AppProviders} from "@/features/app/AppProviders" + 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). +// are the reason this app exists as a separate bundle). AppProviders holds +// only what the data layer needs: query client + jotai default store + SDK +// host pin + route→project sync. export default function App({Component, pageProps}: AppProps) { return ( <> @@ -15,7 +18,9 @@ export default function App({Component, pageProps}: AppProps) { content="width=device-width, initial-scale=1, viewport-fit=cover" /> - + + + ) } diff --git a/web/mobile/src/pages/index.tsx b/web/mobile/src/pages/index.tsx index 8002788e1d..efb7ef4add 100644 --- a/web/mobile/src/pages/index.tsx +++ b/web/mobile/src/pages/index.tsx @@ -1,33 +1,16 @@ import Head from "next/head" -// Placeholder route shell: proves the scaffold end to end (basePath, Tailwind -// v4, palette-bridged tokens, dark mode). Replaced in WP2 by context -// resolution (last-used workspace/project) + redirect to the sessions list. -// The footer link is the WP5 gate escape hatch: a plain (next/link would -// prefix the /m basePath) to a desktop URL carrying ?view=desktop, which the -// desktop middleware turns into the agenta-mobile-optout cookie. +import {ContextResolver} from "@/features/context/ContextResolver" + +// Thin shell: `/m/` resolves a workspace/project context and forwards to its +// sessions list (or shows the raw picker / signed-out notice). export default function Home() { return ( <> Agenta Mobile - + ) } diff --git a/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/[session_id].tsx b/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/[session_id].tsx new file mode 100644 index 0000000000..977d36eb6f --- /dev/null +++ b/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/[session_id].tsx @@ -0,0 +1,29 @@ +import Head from "next/head" +import {useRouter} from "next/router" + +import {ChatScreen} from "@/features/chat/ChatScreen" + +export default function SessionPage() { + const router = useRouter() + const {workspace_id: workspaceId, project_id: projectId, session_id: sessionId} = router.query + if ( + typeof workspaceId !== "string" || + typeof projectId !== "string" || + typeof sessionId !== "string" + ) { + return null + } + return ( + <> + + Session + + + + ) +} diff --git a/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx b/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx new file mode 100644 index 0000000000..d192eac7c9 --- /dev/null +++ b/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx @@ -0,0 +1,18 @@ +import Head from "next/head" +import {useRouter} from "next/router" + +import {SessionListScreen} from "@/features/sessions/SessionListScreen" + +export default function SessionsPage() { + const router = useRouter() + const {workspace_id: workspaceId, project_id: projectId} = router.query + if (typeof workspaceId !== "string" || typeof projectId !== "string") return null + return ( + <> + + Sessions + + + + ) +} diff --git a/web/mobile/tests/unit/pageFailure.test.ts b/web/mobile/tests/unit/pageFailure.test.ts new file mode 100644 index 0000000000..6bd9afbd20 --- /dev/null +++ b/web/mobile/tests/unit/pageFailure.test.ts @@ -0,0 +1,36 @@ +import {describe, expect, it} from "vitest" + +import {classifyPageFailure} from "../../src/features/sessions/pageFailure" + +describe("classifyPageFailure", () => { + it("treats a query error as a whole-list failure", () => { + expect(classifyPageFailure([], true)).toEqual({failed: true, laterPageFailed: false}) + }) + + it("treats a null first page as a whole-list failure", () => { + expect(classifyPageFailure([null], false)).toEqual({failed: true, laterPageFailed: false}) + }) + + // The case this split exists for: rows are already on screen, so replacing them with a + // full-screen error would throw away everything the reader scrolled through. + it("keeps the list when only a later page fails", () => { + expect(classifyPageFailure([[{id: "a"}], [{id: "b"}], null], false)).toEqual({ + failed: false, + laterPageFailed: true, + }) + }) + + it("reports neither when every page arrived", () => { + expect(classifyPageFailure([[{id: "a"}], []], false)).toEqual({ + failed: false, + laterPageFailed: false, + }) + }) + + it("prefers the whole-list failure when the first page and a later page both failed", () => { + expect(classifyPageFailure([null, [{id: "b"}], null], false)).toEqual({ + failed: true, + laterPageFailed: false, + }) + }) +}) diff --git a/web/oss/docker/Dockerfile.dev b/web/oss/docker/Dockerfile.dev index 5c789a1bae..29ec5b63fb 100644 --- a/web/oss/docker/Dockerfile.dev +++ b/web/oss/docker/Dockerfile.dev @@ -39,6 +39,7 @@ COPY packages/agenta-playground-ui/package.json ./packages/agenta-playground-ui/ COPY packages/agenta-annotation/package.json ./packages/agenta-annotation/ COPY packages/agenta-annotation-ui/package.json ./packages/agenta-annotation-ui/ COPY packages/agenta-sdk/package.json ./packages/agenta-sdk/ +COPY packages/agenta-chat/package.json ./packages/agenta-chat/ # `agenta-api-client` runs `tsc` as a `prepare` lifecycle script during install. # Its source (src/, tsconfig.json) must be present before `pnpm i` runs, so we @@ -89,6 +90,8 @@ COPY --chown=agenta:agenta packages/agenta-annotation/src ./packages/agenta-anno COPY --chown=agenta:agenta packages/agenta-annotation/tsconfig.json ./packages/agenta-annotation/ COPY --chown=agenta:agenta packages/agenta-annotation-ui/src ./packages/agenta-annotation-ui/src COPY --chown=agenta:agenta packages/agenta-annotation-ui/tsconfig.json ./packages/agenta-annotation-ui/ +COPY --chown=agenta:agenta packages/agenta-chat/src ./packages/agenta-chat/src +COPY --chown=agenta:agenta packages/agenta-chat/tsconfig.json ./packages/agenta-chat/ COPY --chown=agenta:agenta oss/src ./oss/src COPY --chown=agenta:agenta oss/public ./oss/public diff --git a/web/packages/agenta-sdk/src/resources.ts b/web/packages/agenta-sdk/src/resources.ts index e4d6632565..6e512ca5ef 100644 --- a/web/packages/agenta-sdk/src/resources.ts +++ b/web/packages/agenta-sdk/src/resources.ts @@ -12,6 +12,7 @@ import {ApplicationsClient} from "@agentaai/api-client/resources/applications" import {EvaluationsClient} from "@agentaai/api-client/resources/evaluations" import {EventsClient} from "@agentaai/api-client/resources/events" import {MountsClient} from "@agentaai/api-client/resources/mounts" +import {ProjectsClient} from "@agentaai/api-client/resources/projects" import {SecretsClient} from "@agentaai/api-client/resources/secrets" import {SessionsClient} from "@agentaai/api-client/resources/sessions" import {TestsetsClient} from "@agentaai/api-client/resources/testsets" @@ -97,6 +98,11 @@ export function getLowPrioritySessionsClient(): SessionsClient { return (_sessionsLowPriority ??= new SessionsClient(withLowPriorityFetch(buildClientOptions()))) } +let _projects: ProjectsClient | undefined +export function getProjectsClient(): ProjectsClient { + return (_projects ??= new ProjectsClient(buildClientOptions())) +} + let _mounts: MountsClient | undefined export function getMountsClient(): MountsClient { return (_mounts ??= new MountsClient(buildClientOptions()))