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 - + ) } 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 ( +
+
+ +
+ {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/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" ` ) : null} 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/lib/context.ts b/web/mobile/src/lib/context.ts index 1f93e8e5e7..62634fb5c4 100644 --- a/web/mobile/src/lib/context.ts +++ b/web/mobile/src/lib/context.ts @@ -1,3 +1,4 @@ +import {safeParseWithLogging} from "@agenta/entities/shared" import {getProjectsClient} from "@agenta/sdk/resources" import {z} from "zod" @@ -20,6 +21,19 @@ export function writeLastContext(context: LastContext): void { } } +/** + * 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) @@ -72,12 +86,9 @@ export type ProjectsResult = 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} + 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"} 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, + }) + }) +}) From 5b27c42d7eab9d378dd0d473485734203c955aad Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 5 Aug 2026 16:31:15 +0300 Subject: [PATCH 9/9] fix(mobile): keep build-time flags reachable in the deployed image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getEnv` fell back to `process.env[key]` with a computed key. Next only inlines `process.env.SOME_LITERAL`, so a flag that is not also emitted into `/m/__env.js` read as empty in every built image, no matter how the container was configured. Naming each key in a literal map keeps it statically bundled — the same shape the desktop uses in `dynamicEnv.ts`'s `processEnv`. Runtime config still wins: `__env.js` is consulted first, so a value baked at build time only fills the gap. Verified against a real build: with `NEXT_PUBLIC_AGENT_CHAT_STEER=true` set, the client chunk now contains `NEXT_PUBLIC_AGENT_CHAT_STEER:"true"` instead of nothing. --- web/mobile/src/lib/env.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/web/mobile/src/lib/env.ts b/web/mobile/src/lib/env.ts index 533fe5cfd8..eba2894b85 100644 --- a/web/mobile/src/lib/env.ts +++ b/web/mobile/src/lib/env.ts @@ -9,11 +9,24 @@ declare global { } } +/** + * 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] ?? "" } - return process.env[key] ?? "" + // `__env.js` first (runtime config wins), then the build-time value. + return buildEnv[key] ?? process.env[key] ?? "" } export function getApiUrl(): string {