From 8d97c3c9e49039f230c38b74bfd2f8f2b59695ec Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 00:57:10 +0300
Subject: [PATCH 1/9] chore(mobile): mount and bake @agenta/* packages for the
web-mobile containers
Both dev composes mount web/packages into web-mobile (same line the web
service already has); both dev Dockerfiles now COPY the agenta-chat
manifest and source (they predate the package); Dockerfile.gh copies the
@agenta/* workspace closure (shared, ui, entities, playground, chat, sdk,
api-client full-dir for its prepare build) so pnpm install resolves the
new workspace deps.
Operator runbook (do not run in-session): the live web-mobile container
has neither the new deps nor @agenta/chat. Applying this change requires
a dev web image rebuild + web-mobile recreate:
run.sh --ee --dev --with-mobile --build
Interim bootstrap of the RUNNING container, if needed before the rebuild:
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
---
.../docker-compose/ee/docker-compose.dev.yml | 1 +
.../docker-compose/oss/docker-compose.dev.yml | 1 +
web/ee/docker/Dockerfile.dev | 3 ++
web/mobile/docker/Dockerfile.gh | 28 +++++++++++++++++--
web/oss/docker/Dockerfile.dev | 3 ++
5 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml
index aedff28310..0470b08b7a 100644
--- a/hosting/docker-compose/ee/docker-compose.dev.yml
+++ b/hosting/docker-compose/ee/docker-compose.dev.yml
@@ -90,6 +90,7 @@ services:
command: sh -c 'echo "AGENTA_MOBILE_GATE=$${AGENTA_MOBILE_GATE:-false}" > /app/mobile/.env.development.local && pnpm dev-mobile'
# === STORAGE ============================================== #
volumes:
+ - ../../../web/packages:/app/packages
- ../../../web/mobile/src:/app/mobile/src
- ../../../web/mobile/public:/app/mobile/public
- nextjs-mobile-cache:/app/mobile/.next/cache
diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml
index a7644dacba..65eab66194 100644
--- a/hosting/docker-compose/oss/docker-compose.dev.yml
+++ b/hosting/docker-compose/oss/docker-compose.dev.yml
@@ -89,6 +89,7 @@ services:
command: sh -c 'echo "AGENTA_MOBILE_GATE=$${AGENTA_MOBILE_GATE:-false}" > /app/mobile/.env.development.local && pnpm dev-mobile'
# === STORAGE ============================================== #
volumes:
+ - ../../../web/packages:/app/packages
- ../../../web/mobile/src:/app/mobile/src
- ../../../web/mobile/public:/app/mobile/public
- nextjs-mobile-cache:/app/mobile/.next/cache
diff --git a/web/ee/docker/Dockerfile.dev b/web/ee/docker/Dockerfile.dev
index fe4fcf9aba..d4bd4974d6 100644
--- a/web/ee/docker/Dockerfile.dev
+++ b/web/ee/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
@@ -90,6 +91,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 ee/src ./ee/src
COPY --chown=agenta:agenta ee/public ./ee/public
diff --git a/web/mobile/docker/Dockerfile.gh b/web/mobile/docker/Dockerfile.gh
index fdf12abe3e..3c2434ab99 100644
--- a/web/mobile/docker/Dockerfile.gh
+++ b/web/mobile/docker/Dockerfile.gh
@@ -27,10 +27,21 @@ 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.
+# Manifests first (change less often than source): the mobile app plus the
+# @agenta/* workspace closure it links (chat → entities/playground/shared;
+# entities → sdk/api-client/shared/ui) so `pnpm install` resolves the
+# workspace: deps.
COPY mobile/package.json ./mobile/package.json
+COPY packages/agenta-shared/package.json ./packages/agenta-shared/
+COPY packages/agenta-ui/package.json ./packages/agenta-ui/
+COPY packages/agenta-entities/package.json ./packages/agenta-entities/
+COPY packages/agenta-playground/package.json ./packages/agenta-playground/
+COPY packages/agenta-chat/package.json ./packages/agenta-chat/
+COPY packages/agenta-sdk/package.json ./packages/agenta-sdk/
+
+# `agenta-api-client` runs `tsc` as a `prepare` lifecycle script during
+# install, so its full source must be present before `pnpm install` runs.
+COPY packages/agenta-api-client/ ./packages/agenta-api-client/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm fetch --frozen-lockfile
@@ -38,6 +49,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --offline
+# Shared package config (required by the package tsconfigs), then the
+# workspace package sources for the same closure.
+COPY packages/tsconfig.base.json ./packages/
+COPY packages/css-modules.d.ts ./packages/
+COPY packages/agenta-shared/ ./packages/agenta-shared/
+COPY packages/agenta-ui/ ./packages/agenta-ui/
+COPY packages/agenta-entities/ ./packages/agenta-entities/
+COPY packages/agenta-playground/ ./packages/agenta-playground/
+COPY packages/agenta-chat/ ./packages/agenta-chat/
+COPY packages/agenta-sdk/ ./packages/agenta-sdk/
+
COPY mobile/ ./mobile/
RUN --mount=type=cache,id=turbo-mobile,target=/app/.turbo \
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
From f5692fd6ce8098c4c68afe2d78579f0bb6b4231b Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 08:14:25 +0300
Subject: [PATCH 2/9] feat(mobile): app providers, sdk host pinning, and
route-scoped project state
---
web/mobile/src/features/app/AppProviders.tsx | 33 ++++++++++++++++++++
web/mobile/src/features/app/ContextSync.tsx | 30 ++++++++++++++++++
web/mobile/src/lib/context.ts | 15 +++++++++
web/mobile/src/lib/env.ts | 21 +++++++++++++
web/mobile/src/lib/queryClient.ts | 11 +++++++
web/mobile/src/pages/_app.tsx | 11 +++++--
6 files changed, 118 insertions(+), 3 deletions(-)
create mode 100644 web/mobile/src/features/app/AppProviders.tsx
create mode 100644 web/mobile/src/features/app/ContextSync.tsx
create mode 100644 web/mobile/src/lib/context.ts
create mode 100644 web/mobile/src/lib/env.ts
create mode 100644 web/mobile/src/lib/queryClient.ts
diff --git a/web/mobile/src/features/app/AppProviders.tsx b/web/mobile/src/features/app/AppProviders.tsx
new file mode 100644
index 0000000000..3ba359bcaf
--- /dev/null
+++ b/web/mobile/src/features/app/AppProviders.tsx
@@ -0,0 +1,33 @@
+import type {PropsWithChildren} from "react"
+
+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"
+
+import {getApiUrl} from "@/lib/env"
+import {queryClient} from "@/lib/queryClient"
+
+import {ContextSync} from "./ContextSync"
+
+// Module scope, like the desktop _app: __env.js is beforeInteractive, so
+// window.__env is already populated when this module first evaluates.
+configureAgentaSdk({host: getApiUrl()})
+
+const HydrateAtoms = ({children}: PropsWithChildren) => {
+ useHydrateAtoms([[queryClientAtom, queryClient]])
+ return children
+}
+
+export const AppProviders = ({children}: PropsWithChildren) => (
+
+ {/* default store — @agenta/chat's loadSessionMessages writes through getDefaultStore() */}
+
+
+
+ {children}
+
+
+
+)
diff --git a/web/mobile/src/features/app/ContextSync.tsx b/web/mobile/src/features/app/ContextSync.tsx
new file mode 100644
index 0000000000..ec928afcfb
--- /dev/null
+++ b/web/mobile/src/features/app/ContextSync.tsx
@@ -0,0 +1,30 @@
+import {useEffect} from "react"
+
+import {setProjectIdAtom} from "@agenta/shared/state"
+import {useSetAtom} from "jotai"
+import {useRouter} from "next/router"
+
+import {writeLastContext} from "@/lib/context"
+
+/** Null-rendering: mirrors route params into the shared state @agenta/entities reads. */
+export const ContextSync = () => {
+ const router = useRouter()
+ const setProjectId = useSetAtom(setProjectIdAtom)
+
+ const {workspace_id, project_id} = router.query
+ const workspaceId = typeof workspace_id === "string" ? workspace_id : null
+ const projectId = typeof project_id === "string" ? project_id : null
+
+ useEffect(() => {
+ if (!router.isReady) return
+ setProjectId(projectId)
+ }, [router.isReady, projectId, setProjectId])
+
+ useEffect(() => {
+ if (workspaceId && projectId) {
+ writeLastContext({workspaceId, projectId})
+ }
+ }, [workspaceId, projectId])
+
+ return null
+}
diff --git a/web/mobile/src/lib/context.ts b/web/mobile/src/lib/context.ts
new file mode 100644
index 0000000000..8739715845
--- /dev/null
+++ b/web/mobile/src/lib/context.ts
@@ -0,0 +1,15 @@
+/** Mobile's own last-visited workspace/project, for `/m/` root resolution. */
+export const LAST_CONTEXT_KEY = "agenta:mobile:last-context"
+
+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
+ }
+}
diff --git a/web/mobile/src/lib/env.ts b/web/mobile/src/lib/env.ts
new file mode 100644
index 0000000000..533fe5cfd8
--- /dev/null
+++ b/web/mobile/src/lib/env.ts
@@ -0,0 +1,21 @@
+/**
+ * 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
+ }
+}
+
+export function getEnv(key: string): string {
+ if (typeof window !== "undefined" && window.__env?.[key]) {
+ return window.__env[key] ?? ""
+ }
+ return 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"
/>
-
+
+
+
>
)
}
From 8066580f593fca89ec5f753340db0bc36a325c06 Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 09:09:21 +0300
Subject: [PATCH 3/9] feat(mobile): workspace and project resolution at the
mobile root
---
.../src/features/context/ContextResolver.tsx | 108 ++++++++++++++++++
.../features/context/WorkspaceProjectList.tsx | 41 +++++++
.../context/states/SignedOutNotice.tsx | 8 ++
web/mobile/src/lib/context.ts | 71 ++++++++++++
web/mobile/src/pages/index.tsx | 27 +----
.../p/[project_id]/sessions/index.tsx | 18 +++
web/packages/agenta-sdk/src/resources.ts | 6 +
7 files changed, 257 insertions(+), 22 deletions(-)
create mode 100644 web/mobile/src/features/context/ContextResolver.tsx
create mode 100644 web/mobile/src/features/context/WorkspaceProjectList.tsx
create mode 100644 web/mobile/src/features/context/states/SignedOutNotice.tsx
create mode 100644 web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx
diff --git a/web/mobile/src/features/context/ContextResolver.tsx b/web/mobile/src/features/context/ContextResolver.tsx
new file mode 100644
index 0000000000..98eba8f28e
--- /dev/null
+++ b/web/mobile/src/features/context/ContextResolver.tsx
@@ -0,0 +1,108 @@
+import {useEffect, useMemo, useState} from "react"
+
+import {useQuery} from "@tanstack/react-query"
+import {useRouter} from "next/router"
+
+import {fetchProjects, readDesktopLastUsed, readLastContext, type LastContext} from "@/lib/context"
+
+import {SignedOutNotice} from "./states/SignedOutNotice"
+import {WorkspaceProjectList, type WorkspaceGroup} from "./WorkspaceProjectList"
+
+const sessionsUrl = ({workspaceId, projectId}: LastContext) =>
+ `/w/${workspaceId}/p/${projectId}/sessions`
+
+/** `/m/` root flow: last-context → auto-forward, else fetch projects and pick. */
+export const ContextResolver = () => {
+ const router = useRouter()
+ // useState initializer: read once, client-only (SSR renders the loading branch).
+ const [stored] = useState(() =>
+ typeof window === "undefined" ? null : readLastContext(),
+ )
+
+ const query = useQuery({
+ queryKey: ["mobile", "projects"],
+ queryFn: () => fetchProjects(),
+ enabled: !stored,
+ staleTime: 30_000,
+ })
+ const result = query.data
+
+ const groups = useMemo(() => {
+ if (result?.kind !== "ok") return []
+ const byWorkspace = new Map()
+ for (const project of result.projects) {
+ if (!project.workspace_id) continue
+ const group = byWorkspace.get(project.workspace_id) ?? {
+ workspaceId: project.workspace_id,
+ workspaceName: project.workspace_name ?? "Workspace",
+ projects: [],
+ }
+ group.projects.push(project)
+ byWorkspace.set(project.workspace_id, group)
+ }
+ return [...byWorkspace.values()]
+ }, [result])
+
+ const target = useMemo(() => {
+ if (stored) return stored
+ if (result?.kind !== "ok" || groups.length === 0) return null
+ if (groups.length === 1 && groups[0].projects.length === 1) {
+ return {workspaceId: groups[0].workspaceId, projectId: groups[0].projects[0].project_id}
+ }
+ // Desktop continuity: forward to the desktop's last-used pair when it
+ // still exists in the fetched tree.
+ const desktopLastUsed = readDesktopLastUsed()
+ for (const group of groups) {
+ const projectId = desktopLastUsed[group.workspaceId]
+ if (projectId && group.projects.some((p) => p.project_id === projectId)) {
+ return {workspaceId: group.workspaceId, projectId}
+ }
+ }
+ return null
+ }, [stored, result, groups])
+
+ useEffect(() => {
+ if (target) void router.replace(sessionsUrl(target))
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [target])
+
+ let body
+ if (target || (!stored && query.isPending)) {
+ body = Loading…
+ } else if (result?.kind === "unauthenticated") {
+ body =
+ } else if (result?.kind === "ok" && groups.length > 0) {
+ body =
+ } else {
+ body = (
+
+
+ {result?.kind === "ok" ? "No projects found." : "Something went wrong."}
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
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/lib/context.ts b/web/mobile/src/lib/context.ts
index 8739715845..1f93e8e5e7 100644
--- a/web/mobile/src/lib/context.ts
+++ b/web/mobile/src/lib/context.ts
@@ -1,6 +1,12 @@
+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
@@ -13,3 +19,68 @@ export function writeLastContext(context: LastContext): void {
// 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 parsed = z.array(projectRowSchema).safeParse(data)
+ if (!parsed.success) {
+ console.error("[fetchProjects] response shape drift", parsed.error)
+ return {kind: "error"}
+ }
+ return {kind: "ok", projects: parsed.data}
+ } 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/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
-
-
- Agenta Mobile
-
- Foundation scaffold is alive under /m.
-
-
-
-
+
>
)
}
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..61aa9ef5a2
--- /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"
+
+// Placeholder shell so root context resolution has a navigation target.
+// T5 replaces this with the real sessions list screen.
+export default function SessionsPage() {
+ return (
+ <>
+
+ Sessions
+
+
+
+ Sessions — coming in T5.
+
+
+ >
+ )
+}
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()))
From 92a42cc09ab8a6719f05d1f6588570a6ebee2004 Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 09:43:07 +0300
Subject: [PATCH 4/9] feat(mobile): sessions list with search and windowed
paging
---
.../features/sessions/SessionListScreen.tsx | 68 +++++++++++++++++++
.../src/features/sessions/SessionRow.tsx | 38 +++++++++++
.../features/sessions/SessionSearchBar.tsx | 15 ++++
.../sessions/states/SessionListStates.tsx | 23 +++++++
.../features/sessions/useSessionsInfinite.ts | 30 ++++++++
.../p/[project_id]/sessions/index.tsx | 14 ++--
6 files changed, 181 insertions(+), 7 deletions(-)
create mode 100644 web/mobile/src/features/sessions/SessionListScreen.tsx
create mode 100644 web/mobile/src/features/sessions/SessionRow.tsx
create mode 100644 web/mobile/src/features/sessions/SessionSearchBar.tsx
create mode 100644 web/mobile/src/features/sessions/states/SessionListStates.tsx
create mode 100644 web/mobile/src/features/sessions/useSessionsInfinite.ts
diff --git a/web/mobile/src/features/sessions/SessionListScreen.tsx b/web/mobile/src/features/sessions/SessionListScreen.tsx
new file mode 100644
index 0000000000..9052d0871a
--- /dev/null
+++ b/web/mobile/src/features/sessions/SessionListScreen.tsx
@@ -0,0 +1,68 @@
+import {useEffect, useState} from "react"
+
+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 ?? []
+ // querySessions resolves null on failure — treat a null page like a query error.
+ const failed = query.isError || pages.some((page) => page === null)
+ 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) => (
+
+ ))}
+ {query.hasNextPage ? (
+
+ ) : null}
+
+ )
+ }
+
+ return (
+
+ )
+}
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/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..988e4768cc
--- /dev/null
+++ b/web/mobile/src/features/sessions/useSessionsInfinite.ts
@@ -0,0 +1,30 @@
+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,
+ 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/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx b/web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/index.tsx
index 61aa9ef5a2..d192eac7c9 100644
--- 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
@@ -1,18 +1,18 @@
import Head from "next/head"
+import {useRouter} from "next/router"
+
+import {SessionListScreen} from "@/features/sessions/SessionListScreen"
-// Placeholder shell so root context resolution has a navigation target.
-// T5 replaces this with the real sessions list screen.
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
-
-
- Sessions — coming in T5.
-
-
+
>
)
}
From 9029214eb8553e23f2e47f5c39f8bb81ec77039a Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 09:44:38 +0300
Subject: [PATCH 5/9] feat(mobile): read-only session transcript replay
---
web/mobile/src/features/chat/ChatHeader.tsx | 36 ++++++++++++
web/mobile/src/features/chat/ChatScreen.tsx | 52 +++++++++++++++++
web/mobile/src/features/chat/TurnRow.tsx | 58 +++++++++++++++++++
.../src/features/chat/states/ChatStates.tsx | 12 ++++
.../src/features/chat/useSessionTranscript.ts | 33 +++++++++++
.../p/[project_id]/sessions/[session_id].tsx | 29 ++++++++++
6 files changed, 220 insertions(+)
create mode 100644 web/mobile/src/features/chat/ChatHeader.tsx
create mode 100644 web/mobile/src/features/chat/ChatScreen.tsx
create mode 100644 web/mobile/src/features/chat/TurnRow.tsx
create mode 100644 web/mobile/src/features/chat/states/ChatStates.tsx
create mode 100644 web/mobile/src/features/chat/useSessionTranscript.ts
create mode 100644 web/mobile/src/pages/w/[workspace_id]/p/[project_id]/sessions/[session_id].tsx
diff --git a/web/mobile/src/features/chat/ChatHeader.tsx b/web/mobile/src/features/chat/ChatHeader.tsx
new file mode 100644
index 0000000000..eaccb97f60
--- /dev/null
+++ b/web/mobile/src/features/chat/ChatHeader.tsx
@@ -0,0 +1,36 @@
+import {fetchSessionStream} from "@agenta/entities/session"
+import {useQuery} from "@tanstack/react-query"
+import Link from "next/link"
+
+export const ChatHeader = ({
+ sessionId,
+ projectId,
+ workspaceId,
+}: {
+ sessionId: string
+ projectId: string
+ workspaceId: string
+}) => {
+ const query = useQuery({
+ queryKey: ["mobile", "session-stream", projectId, sessionId],
+ queryFn: () => fetchSessionStream({sessionId, projectId}),
+ enabled: Boolean(projectId && sessionId),
+ staleTime: 30_000,
+ })
+ return (
+
+
+
+ Back
+
+
{query.data?.name ?? "Session"}
+
+
+ Read-only on mobile for now — continue this session on desktop.
+
+
+ )
+}
diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx
new file mode 100644
index 0000000000..2f489077a6
--- /dev/null
+++ b/web/mobile/src/features/chat/ChatScreen.tsx
@@ -0,0 +1,52 @@
+import {useMemo} from "react"
+
+import {buildTurnViewModels, createExecutedToolIdentityCache} from "@agenta/chat/model"
+
+import {ChatHeader} from "./ChatHeader"
+import {ChatEmpty, ChatLoading} from "./states/ChatStates"
+import {TurnRow} from "./TurnRow"
+import {useSessionTranscript} from "./useSessionTranscript"
+
+/** Read-only replay screen — mount it with `key={sessionId}` so per-session state resets. */
+export const ChatScreen = ({
+ sessionId,
+ projectId,
+ workspaceId,
+}: {
+ sessionId: string
+ projectId: string
+ workspaceId: string
+}) => {
+ const {messages, state} = useSessionTranscript(sessionId)
+ // One identity cache per session mount (the screen is keyed by sessionId).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const executedFor = useMemo(() => createExecutedToolIdentityCache(), [sessionId])
+ const turns = useMemo(
+ () => buildTurnViewModels(messages, {busy: false, executedFor}),
+ [messages, executedFor],
+ )
+
+ let body
+ if (state === "loading") {
+ body =
+ } else if (state === "empty") {
+ body =
+ } else {
+ body = (
+
+ {turns
+ .filter((turn) => !turn.hidden)
+ .map((turn) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+ {body}
+
+ )
+}
diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx
new file mode 100644
index 0000000000..35a9aa16a6
--- /dev/null
+++ b/web/mobile/src/features/chat/TurnRow.tsx
@@ -0,0 +1,58 @@
+import {partToolName, rowSummary, type TurnViewModel} from "@agenta/chat/model"
+
+/** One transcript turn: raw aligned text parts, one-line tool summaries, raw error line. */
+export const TurnRow = ({turn}: {turn: TurnViewModel}) => (
+
+
+ {turn.items.map((item) => {
+ if (item.kind === "part") {
+ if (item.part.type === "text") {
+ return (
+
+ {item.part.text}
+
+ )
+ }
+ if (item.part.type === "reasoning") {
+ return (
+
+ {item.part.text}
+
+ )
+ }
+ return null
+ }
+ if (item.kind === "tools") {
+ return (
+
+ {item.parts.map((part, i) => {
+ const summary = rowSummary(part)
+ return (
+
+ {partToolName(part)} — {part.state}
+ {summary ? ` · ${summary}` : ""}
+
+ )
+ })}
+
+ )
+ }
+ // clientTool never occurs — the predicate defaults to false on mobile.
+ return null
+ })}
+ {turn.status.showError ? (
+
+ {turn.status.errorText ?? "Something went wrong."}
+
+ ) : null}
+
+
+)
diff --git a/web/mobile/src/features/chat/states/ChatStates.tsx b/web/mobile/src/features/chat/states/ChatStates.tsx
new file mode 100644
index 0000000000..40d1883fa3
--- /dev/null
+++ b/web/mobile/src/features/chat/states/ChatStates.tsx
@@ -0,0 +1,12 @@
+// Raw one-liner states for the LITE phase (designed states come with the skin pass).
+
+export const ChatLoading = () => (
+ Loading…
+)
+
+/** Also covers history-unavailable — loadSessionMessages resolves null for both. */
+export const ChatEmpty = () => (
+
+ No messages here — this session has no replayable history.
+
+)
diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts
new file mode 100644
index 0000000000..6ae6ac2209
--- /dev/null
+++ b/web/mobile/src/features/chat/useSessionTranscript.ts
@@ -0,0 +1,33 @@
+import {useEffect, useState} from "react"
+
+import {loadSessionMessages} from "@agenta/chat/assets"
+import type {UIMessage} from "ai"
+
+/**
+ * Read-only transcript for one session: server record replay via `loadSessionMessages`
+ * (IndexedDB-restored, revalidation re-delivered through `onRefreshed`). `null` history
+ * collapses into "empty" — raw text covers both no-messages and history-unavailable.
+ */
+export const useSessionTranscript = (sessionId: string) => {
+ const [messages, setMessages] = useState([])
+ const [state, setState] = useState<"loading" | "ready" | "empty">("loading")
+ useEffect(() => {
+ let cancelled = false
+ setState("loading")
+ setMessages([])
+ void loadSessionMessages(sessionId, (fresh) => {
+ // Disk-restore revalidation re-delivery — fresh is non-empty by contract.
+ if (cancelled) return
+ setMessages(fresh)
+ setState("ready")
+ }).then((msgs) => {
+ if (cancelled) return
+ setMessages(msgs ?? [])
+ setState(msgs && msgs.length > 0 ? "ready" : "empty")
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [sessionId])
+ return {messages, state}
+}
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
+
+
+ >
+ )
+}
From 44bb88d667bcd2ac74283ebece1f3e574f4d16b3 Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 10:37:13 +0300
Subject: [PATCH 6/9] fix(mobile): review fixes for the sessions and transcript
flows
---
.github/workflows/17-check-mobile.yml | 1 +
web/mobile/src/features/chat/TurnRow.tsx | 2 +-
web/mobile/src/features/chat/useSessionTranscript.ts | 5 ++++-
web/mobile/src/features/sessions/useSessionsInfinite.ts | 3 +++
4 files changed, 9 insertions(+), 2 deletions(-)
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/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx
index 35a9aa16a6..53cf3a70b2 100644
--- a/web/mobile/src/features/chat/TurnRow.tsx
+++ b/web/mobile/src/features/chat/TurnRow.tsx
@@ -37,7 +37,7 @@ export const TurnRow = ({turn}: {turn: TurnViewModel}) => (
key={part.toolCallId ?? `${item.index}-${i}`}
className="text-muted-foreground text-xs"
>
- {partToolName(part)} — {part.state}
+ {partToolName(part)} — {part.state ?? "pending"}
{summary ? ` · ${summary}` : ""}
)
diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts
index 6ae6ac2209..b0e62a497f 100644
--- a/web/mobile/src/features/chat/useSessionTranscript.ts
+++ b/web/mobile/src/features/chat/useSessionTranscript.ts
@@ -13,15 +13,18 @@ export const useSessionTranscript = (sessionId: string) => {
const [state, setState] = useState<"loading" | "ready" | "empty">("loading")
useEffect(() => {
let cancelled = false
+ let refreshed = false
setState("loading")
setMessages([])
void loadSessionMessages(sessionId, (fresh) => {
// Disk-restore revalidation re-delivery — fresh is non-empty by contract.
if (cancelled) return
+ refreshed = true
setMessages(fresh)
setState("ready")
}).then((msgs) => {
- if (cancelled) return
+ // A fast revalidation can beat this one-shot resolve; never clobber it.
+ if (cancelled || refreshed) return
setMessages(msgs ?? [])
setState(msgs && msgs.length > 0 ? "ready" : "empty")
})
diff --git a/web/mobile/src/features/sessions/useSessionsInfinite.ts b/web/mobile/src/features/sessions/useSessionsInfinite.ts
index 988e4768cc..d5625d524b 100644
--- a/web/mobile/src/features/sessions/useSessionsInfinite.ts
+++ b/web/mobile/src/features/sessions/useSessionsInfinite.ts
@@ -18,6 +18,9 @@ export const useSessionsInfinite = (projectId: string, search: string) =>
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 => {
From 987f93ded40ae6bb2494bb8247dba0d9bebe994a Mon Sep 17 00:00:00 2001
From: Arda Erzin
Date: Mon, 27 Jul 2026 10:37:22 +0300
Subject: [PATCH 7/9] docs(mobile): commit the executed flows-lite plan
---
.../plans/2026-07-26-mobile-flows-lite.md | 382 ++++++++++++++++++
1 file changed, 382 insertions(+)
create mode 100644 docs/design/agenta-mobile/plans/2026-07-26-mobile-flows-lite.md
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" `