diff --git a/README.md b/README.md
index 581e9e1..1ba36d7 100644
--- a/README.md
+++ b/README.md
@@ -1,269 +1,420 @@
-# Spoon
+
+
+
-Spoon is a self-hostable fork maintenance cockpit.
+Spoon
-Forking a project should not mean supporting it alone. Spoon tracks managed
-forks, called **Spoons**, watches upstream for drift, automatically syncs clean
-forks when it can, and opens durable **Threads** when upstream changes need
-review, context, or code.
+
+ Fork freely & keep them all intimately close to upstream.
+
-This repository is the Spoon application itself, not a generic starter.
+
+ Spoon is a self-hostable fork maintenance cockpit built around managed forks,
+ durable maintenance threads, and OpenCode-powered workspaces.
+
-## What Spoon Does
+
+ What this is
+ ·
+ Product model
+ ·
+ Architecture
+ ·
+ Environment
+
-- Tracks GitHub-backed managed forks and their upstream projects.
-- Shows raw and effective drift, fork-only commits, pull requests, clone URLs,
- additional remotes, sync history, and open maintenance work.
-- Uses Threads as the product center for upstream reviews, merge conflicts,
- ignored commits, user-requested changes, worker logs, and draft PR handoff.
-- Auto-syncs clean behind forks when there are no fork-only commits.
-- Creates maintenance threads when custom fork work means upstream changes need
- a decision.
-- Runs optional OpenCode-backed workspaces in isolated agent-job containers.
-- Lets users configure encrypted AI provider profiles, Codex/OpenCode auth,
- per-Spoon secrets, commands, and agent settings.
-- Opens draft PRs for code changes instead of auto-merging custom forks.
+---
-## Current Scope
+## What This Is
-Implemented today:
+Spoon is a private, actively evolving project for making forks less lonely to
+maintain.
-- Public Next.js landing page for Spoon's thread-first maintenance model.
-- Authenticated web routes:
- - `/dashboard`
- - `/spoons`
- - `/spoons/new`
- - `/spoons/[spoonId]`
- - `/spoons/[spoonId]/agent/[jobId]`
- - `/threads`
- - `/threads/[threadId]`
- - `/settings/profile`
- - `/settings/integrations`
- - `/settings/ai-providers`
-- Legacy `/updates` and `/agents` routes redirect into Threads.
-- GitHub App connection, repository listing, fork creation, drift refresh,
- commit/PR cache, and safe sync foundation.
-- Thread-first maintenance model with ignored upstream changes and effective
- drift.
-- Optional `apps/agent-worker` service that claims queued jobs, clones the
- current GitHub fork, starts an isolated workspace, exposes file browsing and
- edits through server-side Next proxies, runs commands, and opens draft PRs.
-- Browser workspace with persisted thread messages, file tree, Monaco editor
- with optional Vim mode, diff view, command panel, logs, artifacts, and draft
- PR actions.
-- Encrypted per-user AI provider profiles and per-Spoon project secrets.
-- Password auth and Authentik/GitHub OAuth through Convex Auth.
-- Expo companion app shell with password and Authentik sign-in.
-- Self-hosted local Convex using Postgres storage.
+Forking a project is easy. Keeping that fork close to upstream after you add
+custom changes is the hard part. Spoon treats a fork as an ongoing relationship:
+it watches upstream, understands fork-only commits, automatically syncs clean
+drift when it can, and opens a durable **Thread** when a decision needs context
+or code.
-Not implemented yet:
+The application is currently GitHub-first. Future provider-neutral fields exist
+in the data model, but GitHub is the active automation surface today.
-- Automatic merge of custom/diverged forks.
-- Git provider automation beyond GitHub.
-- Additional remotes as push targets.
-- Long-running service-stack orchestration inside agent jobs.
-- Direct browser access to worker containers.
-- Production mobile build/release setup.
+## Highlights
+
+- **Managed forks, called Spoons**
+ Track upstream metadata, fork metadata, clone URLs, extra remotes, sync
+ cadence, production-ref strategy, fork-only commits, and pull requests.
+
+- **Thread-first maintenance**
+ Upstream updates, conflict review, ignore decisions, user-requested work,
+ worker output, and draft PR handoff all live inside Threads.
+
+- **Clean drift auto-sync**
+ If upstream moves and the fork has no custom commits, Spoon can fast-forward
+ the fork without creating busywork.
+
+- **Custom forks get context**
+ If the fork has custom commits, Spoon creates a maintenance thread rather than
+ pretending the update is trivial.
+
+- **Effective drift**
+ Spoon keeps raw GitHub drift visible while also tracking ignored upstream
+ changes so irrelevant commits do not keep a fork permanently actionable.
+
+- **OpenCode workspaces**
+ Agent work happens in an isolated workspace with a file tree, browser editor,
+ diff viewer, command panel, logs, artifacts, and draft PR actions.
+
+- **User-owned providers and secrets**
+ AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
+ encrypted. Secrets are redacted from logs and refused from commits when
+ materialized into env files.
+
+- **Draft PR handoff**
+ Code changes become branches and draft pull requests. Spoon does not
+ auto-merge custom forks behind the user's back.
+
+## Product Model
+
+
+Spoons
+
+A **Spoon** is a managed fork. It records the upstream project, the fork
+repository, default branches, sync policy, extra remotes, current drift, cached
+commits, cached pull requests, secrets, and agent settings.
+
+Spoons are the durable project-level objects. They answer:
+
+- What did I fork?
+- Where does my fork live?
+- How far has it drifted?
+- Which commits are mine?
+- Which upstream changes matter?
+- What threads or PRs are open?
+
+
+
+
+Threads
+
+A **Thread** is the durable place where Spoon talks about maintenance work.
+Threads can be created by a user or by the system.
+
+Common thread sources:
+
+- `user_request`: user asks Spoon to change a fork.
+- `upstream_update`: upstream moved and the fork needs review.
+- `merge_conflict`: a sync conflict needs context or code.
+- `manual_review`: user explicitly asks for a review.
+- `system`: internal maintenance coordination.
+
+Threads hold messages, status, outcomes, related sync runs, related jobs,
+workspace links, draft PR links, and ignored upstream decisions.
+
+
+
+
+Maintenance decisions
+
+Spoon's maintenance policy is intentionally conservative:
+
+| Situation | Default action |
+| ------------------------------------------ | ------------------------------------- |
+| No fork-only commits and upstream is ahead | Auto-sync |
+| Fork-only commits and upstream is ahead | Create a maintenance thread |
+| Merge conflicts | Open or continue a workspace thread |
+| Irrelevant upstream changes | Record an intentional ignore decision |
+| Agent/code changes | Open a draft PR |
+
+The goal is to keep forks close without hiding risk or skipping review when
+custom work exists.
+
+
+
+
+OpenCode workspaces
+
+Spoon's optional agent worker is designed to run outside Convex actions. The
+worker claims queued jobs, clones the current GitHub fork, creates a branch,
+starts an isolated workspace, and exposes workspace operations to the Next app
+through server-only API proxies.
+
+Workspace capabilities:
+
+- browse repository files
+- edit files in a browser editor
+- use optional Vim keybindings
+- inspect diffs
+- send thread messages to the agent
+- run configured commands
+- store logs and artifacts
+- push a branch
+- open a draft PR
+
+The browser never receives worker tokens and never talks directly to the worker
+or job container.
+
+
## Architecture
-- `apps/next`: Next.js 16 web app and primary product UI.
-- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and
- draft PR jobs.
-- `apps/expo`: Expo companion app.
-- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and
- HTTP routes.
-- `packages/ui`: shared shadcn-based UI components.
-- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
-- `docker`: local and production Compose files.
-- `scripts`: environment, database, codegen, and CI helpers.
-
-Core domain objects:
-
-- `spoons`: managed fork records.
-- `threads`: durable maintenance and work conversations.
-- `threadMessages`: persisted thread messages.
-- `syncRuns`: upstream checks, sync attempts, and maintenance decisions.
-- `ignoredUpstreamChanges`: intentional ignore decisions that affect effective
- drift.
-- `gitConnections`: Git provider connection metadata.
-- `agentJobs`: worker-executed workspace jobs and PR lifecycle.
-- `agentJobEvents` and `agentJobArtifacts`: logs and structured job outputs.
-- `agentWorkspaceChanges`: recorded file changes from user, agent, or command
- activity.
-- `spoonSecrets`: encrypted per-Spoon environment variables.
-- `spoonAgentSettings`: per-Spoon runtime, branch, command, and env-file
- settings.
-- `aiProviderProfiles`: encrypted provider/auth profiles used by OpenCode.
-
-## Local Setup
-
-Requirements:
-
-- Bun 1.3.10
-- Node 22
-- Docker or Podman
-- Infisical CLI
-
-```sh
-bun install --frozen-lockfile
-infisical login
-infisical init
-bun db:up
-bun dev:next
-```
-
-Local services:
-
-- Next.js: `http://localhost:3000`
-- Convex API: `http://localhost:3210`
-- Convex site HTTP routes: `http://localhost:3211`
-- Convex dashboard: `http://localhost:6791`
-- Convex Postgres: `localhost:5432`
-
-Next and Expo run on the host. Local Convex runs in containers with Postgres
-storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
-Convex, and the dashboard, generates a machine-local Convex admin key in
-`.local/dev.generated.env` when needed, deploys functions/schema, and configures
-local Convex Auth keys.
-
-```sh
-bun db:down # stop; preserve local data
-bun db:down:wipe # remove local data volumes and generated admin key
-```
-
-Use staging services explicitly:
-
-```sh
-INFISICAL_ENV=staging bun dev:next
-```
-
-Run the optional local agent worker in a separate terminal:
-
-```sh
-bun dev:agent
-```
-
-The worker starts an internal HTTP API, defaulting to `http://localhost:3921`,
-for server-side Next route handlers. The browser never receives the worker token
-or talks to this API directly.
-
-The Docker Compose local worker service is disabled by default behind the
-`agent` profile. Build the job image before using Docker-backed jobs:
-
-```sh
-docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest .
-docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker
-```
-
-The job image includes the OpenCode CLI. Rebuild it after changes to
-`docker/agent-job.Dockerfile`.
-
-## Environment Model
-
-Local `dev` and `staging` values come from Infisical through
-`scripts/with-env`. App commands do not fall back to root `.env` files.
-
-Generated local state belongs in:
+
+Workspace layout
```txt
-.local/.generated.env
+.
+├── apps
+│ ├── next # Next.js 16 web app and primary Spoon UI
+│ ├── agent-worker # Optional OpenCode workspace / draft PR worker
+│ └── expo # Expo companion app scaffold
+├── packages
+│ ├── backend # Convex backend package
+│ │ └── convex # Schema, functions, auth, HTTP routes
+│ └── ui # Shared shadcn-based UI components
+├── tools # Shared lint, format, Tailwind, TS, Vitest config
+├── docker # Compose files and worker/job Dockerfiles
+└── scripts # Env, Convex, codegen, database, and CI helpers
```
-CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical.
+
-Useful helpers:
+
+Core tables
+
+| Table | Purpose |
+| ------------------------ | --------------------------------------------------------- |
+| `spoons` | Managed fork records |
+| `threads` | Durable maintenance and work conversations |
+| `threadMessages` | Messages inside threads |
+| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
+| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
+| `gitConnections` | Git provider connection metadata |
+| `spoonRepositoryStates` | Latest cached upstream/fork state |
+| `spoonCommits` | Cached upstream and fork-only commits |
+| `spoonPullRequests` | Cached fork/upstream pull requests |
+| `spoonSecrets` | Encrypted per-Spoon environment variables |
+| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
+| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
+| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
+| `agentJobEvents` | Append-only worker event log |
+| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
+| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
+
+
+
+
+Important routes
+
+| Route | Purpose |
+| --------------------------------- | --------------------------------------- |
+| `/` | Public product landing page |
+| `/dashboard` | Maintenance overview |
+| `/spoons` | Managed fork list |
+| `/spoons/new` | Manual/GitHub Spoon creation |
+| `/spoons/[spoonId]` | Spoon detail dashboard |
+| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
+| `/threads` | Global thread queue |
+| `/threads/[threadId]` | Thread detail |
+| `/settings/profile` | User profile settings |
+| `/settings/integrations` | GitHub and service integration settings |
+| `/settings/ai-providers` | AI/OpenCode provider profiles |
+
+Legacy `/updates` and `/agents` routes redirect into `/threads`.
+
+
+
+## Mobile App
+
+
+Current Expo scope
+
+`apps/expo` is the mobile Spoon client. It is designed to mirror the core web
+product without exposing worker internals or trying to turn a phone into the
+primary code-editing surface.
+
+The mobile app currently supports:
+
+- password, GitHub, and Authentik sign-in
+- Dashboard, Spoons, Threads, Workspace Review, and Settings tabs/screens
+- manual Spoon creation and GitHub-assisted repository tracking
+- Spoon detail views for overview, upstream commits, fork-only commits, PRs,
+ threads, settings, clone URLs, and additional remotes
+- Spoon maintenance settings, agent settings, encrypted secrets, and bulk
+ `.env` paste import
+- thread list/detail, message composer, resolve/cancel actions, and workspace
+ review links
+- GitHub integration status and repository listing
+- AI provider profile management, including Codex/OpenCode auth JSON
+- read-only workspace review for job status, messages, diffs, events,
+ artifacts, and draft PR links
+
+The mobile app intentionally does not currently support:
+
+- live workspace file browsing/editing
+- mobile command execution
+- direct mobile calls to the agent worker HTTP API
+- mobile access to worker/container tokens
+- long-running app preview stacks
+- production app-store/EAS release flow
+
+Mobile workspace editing is deferred until worker authorization and mobile
+editor UX are designed explicitly. For now, the phone is a strong review and
+control surface; the browser remains the code workspace.
+
+
+
+
+Expo validation
+
+Useful mobile checks:
```sh
-sh scripts/with-env dev --
-sh scripts/export-env dev
-bun sync:convex
-bun sync:convex:staging
+bun --filter @spoon/expo lint
+bun --filter @spoon/expo typecheck
+bun --filter @spoon/expo test:unit
+bun --filter @spoon/expo test:component
```
-### Convex Deployment Env
+The Expo unit tests cover pure utilities such as `.env` parsing and formatting.
+The component tests use a lightweight React Native mock layer to exercise shared
+mobile controls, higher-value forms, and route smoke renders without booting a
+native simulator.
-Convex functions and HTTP actions read environment variables from the Convex
-deployment environment, not directly from the host process. OAuth providers,
-GitHub App credentials, UseSend, encryption keys, worker tokens, and Convex Auth
-signing keys must be synced into the selected Convex deployment.
+
-`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
-`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
-values into local Convex first. Run it manually when needed:
+## Environment Reference
-```sh
-sh scripts/sync-convex-env dev
-sh scripts/sync-convex-env staging
-INFISICAL_ENV=staging bun sync:convex
-```
+This project is currently private, so this section is a reference for what the
+application expects rather than public setup documentation.
-For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`,
-`SPOON_WORKER_TOKEN`, and related generated values are created automatically if
-they are not already present. The generated Convex admin key remains
-machine-local in `.local/dev.generated.env`; do not put it in Infisical.
+
+Public Next variables
-Local OAuth callback URLs:
+| Variable | Used for |
+| --------------------------------- | ------------------------------------------- |
+| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
+| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
+| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
+| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
+| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
+| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
+| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
+| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
-```txt
-http://localhost:3211/api/auth/callback/authentik
-http://localhost:3211/api/auth/callback/github
-```
+
-If GitHub App actions fail with `GITHUB_APP_PRIVATE_KEY is not configured`, add
-the full PEM contents to Infisical as `GITHUB_APP_PRIVATE_KEY` and rerun the
-sync command.
+
+Auth and email
-## Development
+| Variable | Used for |
+| ----------------------- | ----------------------------- |
+| `SITE_URL` | Convex Auth site URL |
+| `JWT_PRIVATE_KEY` | Convex Auth signing key |
+| `JWKS` | Convex Auth JWKS |
+| `AUTH_AUTHENTIK_ID` | Authentik OAuth client ID |
+| `AUTH_AUTHENTIK_SECRET` | Authentik OAuth client secret |
+| `AUTH_AUTHENTIK_ISSUER` | Authentik issuer URL |
+| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
+| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
+| `USESEND_API_KEY` | UseSend API key |
+| `USESEND_URL` | UseSend API URL |
+| `USESEND_FROM_EMAIL` | Transactional email sender |
-```sh
-bun dev:next
-bun dev:expo
-bun dev:agent
-```
+
-Physical devices cannot resolve their own `localhost`; override the public
-Convex URL with the development host's LAN address when testing Expo on-device.
+
+GitHub App
-Shared dependency versions belong in root catalogs. Edit the root catalog, run
-`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace.
+| Variable | Used for |
+| ---------------------------- | ---------------------------------- |
+| `GITHUB_APP_ID` | GitHub App ID |
+| `GITHUB_APP_CLIENT_ID` | GitHub App OAuth client ID |
+| `GITHUB_APP_CLIENT_SECRET` | GitHub App OAuth client secret |
+| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM private key |
+| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification secret |
+| `GITHUB_APP_SLUG` | GitHub App slug |
+| `GITHUB_APP_INSTALLATION_ID` | Default/local installation ID |
+| `GITHUB_APP_OWNER` | Default/local installation owner |
-## Validation
+
-Routine checks:
+
+Convex, storage, and runtime
-```sh
-bun lint:ws
-bun format
-bun lint
-bun typecheck
-bun run test
-```
+| Variable | Used for |
+| ----------------------------------- | ----------------------------------------------- |
+| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
+| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
+| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
+| `CONVEX_SITE_ORIGIN` | Convex site-function origin |
+| `CONVEX_SITE_URL` | Site URL seen by Convex Auth |
+| `POSTGRES_URL` | Convex storage database URL |
+| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth |
+| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations |
+| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
+| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
+| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
+| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
+| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
+| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
+| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
+| `SPOON_AGENT_WORKDIR` | Worker work directory |
+| `SPOON_AGENT_NETWORK` | Optional job container network |
-Full local gate without e2e:
+
-```sh
-SKIP_E2E=1 bun run ci:check
-```
+
+Deployment and observability
-Local-stack smoke e2e:
+| Variable | Used for |
+| ----------------------- | --------------------------------- |
+| `NODE_ENV` | Runtime environment |
+| `SENTRY_AUTH_TOKEN` | Sentry source map/upload auth |
+| `REDACT_LOGS_TO_CLIENT` | Convex log redaction setting |
+| `DISABLE_BEACON` | Self-hosted Convex beacon setting |
+| `DO_NOT_REQUIRE_SSL` | Self-hosted Convex SSL behavior |
+| `CI_ENV_FILE` | CI-provided env file path |
-```sh
-bun test:e2e
-```
+
-`bun test:e2e` starts the isolated local stack when needed and stops it
-afterward only when it was not already running.
+## Current Status
-Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in
-test runner instead of the repo's Turbo/Vitest test script.
+
+Implemented
-## Deployment
+- Thread-first Next.js product shell
+- GitHub App connection and fork creation foundation
+- GitHub drift refresh, commit cache, PR cache, and sync-run history
+- Effective drift and ignored upstream change records
+- Global Threads page and Spoon-scoped Threads tab
+- OpenCode-oriented agent worker and browser workspace foundation
+- Monaco editor with optional Vim mode
+- Diff viewer, command panel, worker logs, and artifacts
+- Encrypted Spoon secrets and bulk `.env` import
+- Encrypted AI provider profiles, including Codex/OpenCode auth support
+- Authentik, GitHub, and password auth through Convex Auth
+- Self-hosted Convex/Postgres deployment model
-Production Compose runs the Next image, self-hosted Convex backend/dashboard,
-and Postgres. The deployed Next image is expected to be named
-`spoon-next:latest` in the Gitea registry.
+
-Gitea runs the quality gate first, runs Convex codegen with deployment env,
-builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA
-and `latest` tags. CI never installs or invokes Infisical.
+
+Intentionally not done yet
+
+- Autonomous merging for custom/diverged forks
+- Non-GitHub provider automation
+- Pushing agent branches to additional remotes
+- Long-running preview stacks for arbitrary forked projects
+- Direct browser access to worker containers
+- Public self-hosting setup documentation
+- Production mobile release flow
+
+
+
+## Notes
+
+Spoon is built for a very specific maintenance problem: "I want to fork this
+project, but I do not want to permanently become its maintenance team."
+
+The current product direction is to make that maintenance visible, threaded,
+reviewable, and increasingly automated where it is safe. Clean forks can stay
+close automatically. Custom forks get context, workspace help, and draft PRs.
diff --git a/apps/expo/package.json b/apps/expo/package.json
index 33d413f..49b157f 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -12,6 +12,9 @@
"ios": "expo run:ios",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
+ "test:unit": "vitest run --project unit",
+ "test:integration": "vitest run --project integration --passWithNoTests",
+ "test:component": "vitest run --project component",
"typecheck": "tsc --noEmit",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
},
@@ -27,6 +30,7 @@
"convex": "catalog:convex",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
+ "expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
@@ -57,11 +61,14 @@
"@spoon/prettier-config": "workspace:*",
"@spoon/tailwind-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
+ "@spoon/vitest-config": "workspace:*",
+ "@testing-library/react": "catalog:test",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
- "typescript": "catalog:"
+ "typescript": "catalog:",
+ "vitest": "catalog:test"
},
"prettier": "@spoon/prettier-config"
}
diff --git a/apps/expo/src/app/(app)/_layout.tsx b/apps/expo/src/app/(app)/_layout.tsx
new file mode 100644
index 0000000..b133a41
--- /dev/null
+++ b/apps/expo/src/app/(app)/_layout.tsx
@@ -0,0 +1,56 @@
+import { useEffect } from 'react';
+import { useColorScheme } from 'react-native';
+import { Redirect, Tabs } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { useConvexAuth } from 'convex/react';
+
+import { LoadingState } from '~/components/ui/loading-state';
+
+const iconName = (route: string, focused: boolean) => {
+ if (route === 'dashboard') return focused ? 'grid' : 'grid-outline';
+ if (route === 'spoons') return focused ? 'git-branch' : 'git-branch-outline';
+ if (route === 'threads')
+ return focused ? 'chatbubbles' : 'chatbubbles-outline';
+ return focused ? 'settings' : 'settings-outline';
+};
+
+const AppTabs = () => {
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const colorScheme = useColorScheme();
+
+ useEffect(() => {
+ // Keeps the auth subscription warm while tab routes mount.
+ }, [isAuthenticated]);
+
+ if (isLoading) return ;
+ if (!isAuthenticated) return ;
+
+ return (
+ ({
+ headerShown: false,
+ tabBarActiveTintColor: '#0f766e',
+ tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b',
+ tabBarStyle: {
+ backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
+ borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0',
+ },
+ tabBarIcon: ({ color, focused, size }) => (
+
+ ),
+ })}
+ >
+
+
+
+
+
+
+ );
+};
+
+export default AppTabs;
diff --git a/apps/expo/src/app/(app)/dashboard.tsx b/apps/expo/src/app/(app)/dashboard.tsx
new file mode 100644
index 0000000..819bf57
--- /dev/null
+++ b/apps/expo/src/app/(app)/dashboard.tsx
@@ -0,0 +1,148 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+import { Link, Stack, useRouter } from 'expo-router';
+import { useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { SpoonListRow } from '~/components/spoons/spoon-list-row';
+import { ThreadListRow } from '~/components/threads/thread-list-row';
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { EmptyState } from '~/components/ui/empty-state';
+import { MetricCard } from '~/components/ui/metric-card';
+import { titleize } from '~/utils/format';
+
+const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
+
+const DashboardRoute = () => {
+ const router = useRouter();
+ const [refreshing, setRefreshing] = useState(false);
+ const spoons = useQuery(api.spoons.listMine, {}) ?? [];
+ const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
+ const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
+ const active = spoons.filter((spoon) => spoon.status === 'active').length;
+ const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
+ const diverged = spoons.filter(
+ (spoon) => spoon.syncStatus === 'diverged',
+ ).length;
+ const openThreads = threads.filter(
+ (thread) => !openThreadStatuses.includes(thread.status),
+ );
+ const upstreamWaiting = spoons.reduce(
+ (total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
+ 0,
+ );
+
+ const softRefresh = () => {
+ setRefreshing(true);
+ setTimeout(() => setRefreshing(false), 600);
+ };
+
+ return (
+
+
+
+
+ Dashboard
+
+ Managed forks, upstream drift, and open maintenance threads.
+
+
+
+ New
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maintenance queue
+
+ {openThreads.length ? (
+ openThreads
+ .slice(0, 5)
+ .map((thread) => (
+ router.push(`/threads/${thread._id}`)}
+ />
+ ))
+ ) : (
+
+ )}
+
+
+
+
+ Recent Spoons
+
+ {spoons.length ? (
+ spoons
+ .slice(0, 5)
+ .map((spoon) => (
+ router.push(`/spoons/${spoon._id}`)}
+ />
+ ))
+ ) : (
+
+ )}
+
+
+
+
+ Recent activity
+
+
+ {syncRuns.length ? (
+ syncRuns.map((run) => (
+
+
+ {titleize(run.kind)}
+
+
+ {titleize(run.status)}
+
+
+ ))
+ ) : (
+
+ Upstream checks will appear here.
+
+ )}
+
+
+
+ );
+};
+
+export default DashboardRoute;
diff --git a/apps/expo/src/app/(app)/settings/_layout.tsx b/apps/expo/src/app/(app)/settings/_layout.tsx
new file mode 100644
index 0000000..b6911b9
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+const SettingsLayout = () => ;
+
+export default SettingsLayout;
diff --git a/apps/expo/src/app/(app)/settings/ai-provider-form.tsx b/apps/expo/src/app/(app)/settings/ai-provider-form.tsx
new file mode 100644
index 0000000..d932ce5
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/ai-provider-form.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { Alert, Text } from 'react-native';
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { useAction, useMutation, useQuery } from 'convex/react';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { AiProviderProfileForm } from '~/components/settings/ai-provider-profile-form';
+import { AppScreen } from '~/components/ui/app-screen';
+
+const AiProviderFormRoute = () => {
+ const router = useRouter();
+ const { profileId: rawProfileId } = useLocalSearchParams<{
+ profileId?: string;
+ }>();
+ const profileId = rawProfileId as Id<'aiProviderProfiles'> | undefined;
+ const existing = useQuery(
+ api.aiProviderProfiles.get,
+ profileId ? { profileId } : 'skip',
+ );
+ const save = useAction(api.aiProviderProfilesNode.save);
+ const updateMetadata = useMutation(api.aiProviderProfiles.updateMetadata);
+ const [saving, setSaving] = useState(false);
+
+ const submit = async (values: Parameters[0]) => {
+ setSaving(true);
+ try {
+ if (profileId && !values.secret) {
+ await updateMetadata({
+ baseUrl: values.baseUrl,
+ defaultModel: values.defaultModel,
+ enabled: values.enabled,
+ modelOptions: values.modelOptions,
+ name: values.name,
+ profileId,
+ reasoningEffort: values.reasoningEffort,
+ });
+ } else {
+ await save({ ...values, profileId });
+ }
+ router.replace('/settings/ai-providers');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not save AI provider.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (profileId && !existing) {
+ return (
+
+
+ Loading provider...
+
+ );
+ }
+
+ return (
+
+
+
+ {profileId ? 'Edit provider' : 'New provider'}
+
+
+
+ );
+};
+
+export default AiProviderFormRoute;
diff --git a/apps/expo/src/app/(app)/settings/ai-providers.tsx b/apps/expo/src/app/(app)/settings/ai-providers.tsx
new file mode 100644
index 0000000..e0ccecb
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/ai-providers.tsx
@@ -0,0 +1,92 @@
+import { Alert, Text, View } from 'react-native';
+import { Link, Stack, useRouter } from 'expo-router';
+import { useMutation, useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { AppScreen } from '~/components/ui/app-screen';
+import { Badge } from '~/components/ui/badge';
+import { Button } from '~/components/ui/button';
+import { EmptyState } from '~/components/ui/empty-state';
+import { ListRow } from '~/components/ui/list-row';
+import { titleize } from '~/utils/format';
+
+const AiProvidersRoute = () => {
+ const router = useRouter();
+ const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
+ const setDefault = useMutation(api.aiProviderProfiles.setDefault);
+ const remove = useMutation(api.aiProviderProfiles.remove);
+
+ return (
+
+
+
+
+
+ AI providers
+
+
+ Provider profiles for OpenCode workspaces.
+
+
+
+ New
+
+
+ {profiles.length ? (
+ profiles.map((profile) => (
+
+ router.push(`/settings/ai-provider-form?profileId=${profile._id}`)
+ }
+ >
+
+
+ {profile.isDefault ? (
+
+ ) : null}
+
+
+
+ void setDefault({ profileId: profile._id })}
+ >
+ Set default
+
+
+ Alert.alert('Remove provider', `Remove ${profile.name}?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove',
+ style: 'destructive',
+ onPress: () => void remove({ profileId: profile._id }),
+ },
+ ])
+ }
+ >
+ Remove
+
+
+
+ ))
+ ) : (
+
+ )}
+
+ );
+};
+
+export default AiProvidersRoute;
diff --git a/apps/expo/src/app/(app)/settings/index.tsx b/apps/expo/src/app/(app)/settings/index.tsx
new file mode 100644
index 0000000..fd302fc
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/index.tsx
@@ -0,0 +1,70 @@
+import { Alert, Text } from 'react-native';
+import { Link, Stack } from 'expo-router';
+import { useAuthActions } from '@convex-dev/auth/react';
+import { useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+import { ListRow } from '~/components/ui/list-row';
+
+const SettingsRoute = () => {
+ const { signOut } = useAuthActions();
+ const user = useQuery(api.auth.getUser, {});
+ const connection = useQuery(api.github.getConnection, {});
+ const providers = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
+ const defaultProvider = providers.find((provider) => provider.isDefault);
+
+ return (
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+ Alert.alert('Sign out', 'Sign out of Spoon on this device?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Sign out',
+ style: 'destructive',
+ onPress: () => void signOut(),
+ },
+ ])
+ }
+ >
+ Sign out
+
+
+ );
+};
+
+export default SettingsRoute;
diff --git a/apps/expo/src/app/(app)/settings/integrations.tsx b/apps/expo/src/app/(app)/settings/integrations.tsx
new file mode 100644
index 0000000..0774429
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/integrations.tsx
@@ -0,0 +1,56 @@
+import { useState } from 'react';
+import { Text } from 'react-native';
+import { Stack } from 'expo-router';
+import { useAction, useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { GitHubIntegrationPanel } from '~/components/settings/github-integration-panel';
+import { AppScreen } from '~/components/ui/app-screen';
+
+const IntegrationsRoute = () => {
+ const installUrl = useQuery(api.github.getInstallUrl, {});
+ const connection = useQuery(api.github.getConnection, {});
+ const status = useQuery(api.integrations.getStatus, {});
+ const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
+ const repositories = useAction(api.githubNode.listInstallationRepositories);
+ const [syncing, setSyncing] = useState(false);
+ const [loadingRepos, setLoadingRepos] = useState(false);
+
+ const sync = async () => {
+ setSyncing(true);
+ try {
+ await syncInstallation({});
+ } finally {
+ setSyncing(false);
+ }
+ };
+
+ const listRepos = async () => {
+ setLoadingRepos(true);
+ try {
+ const result = await repositories({});
+ return result.map((repo) => repo.fullName);
+ } finally {
+ setLoadingRepos(false);
+ }
+ };
+
+ return (
+ void sync()} refreshing={syncing}>
+
+ Integrations
+
+
+ );
+};
+
+export default IntegrationsRoute;
diff --git a/apps/expo/src/app/(app)/settings/profile.tsx b/apps/expo/src/app/(app)/settings/profile.tsx
new file mode 100644
index 0000000..8ce9b60
--- /dev/null
+++ b/apps/expo/src/app/(app)/settings/profile.tsx
@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import { Alert, Text } from 'react-native';
+import { Stack } from 'expo-router';
+import { useAction, useMutation, useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { Field } from '~/components/ui/field';
+import { titleize } from '~/utils/format';
+
+const ProfileRoute = () => {
+ const user = useQuery(api.auth.getUser, {});
+ const provider = useQuery(api.auth.getUserProvider, {});
+ const updateUser = useMutation(api.auth.updateUser);
+ const updatePassword = useAction(api.auth.updateUserPassword);
+ const [name, setName] = useState(user?.name ?? '');
+ const [email, setEmail] = useState(user?.email ?? '');
+ const [currentPassword, setCurrentPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [savingProfile, setSavingProfile] = useState(false);
+ const [savingPassword, setSavingPassword] = useState(false);
+
+ const saveProfile = async () => {
+ setSavingProfile(true);
+ try {
+ await updateUser({ name, email });
+ Alert.alert('Saved', 'Profile updated.');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not save profile.');
+ } finally {
+ setSavingProfile(false);
+ }
+ };
+
+ const savePassword = async () => {
+ setSavingPassword(true);
+ try {
+ await updatePassword({ currentPassword, newPassword });
+ setCurrentPassword('');
+ setNewPassword('');
+ Alert.alert('Saved', 'Password updated.');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not update password.');
+ } finally {
+ setSavingPassword(false);
+ }
+ };
+
+ return (
+
+
+ Profile
+
+
+ Email is currently managed by {titleize(provider ?? 'your provider')}.
+
+
+
+ void saveProfile()}>
+ {savingProfile ? 'Saving...' : 'Save profile'}
+
+
+ {provider === 'password' ? (
+
+ Password
+
+
+ void savePassword()}
+ >
+ {savingPassword ? 'Updating...' : 'Update password'}
+
+
+ ) : (
+
+
+ Password changes are hidden because this account is currently using{' '}
+ {titleize(provider ?? 'an OAuth provider')}.
+
+
+ )}
+
+ );
+};
+
+export default ProfileRoute;
diff --git a/apps/expo/src/app/(app)/spoons/[spoonId].tsx b/apps/expo/src/app/(app)/spoons/[spoonId].tsx
new file mode 100644
index 0000000..c6a2bad
--- /dev/null
+++ b/apps/expo/src/app/(app)/spoons/[spoonId].tsx
@@ -0,0 +1,296 @@
+import { useState } from 'react';
+import { Alert, Text, View } from 'react-native';
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { useAction, useMutation, useQuery } from 'convex/react';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import type { SpoonDetailSegment } from '~/components/spoons/segment-control';
+import { SegmentControl } from '~/components/spoons/segment-control';
+import { SpoonDetailFork } from '~/components/spoons/spoon-detail-fork';
+import { SpoonDetailOverview } from '~/components/spoons/spoon-detail-overview';
+import { SpoonDetailPrs } from '~/components/spoons/spoon-detail-prs';
+import { SpoonDetailSettings } from '~/components/spoons/spoon-detail-settings';
+import { SpoonDetailThreads } from '~/components/spoons/spoon-detail-threads';
+import { SpoonDetailUpstream } from '~/components/spoons/spoon-detail-upstream';
+import { SpoonStatusBadge } from '~/components/spoons/spoon-status-badge';
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+
+const SpoonDetailRoute = () => {
+ const router = useRouter();
+ const { spoonId: rawSpoonId } = useLocalSearchParams<{ spoonId: string }>();
+ const spoonId = rawSpoonId as Id<'spoons'>;
+ const [segment, setSegment] = useState('overview');
+ const [threadPrompt, setThreadPrompt] = useState('');
+ const [pending, setPending] = useState();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const details = useQuery(api.spoons.getDetails, { spoonId });
+ const upstreamCommits =
+ useQuery(api.spoonCommits.listForSpoon, {
+ limit: 50,
+ side: 'upstream',
+ spoonId,
+ }) ?? [];
+ const forkCommits =
+ useQuery(api.spoonCommits.listForSpoon, {
+ limit: 50,
+ side: 'fork',
+ spoonId,
+ }) ?? [];
+ const pullRequests =
+ useQuery(api.spoonPullRequests.listForSpoon, { limit: 50, spoonId }) ?? [];
+ const remotes = useQuery(api.spoonRemotes.listForSpoon, { spoonId }) ?? [];
+ const threads =
+ useQuery(api.threads.listForSpoon, { limit: 25, spoonId }) ?? [];
+ const spoonSettings = useQuery(api.spoonSettings.getForSpoon, { spoonId });
+ const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
+ spoonId,
+ });
+ const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
+ const providerProfiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
+
+ const refresh = useAction(api.githubSync.refreshSpoonGithubState);
+ const sync = useAction(api.githubSync.syncForkWithUpstream);
+ const updateSpoonSettings = useMutation(api.spoons.updateSettings);
+ const updateMaintenanceSettings = useMutation(api.spoonSettings.update);
+ const updateAgentSettings = useMutation(api.spoonAgentSettings.update);
+ const createThread = useMutation(api.threads.createUserThread);
+ const createSecret = useAction(api.spoonSecretsNode.create);
+ const removeSecretMutation = useMutation(api.spoonSecrets.remove);
+ const createRemote = useMutation(api.spoonRemotes.create);
+ const removeRemoteMutation = useMutation(api.spoonRemotes.remove);
+
+ const runRefresh = async () => {
+ setRefreshing(true);
+ setPending('refresh');
+ try {
+ await refresh({ spoonId });
+ Alert.alert('Refresh started', 'Spoon is checking GitHub state.');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not refresh this Spoon.');
+ } finally {
+ setRefreshing(false);
+ setPending(undefined);
+ }
+ };
+
+ const runSync = async () => {
+ setPending('sync');
+ try {
+ await sync({ spoonId });
+ Alert.alert('Sync started', 'Spoon is syncing the fork.');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not sync this Spoon.');
+ } finally {
+ setPending(undefined);
+ }
+ };
+
+ const submitThread = async () => {
+ if (!threadPrompt.trim()) return;
+ setPending('thread');
+ try {
+ const threadId = await createThread({
+ envFilePath:
+ agentSettings?.envFilePath === 'custom'
+ ? agentSettings.customEnvFilePath
+ : agentSettings?.envFilePath,
+ materializeEnvFile: agentSettings?.materializeEnvFileByDefault,
+ prompt: threadPrompt,
+ spoonId,
+ });
+ setThreadPrompt('');
+ router.push(`/threads/${threadId}`);
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not create thread.');
+ } finally {
+ setPending(undefined);
+ }
+ };
+
+ if (!details) {
+ return (
+
+
+ Loading Spoon...
+
+ );
+ }
+
+ const { effectiveUpstreamAheadBy, spoon } = details;
+ const canSync =
+ spoon.provider === 'github' &&
+ (spoon.syncStatus === 'behind' || spoon.syncStatus === 'up_to_date') &&
+ (spoon.forkAheadBy ?? 0) === 0;
+
+ const settingsActions = {
+ addRemote: async (label: string, url: string) => {
+ setPending('addRemote');
+ try {
+ await createRemote({ label, spoonId, url });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ addSecret: async (name: string, value: string) => {
+ setPending('addSecret');
+ try {
+ await createSecret({ name, spoonId, value });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ importSecrets: async (items: { name: string; value: string }[]) => {
+ setPending('importSecrets');
+ let failed = 0;
+ try {
+ for (const item of items) {
+ try {
+ await createSecret({ name: item.name, spoonId, value: item.value });
+ } catch (error) {
+ failed += 1;
+ console.error(error);
+ }
+ }
+ if (failed > 0) {
+ throw new Error(
+ `${items.length - failed} imported, ${failed} failed.`,
+ );
+ }
+ } finally {
+ setPending(undefined);
+ }
+ },
+ removeRemote: async (remoteId: string) => {
+ setPending(`remote:${remoteId}`);
+ try {
+ await removeRemoteMutation({
+ remoteId: remoteId as Id<'spoonRemotes'>,
+ });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ removeSecret: async (secretId: string) => {
+ setPending(`secret:${secretId}`);
+ try {
+ await removeSecretMutation({
+ secretId: secretId as Id<'spoonSecrets'>,
+ });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ updateAgent: async (patch: Record) => {
+ setPending('settings');
+ try {
+ await updateAgentSettings({ spoonId, ...patch });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ updateMaintenance: async (patch: Record) => {
+ setPending('settings');
+ try {
+ await updateMaintenanceSettings({ spoonId, ...patch });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ updateSpoon: async (patch: Record) => {
+ setPending('settings');
+ try {
+ await updateSpoonSettings({ spoonId, ...patch });
+ } finally {
+ setPending(undefined);
+ }
+ },
+ };
+
+ return (
+ void runRefresh()} refreshing={refreshing}>
+
+
+ {spoon.name}
+
+
+
+
+
+
+
+ void runRefresh()}
+ >
+ {pending === 'refresh' ? 'Refreshing...' : 'Refresh'}
+
+ void runSync()}
+ >
+ {pending === 'sync' ? 'Syncing...' : 'Sync fork'}
+
+
+
+
+
+ {segment === 'overview' ? (
+
+ ) : null}
+ {segment === 'upstream' ? (
+
+ ) : null}
+ {segment === 'fork' ? : null}
+ {segment === 'prs' ? (
+
+ ) : null}
+ {segment === 'threads' ? (
+ void submitThread()}
+ onOpenThread={(threadId) => router.push(`/threads/${threadId}`)}
+ />
+ ) : null}
+ {segment === 'settings' ? (
+
+ ) : null}
+
+ );
+};
+
+export default SpoonDetailRoute;
diff --git a/apps/expo/src/app/(app)/spoons/_layout.tsx b/apps/expo/src/app/(app)/spoons/_layout.tsx
new file mode 100644
index 0000000..f911cc4
--- /dev/null
+++ b/apps/expo/src/app/(app)/spoons/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+const SpoonsLayout = () => ;
+
+export default SpoonsLayout;
diff --git a/apps/expo/src/app/(app)/spoons/index.tsx b/apps/expo/src/app/(app)/spoons/index.tsx
new file mode 100644
index 0000000..b53edde
--- /dev/null
+++ b/apps/expo/src/app/(app)/spoons/index.tsx
@@ -0,0 +1,81 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+import { Link, Stack, useRouter } from 'expo-router';
+import { useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { SpoonListRow } from '~/components/spoons/spoon-list-row';
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+import { EmptyState } from '~/components/ui/empty-state';
+import { MetricCard } from '~/components/ui/metric-card';
+
+const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
+
+const SpoonsRoute = () => {
+ const router = useRouter();
+ const [refreshing, setRefreshing] = useState(false);
+ const spoons = useQuery(api.spoons.listMine, {}) ?? [];
+ const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
+ const active = spoons.filter((spoon) => spoon.status === 'active').length;
+ const upstreamWaiting = spoons.reduce(
+ (total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
+ 0,
+ );
+
+ const openThreadsFor = (spoonId: string) =>
+ threads.filter(
+ (thread) =>
+ thread.spoonId === spoonId &&
+ !openThreadStatuses.includes(thread.status),
+ ).length;
+
+ const softRefresh = () => {
+ setRefreshing(true);
+ setTimeout(() => setRefreshing(false), 600);
+ };
+
+ return (
+
+
+
+
+ Spoons
+
+ Managed forks and their relationship with upstream.
+
+
+
+ New
+
+
+
+
+
+
+
+
+
+
+ {spoons.length ? (
+ spoons.map((spoon) => (
+ router.push(`/spoons/${spoon._id}`)}
+ />
+ ))
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default SpoonsRoute;
diff --git a/apps/expo/src/app/(app)/spoons/new.tsx b/apps/expo/src/app/(app)/spoons/new.tsx
new file mode 100644
index 0000000..10ec40e
--- /dev/null
+++ b/apps/expo/src/app/(app)/spoons/new.tsx
@@ -0,0 +1,396 @@
+import { useState } from 'react';
+import { Alert, Linking, Text, View } from 'react-native';
+import { Stack, useRouter } from 'expo-router';
+import { useAction, useMutation, useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { AppScreen } from '~/components/ui/app-screen';
+import { Badge } from '~/components/ui/badge';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { Field } from '~/components/ui/field';
+import { FormSection } from '~/components/ui/form-section';
+import { PillTabs } from '~/components/ui/pill-tabs';
+import { SheetSelect } from '~/components/ui/sheet-select';
+
+type CreateMode = 'manual' | 'github';
+type Provider = 'github' | 'gitea' | 'gitlab' | 'other';
+type Visibility = 'public' | 'private' | 'internal' | 'unknown';
+type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
+type SyncCadence = 'daily' | 'weekly' | 'manual';
+type ProductionRefStrategy =
+ | 'default_branch'
+ | 'latest_release'
+ | 'tag_pattern';
+
+type Repository = Awaited<
+ ReturnType<
+ ReturnType<
+ typeof useAction
+ >
+ >
+>[number];
+
+const NewSpoonRoute = () => {
+ const router = useRouter();
+ const createManual = useMutation(api.spoons.createManual);
+ const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
+ const listRepositories = useAction(
+ api.githubNode.listInstallationRepositories,
+ );
+ const installUrl = useQuery(api.github.getInstallUrl, {});
+ const connection = useQuery(api.github.getConnection, {});
+ const [mode, setMode] = useState('manual');
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [provider, setProvider] = useState('github');
+ const [upstreamOwner, setUpstreamOwner] = useState('');
+ const [upstreamRepo, setUpstreamRepo] = useState('');
+ const [upstreamDefaultBranch, setUpstreamDefaultBranch] = useState('main');
+ const [upstreamUrl, setUpstreamUrl] = useState('');
+ const [forkOwner, setForkOwner] = useState('');
+ const [forkRepo, setForkRepo] = useState('');
+ const [forkDefaultBranch, setForkDefaultBranch] = useState('main');
+ const [forkUrl, setForkUrl] = useState('');
+ const [visibility, setVisibility] = useState('unknown');
+ const [maintenanceMode, setMaintenanceMode] =
+ useState('watch');
+ const [syncCadence, setSyncCadence] = useState('daily');
+ const [productionRefStrategy, setProductionRefStrategy] =
+ useState('default_branch');
+ const [tagPattern, setTagPattern] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [loadingRepos, setLoadingRepos] = useState(false);
+ const [repositories, setRepositories] = useState([]);
+
+ const submitManual = async () => {
+ if (!name || !upstreamOwner || !upstreamRepo || !upstreamUrl) {
+ Alert.alert('Missing fields', 'Name and upstream metadata are required.');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const spoonId = await createManual({
+ description: description || undefined,
+ forkDefaultBranch: forkDefaultBranch || undefined,
+ forkOwner: forkOwner || undefined,
+ forkRepo: forkRepo || undefined,
+ forkUrl: forkUrl || undefined,
+ maintenanceMode,
+ name,
+ productionRefStrategy,
+ provider,
+ syncCadence,
+ tagPattern: tagPattern || undefined,
+ upstreamDefaultBranch,
+ upstreamOwner,
+ upstreamRepo,
+ upstreamUrl,
+ visibility,
+ });
+ router.replace(`/spoons/${spoonId}`);
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not create Spoon', 'Check the fields and try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const loadRepos = async () => {
+ setLoadingRepos(true);
+ try {
+ const result = await listRepositories({});
+ setRepositories(result);
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not list repositories.');
+ } finally {
+ setLoadingRepos(false);
+ }
+ };
+
+ const createFromRepo = async (repo: Repository) => {
+ setSubmitting(true);
+ try {
+ const upstreamOwnerValue = upstreamOwner.trim() || repo.owner;
+ const upstreamRepoValue = upstreamRepo.trim() || repo.name;
+ const upstreamUrlValue = upstreamUrl.trim() || repo.url;
+ const spoonId = await createManual({
+ forkDefaultBranch: repo.defaultBranch,
+ forkOwner: repo.owner,
+ forkRepo: repo.name,
+ forkUrl: repo.url,
+ maintenanceMode: 'watch',
+ name: repo.name,
+ productionRefStrategy: 'default_branch',
+ provider: 'github',
+ syncCadence: 'daily',
+ upstreamDefaultBranch: repo.defaultBranch,
+ upstreamOwner: upstreamOwnerValue,
+ upstreamRepo: upstreamRepoValue,
+ upstreamUrl: upstreamUrlValue,
+ visibility: repo.private ? 'private' : 'public',
+ });
+ router.replace(`/spoons/${spoonId}`);
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not create Spoon from repository.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const confirmCreateFromRepo = (repo: Repository) => {
+ const message = repo.fork
+ ? 'GitHub did not provide parent repository metadata here. Add upstream fields above if you want Spoon to compare against the original project immediately.'
+ : 'This will create a manual Spoon record using this repository as both upstream and fork unless you add upstream fields above.';
+
+ Alert.alert('Create Spoon from repository metadata?', message, [
+ { style: 'cancel', text: 'Cancel' },
+ {
+ onPress: () => void createFromRepo(repo),
+ text: 'Create Spoon',
+ },
+ ]);
+ };
+
+ const syncGithub = async () => {
+ setLoadingRepos(true);
+ try {
+ await syncInstallation({});
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not sync GitHub installation.');
+ } finally {
+ setLoadingRepos(false);
+ }
+ };
+
+ return (
+
+
+
+ New Spoon
+
+ Create a managed fork record manually or from GitHub.
+
+
+
+
+ {mode === 'manual' ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {productionRefStrategy === 'tag_pattern' ? (
+
+ ) : null}
+
+ void submitManual()}>
+ {submitting ? 'Creating...' : 'Create Spoon'}
+
+ >
+ ) : (
+
+
+ Connection
+
+
+ {installUrl ? (
+ void Linking.openURL(installUrl)}>
+ Install or manage GitHub App
+
+ ) : null}
+
+ void syncGithub()}
+ >
+ Sync
+
+ void loadRepos()}
+ >
+ {loadingRepos ? 'Loading...' : 'Load repositories'}
+
+
+
+ Optional upstream fields are used when the selected repository is a
+ fork. If you leave them blank, Spoon tracks the selected repository
+ as both upstream and fork until you correct it later.
+
+
+
+
+ {!loadingRepos && connection && repositories.length === 0 ? (
+
+
+ Load accessible repositories to create a Spoon from GitHub
+ metadata.
+
+
+ ) : null}
+ {repositories.map((repo) => (
+
+
+ {repo.fullName}
+
+
+ {repo.private ? 'Private' : 'Public'} ·{' '}
+ {repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch}
+
+ confirmCreateFromRepo(repo)}
+ >
+ Create Spoon from metadata
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default NewSpoonRoute;
diff --git a/apps/expo/src/app/(app)/threads/[threadId].tsx b/apps/expo/src/app/(app)/threads/[threadId].tsx
new file mode 100644
index 0000000..6cde4d7
--- /dev/null
+++ b/apps/expo/src/app/(app)/threads/[threadId].tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react';
+import { Alert, Linking, Text, View } from 'react-native';
+import { Link, Stack, useLocalSearchParams } from 'expo-router';
+import { useMutation, useQuery } from 'convex/react';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { ThreadMessageList } from '~/components/threads/thread-message-list';
+import { ThreadStatusBadge } from '~/components/threads/thread-status-badge';
+import { AppScreen } from '~/components/ui/app-screen';
+import { Badge } from '~/components/ui/badge';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { ConfirmButton } from '~/components/ui/confirm-button';
+import { Field } from '~/components/ui/field';
+import { formatDateTime, titleize } from '~/utils/format';
+
+const ThreadDetailRoute = () => {
+ const { threadId: rawThreadId } = useLocalSearchParams<{
+ threadId: string;
+ }>();
+ const threadId = rawThreadId as Id<'threads'>;
+ const details = useQuery(api.threads.get, { threadId });
+ const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
+ const appendMessage = useMutation(api.threads.appendUserMessage);
+ const markResolved = useMutation(api.threads.markResolved);
+ const cancel = useMutation(api.threads.cancel);
+ const [message, setMessage] = useState('');
+ const [pending, setPending] = useState();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const send = async () => {
+ if (!message.trim()) return;
+ setPending('send');
+ try {
+ await appendMessage({ threadId, content: message });
+ setMessage('');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not send message.');
+ } finally {
+ setPending(undefined);
+ }
+ };
+
+ const softRefresh = () => {
+ setRefreshing(true);
+ setTimeout(() => setRefreshing(false), 600);
+ };
+
+ const resolveThread = async () => {
+ setPending('resolve');
+ try {
+ await markResolved({ threadId });
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not resolve thread.');
+ } finally {
+ setPending(undefined);
+ }
+ };
+
+ const cancelThread = async () => {
+ setPending('cancel');
+ try {
+ await cancel({ threadId });
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not cancel thread.');
+ } finally {
+ setPending(undefined);
+ }
+ };
+
+ if (!details) {
+ return (
+
+
+ Loading thread...
+
+ );
+ }
+
+ const { thread, spoon, latestJob } = details;
+ const pullRequestUrl = latestJob?.pullRequestUrl;
+ const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes(
+ thread.status,
+ );
+
+ return (
+
+
+
+
+ {thread.title}
+
+
+
+
+ {thread.maintenanceOutcome ? (
+
+ ) : null}
+
+
+ Updated {formatDateTime(thread.updatedAt)}
+
+
+
+ {spoon ? (
+
+ Spoon
+
+ {spoon.name}
+
+
+ Open Spoon
+
+
+ ) : null}
+
+ {latestJob ? (
+
+ Latest job
+
+ {titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)}
+
+
+ Branch: {latestJob.workBranch}
+
+
+ Open workspace review
+
+ {pullRequestUrl ? (
+ void Linking.openURL(pullRequestUrl)}>
+ Open draft PR
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ Reply
+
+ void send()}
+ >
+ {pending === 'send' ? 'Sending...' : 'Send message'}
+
+
+
+
+ void resolveThread()}
+ >
+ {pending === 'resolve' ? 'Resolving...' : 'Resolve'}
+
+ void cancelThread()}
+ >
+ {pending === 'cancel' ? 'Cancelling...' : 'Cancel'}
+
+
+
+ );
+};
+
+export default ThreadDetailRoute;
diff --git a/apps/expo/src/app/(app)/threads/_layout.tsx b/apps/expo/src/app/(app)/threads/_layout.tsx
new file mode 100644
index 0000000..8bc6af2
--- /dev/null
+++ b/apps/expo/src/app/(app)/threads/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+const ThreadsLayout = () => ;
+
+export default ThreadsLayout;
diff --git a/apps/expo/src/app/(app)/threads/index.tsx b/apps/expo/src/app/(app)/threads/index.tsx
new file mode 100644
index 0000000..0487a50
--- /dev/null
+++ b/apps/expo/src/app/(app)/threads/index.tsx
@@ -0,0 +1,74 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+import { Stack, useRouter } from 'expo-router';
+import { useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import type { PillTab } from '~/components/ui/pill-tabs';
+import { ThreadListRow } from '~/components/threads/thread-list-row';
+import { AppScreen } from '~/components/ui/app-screen';
+import { EmptyState } from '~/components/ui/empty-state';
+import { PillTabs } from '~/components/ui/pill-tabs';
+
+type StatusFilter =
+ | 'all'
+ | 'open'
+ | 'running'
+ | 'waiting_for_user'
+ | 'resolved';
+
+const filters: PillTab[] = [
+ { label: 'All', value: 'all' },
+ { label: 'Open', value: 'open' },
+ { label: 'Running', value: 'running' },
+ { label: 'Waiting', value: 'waiting_for_user' },
+ { label: 'Resolved', value: 'resolved' },
+];
+
+const ThreadsRoute = () => {
+ const router = useRouter();
+ const [status, setStatus] = useState('all');
+ const [refreshing, setRefreshing] = useState(false);
+ const threads =
+ useQuery(api.threads.listMine, {
+ limit: 50,
+ status,
+ }) ?? [];
+
+ const softRefresh = () => {
+ setRefreshing(true);
+ setTimeout(() => setRefreshing(false), 600);
+ };
+
+ return (
+
+
+
+ Threads
+
+ Maintenance decisions, user requests, and workspace handoffs.
+
+
+
+
+ {threads.length ? (
+ threads.map((thread) => (
+ router.push(`/threads/${thread._id}`)}
+ />
+ ))
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default ThreadsRoute;
diff --git a/apps/expo/src/app/(app)/workspace/[jobId].tsx b/apps/expo/src/app/(app)/workspace/[jobId].tsx
new file mode 100644
index 0000000..1e4746b
--- /dev/null
+++ b/apps/expo/src/app/(app)/workspace/[jobId].tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react';
+import { Alert, Text, View } from 'react-native';
+import { Stack, useLocalSearchParams } from 'expo-router';
+import { useMutation, useQuery } from 'convex/react';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import type { PillTab } from '~/components/ui/pill-tabs';
+import { AppScreen } from '~/components/ui/app-screen';
+import { PillTabs } from '~/components/ui/pill-tabs';
+import { WorkspaceArtifacts } from '~/components/workspace/workspace-artifacts';
+import { WorkspaceEvents } from '~/components/workspace/workspace-events';
+import { WorkspaceMessages } from '~/components/workspace/workspace-messages';
+import { WorkspaceSummary } from '~/components/workspace/workspace-summary';
+
+type WorkspaceTab = 'status' | 'messages' | 'diffs' | 'events' | 'artifacts';
+
+const tabs: PillTab[] = [
+ { label: 'Status', value: 'status' },
+ { label: 'Messages', value: 'messages' },
+ { label: 'Diffs', value: 'diffs' },
+ { label: 'Events', value: 'events' },
+ { label: 'Artifacts', value: 'artifacts' },
+];
+
+const WorkspaceRoute = () => {
+ const { jobId: rawJobId } = useLocalSearchParams<{ jobId: string }>();
+ const jobId = rawJobId as Id<'agentJobs'>;
+ const [tab, setTab] = useState('status');
+ const [refreshing, setRefreshing] = useState(false);
+ const [cancelling, setCancelling] = useState(false);
+ const job = useQuery(api.agentJobs.get, { jobId });
+ const messages = useQuery(api.agentJobs.listMessages, { jobId }) ?? [];
+ const events =
+ useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
+ const artifacts = useQuery(api.agentJobs.listArtifacts, { jobId }) ?? [];
+ const cancel = useMutation(api.agentJobs.cancel);
+
+ const softRefresh = () => {
+ setRefreshing(true);
+ setTimeout(() => setRefreshing(false), 600);
+ };
+
+ const cancelJob = async () => {
+ setCancelling(true);
+ try {
+ await cancel({ jobId });
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not cancel job.');
+ } finally {
+ setCancelling(false);
+ }
+ };
+
+ if (!job) {
+ return (
+
+
+ Loading workspace...
+
+ );
+ }
+
+ return (
+
+
+
+
+ Workspace review
+
+
+ Inspect the active job without exposing worker internals to mobile.
+
+
+
+ {tab === 'status' ? (
+ void cancelJob()}
+ />
+ ) : null}
+ {tab === 'messages' ? : null}
+ {tab === 'diffs' ? (
+
+ ) : null}
+ {tab === 'events' ? : null}
+ {tab === 'artifacts' ? (
+
+ ) : null}
+
+ );
+};
+
+export default WorkspaceRoute;
diff --git a/apps/expo/src/app/(app)/workspace/_layout.tsx b/apps/expo/src/app/(app)/workspace/_layout.tsx
new file mode 100644
index 0000000..3823f21
--- /dev/null
+++ b/apps/expo/src/app/(app)/workspace/_layout.tsx
@@ -0,0 +1,5 @@
+import { Stack } from 'expo-router';
+
+const WorkspaceLayout = () => ;
+
+export default WorkspaceLayout;
diff --git a/apps/expo/src/app/(auth)/sign-in.tsx b/apps/expo/src/app/(auth)/sign-in.tsx
new file mode 100644
index 0000000..839e6fa
--- /dev/null
+++ b/apps/expo/src/app/(auth)/sign-in.tsx
@@ -0,0 +1,12 @@
+import { Stack } from 'expo-router';
+
+import { SignInScreen } from '~/components/auth/sign-in-screen';
+
+const SignInRoute = () => (
+ <>
+
+
+ >
+);
+
+export default SignInRoute;
diff --git a/apps/expo/src/app/index.tsx b/apps/expo/src/app/index.tsx
index 7d7704c..5a3bdd4 100644
--- a/apps/expo/src/app/index.tsx
+++ b/apps/expo/src/app/index.tsx
@@ -1,177 +1,28 @@
-import { useMemo, useState } from 'react';
-import { Alert, Pressable, Text, TextInput, View } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import * as Linking from 'expo-linking';
-import { Stack } from 'expo-router';
-import * as WebBrowser from 'expo-web-browser';
-import { useAuthActions } from '@convex-dev/auth/react';
-import { useConvexAuth, useQuery } from 'convex/react';
+import { useEffect } from 'react';
+import { Stack, useRouter } from 'expo-router';
+import { useConvexAuth } from 'convex/react';
-import { api } from '@spoon/backend/convex/_generated/api.js';
+import { LoadingState } from '~/components/ui/loading-state';
-WebBrowser.maybeCompleteAuthSession();
-
-const Stat = ({ label, value }: { label: string; value: number }) => (
-
- {label}
- {value}
-
-);
-
-const Index = () => {
+const IndexRoute = () => {
const { isAuthenticated, isLoading } = useConvexAuth();
- const { signIn, signOut } = useAuthActions();
- const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
- const spoons =
- useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
- const syncRuns =
- useQuery(
- api.syncRuns.listRecent,
- isAuthenticated ? { limit: 5 } : 'skip',
- ) ?? [];
- const threads =
- useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
- [];
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const redirectTo = useMemo(() => Linking.createURL(''), []);
+ const router = useRouter();
- const handlePasswordSignIn = async () => {
- setSubmitting(true);
- try {
- await signIn('password', { email, password, flow: 'signIn' });
- } catch (error) {
- console.error(error);
- Alert.alert('Sign in failed', 'Check your email and password.');
- } finally {
- setSubmitting(false);
+ useEffect(() => {
+ if (isLoading) return;
+ if (isAuthenticated) {
+ router.replace('/dashboard');
+ } else {
+ router.replace('/sign-in');
}
- };
-
- const handleAuthentikSignIn = async () => {
- setSubmitting(true);
- try {
- const result = await signIn('authentik', { redirectTo });
- if (!result.redirect) return;
- const authResult = await WebBrowser.openAuthSessionAsync(
- result.redirect.toString(),
- redirectTo,
- );
- if (authResult.type !== 'success') return;
- const parsed = Linking.parse(authResult.url);
- const code = parsed.queryParams?.code;
- if (typeof code !== 'string') {
- Alert.alert('Sign in failed', 'Authentik did not return a code.');
- return;
- }
- await signIn('authentik', { code });
- } catch (error) {
- console.error(error);
- Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
- } finally {
- setSubmitting(false);
- }
- };
+ }, [isAuthenticated, isLoading, router]);
return (
-
+ <>
-
-
- Spoon
-
- Fork freely. Stay close to upstream.
-
-
-
- {isLoading ? (
- Loading...
- ) : isAuthenticated ? (
-
-
-
- Welcome{user?.name ? `, ${user.name}` : ''}
-
-
- Monitor your managed forks from anywhere.
-
-
-
-
-
-
-
-
-
- Recent Spoons
-
- {spoons.length ? (
- spoons.slice(0, 4).map((spoon) => (
-
- {spoon.name} - {spoon.status.replaceAll('_', ' ')}
-
- ))
- ) : (
-
- Create your first Spoon from the web dashboard.
-
- )}
-
- void signOut()}
- >
-
- Sign out
-
-
-
- ) : (
-
-
-
- void handlePasswordSignIn()}
- >
-
- Sign in with password
-
-
- void handleAuthentikSignIn()}
- >
-
- Continue with Authentik
-
-
-
- Register the native redirect URI based on spoon:// in Authentik.
-
-
- )}
-
-
+
+ >
);
};
-export default Index;
+export default IndexRoute;
diff --git a/apps/expo/src/app/post/[id].tsx b/apps/expo/src/app/post/[id].tsx
deleted file mode 100644
index 9c80beb..0000000
--- a/apps/expo/src/app/post/[id].tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Text, View } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { Stack, useLocalSearchParams } from 'expo-router';
-
-const Post = () => {
- const { id } = useLocalSearchParams<{ id: string }>();
-
- return (
-
-
-
- Post {id}
-
- Implement your post detail screen here using Convex queries.
-
-
-
- );
-};
-
-export default Post;
diff --git a/apps/expo/src/components/auth/sign-in-screen.tsx b/apps/expo/src/components/auth/sign-in-screen.tsx
new file mode 100644
index 0000000..45165d1
--- /dev/null
+++ b/apps/expo/src/components/auth/sign-in-screen.tsx
@@ -0,0 +1,113 @@
+import { useMemo, useState } from 'react';
+import { Alert, Text, View } from 'react-native';
+import * as Linking from 'expo-linking';
+import * as WebBrowser from 'expo-web-browser';
+import { useAuthActions } from '@convex-dev/auth/react';
+
+import { AppScreen } from '~/components/ui/app-screen';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { Field } from '~/components/ui/field';
+
+WebBrowser.maybeCompleteAuthSession();
+
+type OAuthProvider = 'github' | 'authentik';
+
+export const SignInScreen = () => {
+ const { signIn } = useAuthActions();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const redirectTo = useMemo(() => Linking.createURL(''), []);
+
+ const signInWithPassword = async () => {
+ setSubmitting(true);
+ try {
+ await signIn('password', { email, password, flow: 'signIn' });
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Sign in failed', 'Check your email and password.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const signInWithOAuth = async (provider: OAuthProvider) => {
+ setSubmitting(true);
+ try {
+ const result = await signIn(provider, { redirectTo });
+ if (!result.redirect) return;
+ const authResult = await WebBrowser.openAuthSessionAsync(
+ result.redirect.toString(),
+ redirectTo,
+ );
+ if (authResult.type !== 'success') return;
+ const parsed = Linking.parse(authResult.url);
+ const code = parsed.queryParams?.code;
+ if (typeof code !== 'string') {
+ Alert.alert('Sign in failed', 'The provider did not return a code.');
+ return;
+ }
+ await signIn(provider, { code });
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Sign in failed', `Could not complete ${provider} sign in.`);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ Spoon
+
+ Fork freely & keep them close to upstream.
+
+
+
+
+ void signInWithOAuth('github')}
+ >
+ Continue with GitHub
+
+ void signInWithOAuth('authentik')}
+ >
+ Continue with Authentik
+
+
+
+
+
+ Sign in with email
+
+
+
+ void signInWithPassword()}>
+ Sign in with email
+
+
+
+
+ Native OAuth callbacks should allow the `spoon://` redirect scheme.
+
+
+ );
+};
diff --git a/apps/expo/src/components/settings/ai-provider-profile-form.tsx b/apps/expo/src/components/settings/ai-provider-profile-form.tsx
new file mode 100644
index 0000000..28160e3
--- /dev/null
+++ b/apps/expo/src/components/settings/ai-provider-profile-form.tsx
@@ -0,0 +1,233 @@
+import { useMemo, useState } from 'react';
+import { Text } from 'react-native';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { Button } from '~/components/ui/button';
+import { Field } from '~/components/ui/field';
+import { FormSection } from '~/components/ui/form-section';
+import { SheetSelect } from '~/components/ui/sheet-select';
+import { SwitchRow } from '~/components/ui/switch-row';
+import { Textarea } from '~/components/ui/textarea';
+
+type Provider =
+ | 'openai'
+ | 'anthropic'
+ | 'google'
+ | 'openrouter'
+ | 'requesty'
+ | 'litellm'
+ | 'cloudflare_ai_gateway'
+ | 'custom_openai_compatible'
+ | 'opencode_openai_login';
+type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
+type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+
+type ExistingProfile = {
+ _id: Id<'aiProviderProfiles'>;
+ authType: AuthType;
+ baseUrl?: string;
+ defaultModel: string;
+ enabled: boolean;
+ modelOptions?: string[];
+ name: string;
+ provider: Provider;
+ reasoningEffort: ReasoningEffort;
+};
+
+const providerDefaults: Record<
+ Provider,
+ { authType: AuthType; model: string; name: string }
+> = {
+ anthropic: {
+ authType: 'api_key',
+ model: 'claude-sonnet-4-5',
+ name: 'Anthropic',
+ },
+ cloudflare_ai_gateway: {
+ authType: 'api_key',
+ model: 'gpt-5.1-codex',
+ name: 'Cloudflare AI Gateway',
+ },
+ custom_openai_compatible: {
+ authType: 'api_key',
+ model: 'gpt-5.1-codex',
+ name: 'Custom compatible',
+ },
+ google: { authType: 'api_key', model: 'gemini-2.5-pro', name: 'Google' },
+ litellm: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'LiteLLM' },
+ opencode_openai_login: {
+ authType: 'opencode_auth_json',
+ model: 'gpt-5.1-codex',
+ name: 'OpenCode provider',
+ },
+ openai: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'OpenAI' },
+ openrouter: {
+ authType: 'api_key',
+ model: 'openai/gpt-5.1-codex',
+ name: 'OpenRouter',
+ },
+ requesty: {
+ authType: 'api_key',
+ model: 'openai/gpt-5.1-codex',
+ name: 'Requesty',
+ },
+};
+
+const parseModelOptions = (text: string) =>
+ text
+ .split(/\r?\n|,/)
+ .map((model) => model.trim())
+ .filter(Boolean);
+
+export const AiProviderProfileForm = ({
+ existing,
+ onSubmit,
+ saving,
+}: {
+ existing?: ExistingProfile;
+ onSubmit: (values: {
+ authType: AuthType;
+ baseUrl?: string;
+ defaultModel: string;
+ enabled: boolean;
+ modelOptions: string[];
+ name: string;
+ provider: Provider;
+ reasoningEffort: ReasoningEffort;
+ secret?: string;
+ }) => Promise;
+ saving: boolean;
+}) => {
+ const [name, setName] = useState(existing?.name ?? 'OpenCode provider');
+ const [provider, setProvider] = useState(
+ existing?.provider ?? 'opencode_openai_login',
+ );
+ const [authType, setAuthType] = useState(
+ existing?.authType ?? 'opencode_auth_json',
+ );
+ const [secret, setSecret] = useState('');
+ const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? '');
+ const [modelOptions, setModelOptions] = useState(
+ (existing?.modelOptions?.length
+ ? existing.modelOptions
+ : [existing?.defaultModel ?? 'gpt-5.1-codex']
+ ).join('\n'),
+ );
+ const models = useMemo(() => parseModelOptions(modelOptions), [modelOptions]);
+ const [defaultModel, setDefaultModel] = useState(
+ existing?.defaultModel ?? 'gpt-5.1-codex',
+ );
+ const [reasoningEffort, setReasoningEffort] = useState(
+ existing?.reasoningEffort ?? 'medium',
+ );
+ const [enabled, setEnabled] = useState(existing?.enabled ?? true);
+
+ const changeProvider = (nextProvider: Provider) => {
+ const defaults = providerDefaults[nextProvider];
+ setProvider(nextProvider);
+ setName(defaults.name);
+ setAuthType(defaults.authType);
+ setDefaultModel(defaults.model);
+ setModelOptions(defaults.model);
+ };
+
+ const submit = () =>
+ void onSubmit({
+ authType,
+ baseUrl: baseUrl || undefined,
+ defaultModel: models.includes(defaultModel)
+ ? defaultModel
+ : (models[0] ?? defaultModel),
+ enabled,
+ modelOptions: models,
+ name,
+ provider,
+ reasoningEffort,
+ secret: secret || undefined,
+ });
+
+ return (
+
+
+
+
+ {authType === 'opencode_auth_json' ? (
+
+ Copy auth.json from your Codex/OpenCode auth folder, for example
+ ~/.codex/auth.json, and paste it here.
+
+ ) : null}
+ {authType !== 'none' ? (
+
+ ) : null}
+
+
+ ({ label: model, value: model }))
+ : [{ label: 'Add model options first', value: '' }]
+ }
+ value={models.includes(defaultModel) ? defaultModel : (models[0] ?? '')}
+ onChange={setDefaultModel}
+ />
+
+
+
+ {saving ? 'Saving...' : 'Save provider'}
+
+
+ );
+};
diff --git a/apps/expo/src/components/settings/github-integration-panel.tsx b/apps/expo/src/components/settings/github-integration-panel.tsx
new file mode 100644
index 0000000..5dcfa1a
--- /dev/null
+++ b/apps/expo/src/components/settings/github-integration-panel.tsx
@@ -0,0 +1,112 @@
+import { Alert, Linking, Text, View } from 'react-native';
+
+import { Badge } from '~/components/ui/badge';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { EmptyState } from '~/components/ui/empty-state';
+
+export const GitHubIntegrationPanel = ({
+ connection,
+ installUrl,
+ loadingRepos,
+ onListRepos,
+ onSync,
+ runtimeStatus,
+ syncing,
+}: {
+ connection?: {
+ displayName?: string;
+ installationId?: string;
+ status?: string;
+ } | null;
+ installUrl?: string | null;
+ loadingRepos: boolean;
+ onListRepos: () => Promise;
+ onSync: () => Promise;
+ runtimeStatus?: { encryptionConfigured?: boolean } | null;
+ syncing: boolean;
+}) => {
+ const showRepos = async () => {
+ try {
+ const repos = await onListRepos();
+ Alert.alert(
+ 'Accessible repositories',
+ repos.slice(0, 20).join('\n') || 'No repositories returned.',
+ );
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not list repositories.');
+ }
+ };
+
+ const sync = async () => {
+ try {
+ await onSync();
+ Alert.alert('GitHub synced', 'Installation metadata was refreshed.');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not sync GitHub installation.');
+ }
+ };
+
+ return (
+ <>
+
+
+ GitHub App
+
+
+ {connection ? (
+ <>
+
+ {connection.displayName}
+
+
+ Installation {connection.installationId ?? 'unknown'}
+
+ >
+ ) : (
+
+ Connect GitHub so Spoon can create forks, compare branches, and open
+ draft PRs.
+
+ )}
+ {installUrl ? (
+ void Linking.openURL(installUrl)}>
+ Install or manage GitHub App
+
+ ) : null}
+ void sync()}
+ >
+ {syncing ? 'Syncing...' : 'Sync installation'}
+
+ void showRepos()}
+ >
+ {loadingRepos ? 'Loading...' : 'List repositories'}
+
+
+
+ Runtime status
+
+ Encryption configured:{' '}
+ {runtimeStatus?.encryptionConfigured ? 'yes' : 'not reported'}
+
+
+ {!connection ? (
+
+ ) : null}
+ >
+ );
+};
diff --git a/apps/expo/src/components/spoons/segment-control.tsx b/apps/expo/src/components/spoons/segment-control.tsx
new file mode 100644
index 0000000..18bc976
--- /dev/null
+++ b/apps/expo/src/components/spoons/segment-control.tsx
@@ -0,0 +1,27 @@
+import type { PillTab } from '~/components/ui/pill-tabs';
+import { PillTabs } from '~/components/ui/pill-tabs';
+
+export type SpoonDetailSegment =
+ | 'overview'
+ | 'upstream'
+ | 'fork'
+ | 'prs'
+ | 'threads'
+ | 'settings';
+
+const tabs: PillTab[] = [
+ { label: 'Overview', value: 'overview' },
+ { label: 'Upstream', value: 'upstream' },
+ { label: 'Fork', value: 'fork' },
+ { label: 'PRs', value: 'prs' },
+ { label: 'Threads', value: 'threads' },
+ { label: 'Settings', value: 'settings' },
+];
+
+export const SegmentControl = ({
+ onChange,
+ value,
+}: {
+ onChange: (value: SpoonDetailSegment) => void;
+ value: SpoonDetailSegment;
+}) => ;
diff --git a/apps/expo/src/components/spoons/spoon-agent-settings-form.tsx b/apps/expo/src/components/spoons/spoon-agent-settings-form.tsx
new file mode 100644
index 0000000..9dcd027
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-agent-settings-form.tsx
@@ -0,0 +1,192 @@
+import { Alert, Text, View } from 'react-native';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { Field } from '~/components/ui/field';
+import { FormSection } from '~/components/ui/form-section';
+import { SheetSelect } from '~/components/ui/sheet-select';
+import { SwitchRow } from '~/components/ui/switch-row';
+
+type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+
+type ProviderProfile = {
+ _id: Id<'aiProviderProfiles'>;
+ defaultModel: string;
+ enabled: boolean;
+ isDefault?: boolean;
+ modelOptions?: string[];
+ name: string;
+ reasoningEffort: ReasoningEffort;
+};
+
+export const SpoonAgentSettingsForm = ({
+ agent,
+ onUpdate,
+ profiles,
+}: {
+ agent?: {
+ agentModel: string;
+ aiProviderProfileId?: Id<'aiProviderProfiles'>;
+ autoDetectCommands?: boolean;
+ branchPrefix: string;
+ checkCommand?: string;
+ enabled?: boolean;
+ envFilePath?: string;
+ installCommand?: string;
+ materializeEnvFileByDefault?: boolean;
+ reasoningEffort: ReasoningEffort;
+ testCommand?: string;
+ };
+ onUpdate: (patch: {
+ agentModel?: string;
+ aiProviderProfileId?: Id<'aiProviderProfiles'>;
+ autoDetectCommands?: boolean;
+ branchPrefix?: string;
+ checkCommand?: string;
+ enabled?: boolean;
+ envFilePath?: string;
+ installCommand?: string;
+ materializeEnvFileByDefault?: boolean;
+ reasoningEffort?: ReasoningEffort;
+ testCommand?: string;
+ }) => Promise;
+ profiles: ProviderProfile[];
+}) => {
+ const enabledProfiles = profiles.filter((profile) => profile.enabled);
+ const selectedProfile =
+ enabledProfiles.find(
+ (profile) => profile._id === agent?.aiProviderProfileId,
+ ) ??
+ enabledProfiles.find((profile) => profile.isDefault) ??
+ enabledProfiles[0];
+ const models = Array.from(
+ new Set(
+ selectedProfile
+ ? [
+ selectedProfile.defaultModel,
+ ...(selectedProfile.modelOptions ?? []),
+ ].filter(Boolean)
+ : [],
+ ),
+ );
+ const currentModel =
+ models.find((model) => model === agent?.agentModel) ??
+ selectedProfile?.defaultModel ??
+ '';
+
+ const save = (patch: Parameters[0]) =>
+ void onUpdate(patch).catch((error: unknown) => {
+ console.error(error);
+ Alert.alert('Could not save agent settings.');
+ });
+
+ return (
+
+ save({ enabled })}
+ />
+ save({ autoDetectCommands })}
+ />
+
+ save({ materializeEnvFileByDefault })
+ }
+ />
+ ({
+ label: profile.isDefault
+ ? `${profile.name} (default)`
+ : profile.name,
+ value: profile._id,
+ }))
+ : [{ label: 'Configure an AI provider in Settings', value: '' }]
+ }
+ value={selectedProfile?._id ?? ''}
+ onChange={(aiProviderProfileId) => {
+ const profile = enabledProfiles.find(
+ (item) => item._id === aiProviderProfileId,
+ );
+ if (profile) {
+ save({
+ agentModel: profile.defaultModel,
+ aiProviderProfileId: profile._id,
+ reasoningEffort: profile.reasoningEffort,
+ });
+ }
+ }}
+ />
+ ({ label: model, value: model }))
+ : [{ label: 'No models available', value: '' }]
+ }
+ value={currentModel}
+ onChange={(agentModel) => save({ agentModel })}
+ />
+ save({ reasoningEffort })}
+ />
+ {!enabledProfiles.length ? (
+
+ Configure an AI provider in Settings before queueing agent work.
+
+ ) : null}
+
+ save({ branchPrefix })}
+ />
+ save({ installCommand })}
+ />
+ save({ checkCommand })}
+ />
+ save({ testCommand })}
+ />
+ save({ envFilePath })}
+ />
+
+
+ );
+};
diff --git a/apps/expo/src/components/spoons/spoon-commit-list.tsx b/apps/expo/src/components/spoons/spoon-commit-list.tsx
new file mode 100644
index 0000000..d408e18
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-commit-list.tsx
@@ -0,0 +1,58 @@
+import { Linking, Text, View } from 'react-native';
+
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { EmptyState } from '~/components/ui/empty-state';
+import { formatDateTime, truncate } from '~/utils/format';
+
+type Commit = {
+ _id: string;
+ authorLogin?: string;
+ authorName?: string;
+ committedAt?: number;
+ htmlUrl?: string;
+ message: string;
+};
+
+export const SpoonCommitList = ({
+ commits,
+ emptyDescription,
+ emptyTitle,
+ intro,
+ showOpenButton = false,
+}: {
+ commits: Commit[];
+ emptyDescription: string;
+ emptyTitle: string;
+ intro?: string;
+ showOpenButton?: boolean;
+}) => (
+
+ {intro ? (
+ {intro}
+ ) : null}
+ {commits.length ? (
+ commits.map((commit) => (
+
+
+ {truncate(commit.message, 100)}
+
+
+ {commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
+ {formatDateTime(commit.committedAt)}
+
+ {showOpenButton && commit.htmlUrl ? (
+ void Linking.openURL(commit.htmlUrl ?? '')}
+ >
+ Open commit
+
+ ) : null}
+
+ ))
+ ) : (
+
+ )}
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-fork.tsx b/apps/expo/src/components/spoons/spoon-detail-fork.tsx
new file mode 100644
index 0000000..56fbab3
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-fork.tsx
@@ -0,0 +1,14 @@
+import { SpoonCommitList } from './spoon-commit-list';
+
+export const SpoonDetailFork = ({
+ commits,
+}: {
+ commits: Parameters[0]['commits'];
+}) => (
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-overview.tsx b/apps/expo/src/components/spoons/spoon-detail-overview.tsx
new file mode 100644
index 0000000..7773c0c
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-overview.tsx
@@ -0,0 +1,70 @@
+import { Text, View } from 'react-native';
+
+import { Card } from '~/components/ui/card';
+import { CopyRow } from '~/components/ui/copy-row';
+import { MetricCard } from '~/components/ui/metric-card';
+import { formatDate, titleize } from '~/utils/format';
+
+type SpoonOverview = {
+ description?: string;
+ forkAheadBy?: number;
+ forkOwner?: string;
+ forkRepo?: string;
+ forkUrl?: string;
+ lastCheckedAt?: number;
+ syncCadence: string;
+ upstreamAheadBy?: number;
+ upstreamOwner: string;
+ upstreamRepo: string;
+ upstreamUrl: string;
+};
+
+export const SpoonDetailOverview = ({
+ effectiveUpstreamAheadBy,
+ remotes,
+ spoon,
+}: {
+ effectiveUpstreamAheadBy: number;
+ remotes: { _id: string; label: string; url: string }[];
+ spoon: SpoonOverview;
+}) => (
+
+
+
+
+
+
+ {spoon.description ? (
+
+ Description
+
+ {spoon.description}
+
+
+ ) : null}
+
+
+
+ {remotes.map((remote) => (
+
+ ))}
+
+
+ Details
+
+ Last checked: {formatDate(spoon.lastCheckedAt)}
+
+
+ Cadence: {titleize(spoon.syncCadence)}
+
+
+ Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
+
+ {spoon.forkOwner && spoon.forkRepo ? (
+
+ Fork: {spoon.forkOwner}/{spoon.forkRepo}
+
+ ) : null}
+
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-prs.tsx b/apps/expo/src/components/spoons/spoon-detail-prs.tsx
new file mode 100644
index 0000000..097dce4
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-prs.tsx
@@ -0,0 +1,47 @@
+import { Linking, Text, View } from 'react-native';
+
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { EmptyState } from '~/components/ui/empty-state';
+import { titleize } from '~/utils/format';
+
+type PullRequest = {
+ _id: string;
+ htmlUrl: string;
+ number: number;
+ repoFullName: string;
+ state: string;
+ title: string;
+};
+
+export const SpoonDetailPrs = ({
+ pullRequests,
+}: {
+ pullRequests: PullRequest[];
+}) => (
+
+ {pullRequests.length ? (
+ pullRequests.map((pullRequest) => (
+
+
+ #{pullRequest.number} {pullRequest.title}
+
+
+ {titleize(pullRequest.state)} · {pullRequest.repoFullName}
+
+ void Linking.openURL(pullRequest.htmlUrl)}
+ >
+ Open PR
+
+
+ ))
+ ) : (
+
+ )}
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-settings.tsx b/apps/expo/src/components/spoons/spoon-detail-settings.tsx
new file mode 100644
index 0000000..183b7fd
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-settings.tsx
@@ -0,0 +1,106 @@
+import { View } from 'react-native';
+
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { SpoonAgentSettingsForm } from './spoon-agent-settings-form';
+import { SpoonMaintenanceSettingsForm } from './spoon-maintenance-settings-form';
+import { SpoonRemotesPanel } from './spoon-remotes-panel';
+import { SpoonSecretsPanel } from './spoon-secrets-panel';
+
+type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+
+export const SpoonDetailSettings = ({
+ actions,
+ agentSettings,
+ maintenanceSettings,
+ pending,
+ providerProfiles,
+ remotes,
+ secrets,
+ spoon,
+}: {
+ actions: {
+ addRemote: (label: string, url: string) => Promise;
+ addSecret: (name: string, value: string) => Promise;
+ importSecrets: (
+ secrets: { name: string; value: string }[],
+ ) => Promise;
+ removeRemote: (remoteId: string) => Promise;
+ removeSecret: (secretId: string) => Promise;
+ updateAgent: (patch: Record) => Promise;
+ updateMaintenance: (patch: Record) => Promise;
+ updateSpoon: (patch: Record) => Promise;
+ };
+ agentSettings?: {
+ agentModel: string;
+ aiProviderProfileId?: Id<'aiProviderProfiles'>;
+ autoDetectCommands?: boolean;
+ branchPrefix: string;
+ checkCommand?: string;
+ enabled?: boolean;
+ envFilePath?: string;
+ installCommand?: string;
+ materializeEnvFileByDefault?: boolean;
+ reasoningEffort: ReasoningEffort;
+ testCommand?: string;
+ };
+ maintenanceSettings?: {
+ autoRefreshEnabled: boolean;
+ autoReviewEnabled: boolean;
+ autoSyncEnabled: boolean;
+ };
+ pending: {
+ addingRemote: boolean;
+ addingSecret: boolean;
+ importingSecrets: boolean;
+ removingRemoteId?: string;
+ removingSecretId?: string;
+ savingSettings: boolean;
+ };
+ providerProfiles: {
+ _id: Id<'aiProviderProfiles'>;
+ defaultModel: string;
+ enabled: boolean;
+ isDefault?: boolean;
+ modelOptions?: string[];
+ name: string;
+ reasoningEffort: ReasoningEffort;
+ }[];
+ remotes: { _id: string; label: string; url: string }[];
+ secrets: { _id: string; name: string; valuePreview?: string }[];
+ spoon: {
+ maintenanceMode: 'watch' | 'auto_pr' | 'paused';
+ syncCadence: 'daily' | 'weekly' | 'manual';
+ };
+}) => (
+
+
+
+
+
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-threads.tsx b/apps/expo/src/components/spoons/spoon-detail-threads.tsx
new file mode 100644
index 0000000..1b0a165
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-threads.tsx
@@ -0,0 +1,54 @@
+import { Text, View } from 'react-native';
+
+import { ThreadListRow } from '~/components/threads/thread-list-row';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { EmptyState } from '~/components/ui/empty-state';
+import { Textarea } from '~/components/ui/textarea';
+
+type Thread = Parameters[0]['thread'];
+
+export const SpoonDetailThreads = ({
+ creating,
+ onCreate,
+ onOpenThread,
+ prompt,
+ setPrompt,
+ threads,
+}: {
+ creating: boolean;
+ onCreate: () => void;
+ onOpenThread: (threadId: string) => void;
+ prompt: string;
+ setPrompt: (prompt: string) => void;
+ threads: Thread[];
+}) => (
+
+
+ New thread
+
+
+ {creating ? 'Creating...' : 'Create thread'}
+
+
+ {threads.length ? (
+ threads.map((thread) => (
+ onOpenThread(thread._id)}
+ />
+ ))
+ ) : (
+
+ )}
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-detail-upstream.tsx b/apps/expo/src/components/spoons/spoon-detail-upstream.tsx
new file mode 100644
index 0000000..bb2b881
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-detail-upstream.tsx
@@ -0,0 +1,14 @@
+import { SpoonCommitList } from './spoon-commit-list';
+
+export const SpoonDetailUpstream = ({
+ commits,
+}: {
+ commits: Parameters[0]['commits'];
+}) => (
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-list-row.tsx b/apps/expo/src/components/spoons/spoon-list-row.tsx
new file mode 100644
index 0000000..c9d1308
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-list-row.tsx
@@ -0,0 +1,48 @@
+import { Text, View } from 'react-native';
+
+import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { ListRow } from '~/components/ui/list-row';
+import { formatDate } from '~/utils/format';
+import { SpoonStatusBadge } from './spoon-status-badge';
+
+export const SpoonListRow = ({
+ spoon,
+ openThreads,
+ onPress,
+}: {
+ spoon: Doc<'spoons'>;
+ openThreads?: number;
+ onPress: () => void;
+}) => (
+
+
+
+
+ {spoon.forkOwner && spoon.forkRepo ? (
+
+ fork {spoon.forkOwner}/{spoon.forkRepo}
+
+ ) : (
+ missing fork
+ )}
+
+
+
+ {spoon.upstreamAheadBy ?? 0} upstream
+
+
+ {spoon.forkAheadBy ?? 0} fork-only
+
+
+ {openThreads ?? 0} threads
+
+
+
+
+);
diff --git a/apps/expo/src/components/spoons/spoon-maintenance-settings-form.tsx b/apps/expo/src/components/spoons/spoon-maintenance-settings-form.tsx
new file mode 100644
index 0000000..2d7c945
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-maintenance-settings-form.tsx
@@ -0,0 +1,104 @@
+import { Alert, Text } from 'react-native';
+
+import { FormSection } from '~/components/ui/form-section';
+import { SheetSelect } from '~/components/ui/sheet-select';
+import { SwitchRow } from '~/components/ui/switch-row';
+
+type Cadence = 'daily' | 'weekly' | 'manual';
+type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
+
+export const SpoonMaintenanceSettingsForm = ({
+ maintenance,
+ onUpdateMaintenance,
+ onUpdateSpoon,
+ saving,
+ spoon,
+}: {
+ maintenance?: {
+ autoRefreshEnabled: boolean;
+ autoReviewEnabled: boolean;
+ autoSyncEnabled: boolean;
+ };
+ onUpdateMaintenance: (patch: {
+ autoRefreshEnabled?: boolean;
+ autoReviewEnabled?: boolean;
+ autoSyncEnabled?: boolean;
+ }) => Promise;
+ onUpdateSpoon: (patch: {
+ maintenanceMode?: MaintenanceMode;
+ syncCadence?: Cadence;
+ }) => Promise;
+ saving: boolean;
+ spoon: { maintenanceMode: MaintenanceMode; syncCadence: Cadence };
+}) => {
+ const updateMaintenance = (
+ patch: Parameters[0],
+ ) =>
+ void onUpdateMaintenance(patch).catch((error: unknown) => {
+ console.error(error);
+ Alert.alert('Could not save maintenance settings.');
+ });
+
+ const updateSpoon = (patch: Parameters[0]) =>
+ void onUpdateSpoon(patch).catch((error: unknown) => {
+ console.error(error);
+ Alert.alert('Could not save Spoon settings.');
+ });
+
+ return (
+ <>
+
+
+ updateMaintenance({ autoRefreshEnabled })
+ }
+ />
+
+ updateMaintenance({ autoReviewEnabled })
+ }
+ />
+
+ updateMaintenance({ autoSyncEnabled })
+ }
+ />
+ {saving ? (
+ Saving...
+ ) : null}
+
+
+ updateSpoon({ syncCadence })}
+ />
+ updateSpoon({ maintenanceMode })}
+ />
+
+ >
+ );
+};
diff --git a/apps/expo/src/components/spoons/spoon-remotes-panel.tsx b/apps/expo/src/components/spoons/spoon-remotes-panel.tsx
new file mode 100644
index 0000000..80ed4e5
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-remotes-panel.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import { Alert, Text, View } from 'react-native';
+
+import { Button } from '~/components/ui/button';
+import { ConfirmButton } from '~/components/ui/confirm-button';
+import { Field } from '~/components/ui/field';
+import { FormSection } from '~/components/ui/form-section';
+
+export const SpoonRemotesPanel = ({
+ adding,
+ onAddRemote,
+ onRemoveRemote,
+ remotes,
+ removingId,
+}: {
+ adding: boolean;
+ onAddRemote: (label: string, url: string) => Promise;
+ onRemoveRemote: (remoteId: string) => Promise;
+ remotes: { _id: string; label: string; url: string }[];
+ removingId?: string;
+}) => {
+ const [label, setLabel] = useState('');
+ const [url, setUrl] = useState('');
+
+ const add = async () => {
+ if (!label.trim() || !url.trim()) return;
+ try {
+ await onAddRemote(label.trim(), url.trim());
+ setLabel('');
+ setUrl('');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not add remote.');
+ }
+ };
+
+ return (
+
+ {remotes.map((remote) => (
+
+
+ {remote.label}
+ {remote.url}
+
+ void onRemoveRemote(remote._id)}
+ >
+ {removingId === remote._id ? 'Removing...' : 'Remove'}
+
+
+ ))}
+
+
+
+ {adding ? 'Adding...' : 'Add remote'}
+
+
+ );
+};
diff --git a/apps/expo/src/components/spoons/spoon-secrets-panel.tsx b/apps/expo/src/components/spoons/spoon-secrets-panel.tsx
new file mode 100644
index 0000000..4e49a3c
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-secrets-panel.tsx
@@ -0,0 +1,138 @@
+import { useMemo, useState } from 'react';
+import { Alert, Text, View } from 'react-native';
+
+import { Button } from '~/components/ui/button';
+import { ConfirmButton } from '~/components/ui/confirm-button';
+import { Field } from '~/components/ui/field';
+import { FormSection } from '~/components/ui/form-section';
+import { Textarea } from '~/components/ui/textarea';
+import { parseEnvText } from '~/utils/env';
+
+export const SpoonSecretsPanel = ({
+ adding,
+ importing,
+ onAddSecret,
+ onImportSecrets,
+ onRemoveSecret,
+ removingId,
+ secrets,
+}: {
+ adding: boolean;
+ importing: boolean;
+ onAddSecret: (name: string, value: string) => Promise;
+ onImportSecrets: (
+ secrets: { name: string; value: string }[],
+ ) => Promise;
+ onRemoveSecret: (secretId: string) => Promise;
+ removingId?: string;
+ secrets: { _id: string; name: string; valuePreview?: string }[];
+}) => {
+ const [name, setName] = useState('');
+ const [value, setValue] = useState('');
+ const [envText, setEnvText] = useState('');
+ const parsed = useMemo(() => parseEnvText(envText), [envText]);
+ const preview = parsed.slice(0, 25);
+
+ const add = async () => {
+ if (!name.trim() || !value.trim()) return;
+ try {
+ await onAddSecret(name.trim(), value);
+ setName('');
+ setValue('');
+ } catch (error) {
+ console.error(error);
+ Alert.alert('Could not save secret.');
+ }
+ };
+
+ const importAll = async () => {
+ if (!parsed.length) return;
+ try {
+ await onImportSecrets(parsed);
+ setEnvText('');
+ Alert.alert('Secrets imported', `${parsed.length} secrets were saved.`);
+ } catch (error) {
+ console.error(error);
+ Alert.alert(
+ 'Could not import every secret',
+ error instanceof Error
+ ? error.message
+ : 'Some secrets may have been saved. Review the list and try again.',
+ );
+ }
+ };
+
+ return (
+
+ {secrets.map((secret) => (
+
+
+ {secret.name}
+
+ {secret.valuePreview ?? 'configured'}
+
+
+ void onRemoveSecret(secret._id)}
+ >
+ {removingId === secret._id ? 'Removing...' : 'Remove'}
+
+
+ ))}
+
+ Add one secret
+
+
+
+ {adding ? 'Adding...' : 'Add secret'}
+
+
+
+ Import .env
+
+
+ {parsed.length
+ ? `${parsed.length} valid secrets found: ${preview
+ .map((secret) => secret.name)
+ .join(', ')}${parsed.length > preview.length ? ', ...' : ''}`
+ : 'Paste .env contents to preview secret names.'}
+
+
+ void importAll()}
+ >
+ {importing ? 'Importing...' : 'Import secrets'}
+
+ setEnvText('')}>
+ Clear
+
+
+
+
+ );
+};
diff --git a/apps/expo/src/components/spoons/spoon-status-badge.tsx b/apps/expo/src/components/spoons/spoon-status-badge.tsx
new file mode 100644
index 0000000..4800667
--- /dev/null
+++ b/apps/expo/src/components/spoons/spoon-status-badge.tsx
@@ -0,0 +1,16 @@
+import { Badge } from '~/components/ui/badge';
+import { titleize } from '~/utils/format';
+
+const toneForStatus = (status?: string) => {
+ if (status === 'up_to_date' || status === 'active') return 'success';
+ if (status === 'behind' || status === 'diverged' || status === 'conflict') {
+ return 'warning';
+ }
+ if (status === 'error' || status === 'archived') return 'danger';
+ if (status === 'checking') return 'primary';
+ return 'neutral';
+};
+
+export const SpoonStatusBadge = ({ status }: { status?: string }) => (
+
+);
diff --git a/apps/expo/src/components/threads/thread-list-row.tsx b/apps/expo/src/components/threads/thread-list-row.tsx
new file mode 100644
index 0000000..41fd5a4
--- /dev/null
+++ b/apps/expo/src/components/threads/thread-list-row.tsx
@@ -0,0 +1,36 @@
+import { Text, View } from 'react-native';
+
+import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { Badge } from '~/components/ui/badge';
+import { ListRow } from '~/components/ui/list-row';
+import { formatDateTime, titleize, truncate } from '~/utils/format';
+import { ThreadStatusBadge } from './thread-status-badge';
+
+export const ThreadListRow = ({
+ thread,
+ onPress,
+}: {
+ thread: Doc<'threads'>;
+ onPress: () => void;
+}) => (
+
+
+
+
+ {thread.maintenanceOutcome ? (
+
+ ) : null}
+
+ {thread.upstreamTo ? (
+
+ upstream {thread.upstreamTo.slice(0, 12)}
+
+ ) : null}
+
+);
diff --git a/apps/expo/src/components/threads/thread-message-list.tsx b/apps/expo/src/components/threads/thread-message-list.tsx
new file mode 100644
index 0000000..bc2b6cc
--- /dev/null
+++ b/apps/expo/src/components/threads/thread-message-list.tsx
@@ -0,0 +1,31 @@
+import { Text, View } from 'react-native';
+
+import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
+
+import { titleize } from '~/utils/format';
+
+export const ThreadMessageList = ({
+ messages,
+}: {
+ messages: Doc<'threadMessages'>[];
+}) => (
+
+ {messages.map((message) => (
+
+
+ {titleize(message.role)} · {titleize(message.status)}
+
+
+ {message.content}
+
+
+ ))}
+
+);
diff --git a/apps/expo/src/components/threads/thread-status-badge.tsx b/apps/expo/src/components/threads/thread-status-badge.tsx
new file mode 100644
index 0000000..bd7cb58
--- /dev/null
+++ b/apps/expo/src/components/threads/thread-status-badge.tsx
@@ -0,0 +1,16 @@
+import { Badge } from '~/components/ui/badge';
+import { titleize } from '~/utils/format';
+
+const toneForStatus = (status?: string) => {
+ if (status === 'resolved' || status === 'ignored') return 'success';
+ if (status === 'failed' || status === 'cancelled') return 'danger';
+ if (status === 'waiting_for_user' || status === 'changes_ready') {
+ return 'warning';
+ }
+ if (status === 'running' || status === 'queued') return 'primary';
+ return 'neutral';
+};
+
+export const ThreadStatusBadge = ({ status }: { status?: string }) => (
+
+);
diff --git a/apps/expo/src/components/ui/action-row.tsx b/apps/expo/src/components/ui/action-row.tsx
new file mode 100644
index 0000000..7995485
--- /dev/null
+++ b/apps/expo/src/components/ui/action-row.tsx
@@ -0,0 +1,24 @@
+import type { PressableProps } from 'react-native';
+import { Pressable, Text, View } from 'react-native';
+
+export const ActionRow = ({
+ detail,
+ label,
+ ...props
+}: PressableProps & {
+ detail?: string;
+ label: string;
+}) => (
+
+
+ {label}
+ {detail ? (
+ {detail}
+ ) : null}
+
+ ›
+
+);
diff --git a/apps/expo/src/components/ui/app-screen.tsx b/apps/expo/src/components/ui/app-screen.tsx
new file mode 100644
index 0000000..fbd438e
--- /dev/null
+++ b/apps/expo/src/components/ui/app-screen.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react';
+import { RefreshControl, ScrollView, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+export const AppScreen = ({
+ children,
+ onRefresh,
+ refreshing = false,
+ scroll = true,
+}: {
+ children: ReactNode;
+ onRefresh?: () => void;
+ refreshing?: boolean;
+ scroll?: boolean;
+}) => {
+ if (!scroll) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+
+ ) : undefined
+ }
+ >
+ {children}
+
+
+ );
+};
diff --git a/apps/expo/src/components/ui/badge.tsx b/apps/expo/src/components/ui/badge.tsx
new file mode 100644
index 0000000..7b46122
--- /dev/null
+++ b/apps/expo/src/components/ui/badge.tsx
@@ -0,0 +1,27 @@
+import { Text } from 'react-native';
+
+export const Badge = ({
+ label,
+ tone = 'neutral',
+}: {
+ label: string;
+ tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
+}) => {
+ const toneClass =
+ tone === 'primary'
+ ? 'bg-primary/10 text-primary'
+ : tone === 'success'
+ ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
+ : tone === 'warning'
+ ? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
+ : tone === 'danger'
+ ? 'bg-red-500/10 text-red-700 dark:text-red-300'
+ : 'bg-muted text-muted-foreground';
+ return (
+
+ {label}
+
+ );
+};
diff --git a/apps/expo/src/components/ui/button.tsx b/apps/expo/src/components/ui/button.tsx
new file mode 100644
index 0000000..bb1c712
--- /dev/null
+++ b/apps/expo/src/components/ui/button.tsx
@@ -0,0 +1,39 @@
+import type { ComponentProps, ReactNode } from 'react';
+import { Pressable, Text } from 'react-native';
+
+export const Button = ({
+ children,
+ onPress,
+ variant = 'primary',
+ disabled = false,
+ ...props
+}: {
+ children: ReactNode;
+ onPress?: () => void;
+ variant?: 'primary' | 'outline' | 'danger' | 'ghost';
+ disabled?: boolean;
+} & Omit, 'children'>) => {
+ const variantClass =
+ variant === 'outline'
+ ? 'border-border border bg-transparent'
+ : variant === 'danger'
+ ? 'bg-red-600'
+ : variant === 'ghost'
+ ? 'bg-transparent'
+ : 'bg-primary';
+ const textClass =
+ variant === 'outline' || variant === 'ghost'
+ ? 'text-foreground'
+ : 'text-primary-foreground';
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/expo/src/components/ui/card.tsx b/apps/expo/src/components/ui/card.tsx
new file mode 100644
index 0000000..717d106
--- /dev/null
+++ b/apps/expo/src/components/ui/card.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+import { View } from 'react-native';
+
+export const Card = ({
+ children,
+ className = '',
+}: {
+ children: ReactNode;
+ className?: string;
+}) => (
+
+ {children}
+
+);
diff --git a/apps/expo/src/components/ui/chip-row.tsx b/apps/expo/src/components/ui/chip-row.tsx
new file mode 100644
index 0000000..d89f9aa
--- /dev/null
+++ b/apps/expo/src/components/ui/chip-row.tsx
@@ -0,0 +1,42 @@
+import { Pressable, ScrollView, Text } from 'react-native';
+
+export const ChipRow = ({
+ onChange,
+ options,
+ value,
+}: {
+ onChange: (value: T) => void;
+ options: { label: string; value: T }[];
+ value: T;
+}) => (
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ onChange(option.value)}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+);
diff --git a/apps/expo/src/components/ui/confirm-button.tsx b/apps/expo/src/components/ui/confirm-button.tsx
new file mode 100644
index 0000000..21e6b93
--- /dev/null
+++ b/apps/expo/src/components/ui/confirm-button.tsx
@@ -0,0 +1,38 @@
+import { Alert } from 'react-native';
+
+import { Button } from './button';
+
+export const ConfirmButton = ({
+ children,
+ confirmLabel,
+ destructive = false,
+ disabled = false,
+ message,
+ onConfirm,
+ title,
+}: {
+ children: string;
+ confirmLabel: string;
+ destructive?: boolean;
+ disabled?: boolean;
+ message: string;
+ onConfirm: () => void;
+ title: string;
+}) => (
+
+ Alert.alert(title, message, [
+ { style: 'cancel', text: 'Cancel' },
+ {
+ onPress: onConfirm,
+ style: destructive ? 'destructive' : 'default',
+ text: confirmLabel,
+ },
+ ])
+ }
+ >
+ {children}
+
+);
diff --git a/apps/expo/src/components/ui/copy-row.tsx b/apps/expo/src/components/ui/copy-row.tsx
new file mode 100644
index 0000000..3b48604
--- /dev/null
+++ b/apps/expo/src/components/ui/copy-row.tsx
@@ -0,0 +1,27 @@
+import { Alert, Pressable, Text, View } from 'react-native';
+import * as Clipboard from 'expo-clipboard';
+import * as Haptics from 'expo-haptics';
+
+export const CopyRow = ({
+ label,
+ value,
+}: {
+ label: string;
+ value?: string;
+}) => {
+ if (!value) return null;
+ const copy = async () => {
+ await Clipboard.setStringAsync(value);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ Alert.alert('Copied', `${label} copied to clipboard.`);
+ };
+ return (
+
+ {label}
+
+ {value}
+ Copy
+
+
+ );
+};
diff --git a/apps/expo/src/components/ui/empty-state.tsx b/apps/expo/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..1cd487f
--- /dev/null
+++ b/apps/expo/src/components/ui/empty-state.tsx
@@ -0,0 +1,18 @@
+import { Text } from 'react-native';
+
+import { Card } from './card';
+
+export const EmptyState = ({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) => (
+
+ {title}
+
+ {description}
+
+
+);
diff --git a/apps/expo/src/components/ui/error-state.tsx b/apps/expo/src/components/ui/error-state.tsx
new file mode 100644
index 0000000..2226564
--- /dev/null
+++ b/apps/expo/src/components/ui/error-state.tsx
@@ -0,0 +1,10 @@
+import { Text } from 'react-native';
+
+import { Card } from './card';
+
+export const ErrorState = ({ message }: { message: string }) => (
+
+ Something went wrong
+ {message}
+
+);
diff --git a/apps/expo/src/components/ui/field.tsx b/apps/expo/src/components/ui/field.tsx
new file mode 100644
index 0000000..2eec5cb
--- /dev/null
+++ b/apps/expo/src/components/ui/field.tsx
@@ -0,0 +1,34 @@
+import { Text, TextInput, View } from 'react-native';
+
+export const Field = ({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ multiline = false,
+ secureTextEntry = false,
+ keyboardType,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (value: string) => void;
+ placeholder?: string;
+ multiline?: boolean;
+ secureTextEntry?: boolean;
+ keyboardType?: 'default' | 'email-address' | 'url';
+}) => (
+
+ {label}
+
+
+);
diff --git a/apps/expo/src/components/ui/form-section.tsx b/apps/expo/src/components/ui/form-section.tsx
new file mode 100644
index 0000000..a139cb8
--- /dev/null
+++ b/apps/expo/src/components/ui/form-section.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { Text } from 'react-native';
+
+import { Card } from './card';
+
+export const FormSection = ({
+ children,
+ description,
+ title,
+}: {
+ children: ReactNode;
+ description?: string;
+ title: string;
+}) => (
+
+ {title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {children}
+
+);
diff --git a/apps/expo/src/components/ui/list-row.tsx b/apps/expo/src/components/ui/list-row.tsx
new file mode 100644
index 0000000..0a2da2b
--- /dev/null
+++ b/apps/expo/src/components/ui/list-row.tsx
@@ -0,0 +1,36 @@
+import type { ComponentProps, ReactNode } from 'react';
+import { Pressable, Text, View } from 'react-native';
+
+export const ListRow = ({
+ title,
+ subtitle,
+ meta,
+ children,
+ onPress,
+ ...props
+}: {
+ title: string;
+ subtitle?: string;
+ meta?: string;
+ children?: ReactNode;
+ onPress?: () => void;
+} & Omit, 'children'>) => (
+
+
+
+ {title}
+ {subtitle ? (
+ {subtitle}
+ ) : null}
+
+ {meta ? (
+ {meta}
+ ) : null}
+
+ {children ? {children} : null}
+
+);
diff --git a/apps/expo/src/components/ui/loading-state.tsx b/apps/expo/src/components/ui/loading-state.tsx
new file mode 100644
index 0000000..11cc8e4
--- /dev/null
+++ b/apps/expo/src/components/ui/loading-state.tsx
@@ -0,0 +1,7 @@
+import { Text, View } from 'react-native';
+
+export const LoadingState = ({ label = 'Loading...' }: { label?: string }) => (
+
+ {label}
+
+);
diff --git a/apps/expo/src/components/ui/metric-card.tsx b/apps/expo/src/components/ui/metric-card.tsx
new file mode 100644
index 0000000..77685f3
--- /dev/null
+++ b/apps/expo/src/components/ui/metric-card.tsx
@@ -0,0 +1,21 @@
+import { Text } from 'react-native';
+
+import { Card } from './card';
+
+export const MetricCard = ({
+ label,
+ value,
+ note,
+}: {
+ label: string;
+ value: string | number;
+ note?: string;
+}) => (
+
+ {label}
+ {value}
+ {note ? (
+ {note}
+ ) : null}
+
+);
diff --git a/apps/expo/src/components/ui/pill-tabs.tsx b/apps/expo/src/components/ui/pill-tabs.tsx
new file mode 100644
index 0000000..57784cf
--- /dev/null
+++ b/apps/expo/src/components/ui/pill-tabs.tsx
@@ -0,0 +1,61 @@
+import { Pressable, ScrollView, Text } from 'react-native';
+
+export type PillTab = {
+ badge?: number | string;
+ label: string;
+ value: T;
+};
+
+export const PillTabs = ({
+ onChange,
+ tabs,
+ value,
+}: {
+ onChange: (value: T) => void;
+ tabs: PillTab[];
+ value: T;
+}) => (
+
+ {tabs.map((tab) => {
+ const active = tab.value === value;
+ return (
+ onChange(tab.value)}
+ >
+
+ {tab.label}
+
+ {tab.badge === undefined ? null : (
+
+ {tab.badge}
+
+ )}
+
+ );
+ })}
+
+);
diff --git a/apps/expo/src/components/ui/radio-list.tsx b/apps/expo/src/components/ui/radio-list.tsx
new file mode 100644
index 0000000..6c1544e
--- /dev/null
+++ b/apps/expo/src/components/ui/radio-list.tsx
@@ -0,0 +1,46 @@
+import { Pressable, Text, View } from 'react-native';
+
+export type RadioOption = {
+ description?: string;
+ label: string;
+ value: T;
+};
+
+export const RadioList = ({
+ label,
+ onChange,
+ options,
+ value,
+}: {
+ label: string;
+ onChange: (value: T) => void;
+ options: RadioOption[];
+ value: T;
+}) => (
+
+ {label}
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ onChange(option.value)}
+ >
+ {option.label}
+ {option.description ? (
+
+ {option.description}
+
+ ) : null}
+
+ );
+ })}
+
+
+);
diff --git a/apps/expo/src/components/ui/sheet-select.tsx b/apps/expo/src/components/ui/sheet-select.tsx
new file mode 100644
index 0000000..80e782e
--- /dev/null
+++ b/apps/expo/src/components/ui/sheet-select.tsx
@@ -0,0 +1,100 @@
+import { useState } from 'react';
+import { Modal, Pressable, Text, View } from 'react-native';
+
+import { Button } from './button';
+
+export type SheetSelectOption = {
+ description?: string;
+ label: string;
+ value: T;
+};
+
+export const SheetSelect = ({
+ disabled = false,
+ label,
+ onChange,
+ options,
+ value,
+}: {
+ disabled?: boolean;
+ label: string;
+ onChange: (value: T) => void;
+ options: SheetSelectOption[];
+ value: T;
+}) => {
+ const [open, setOpen] = useState(false);
+ const selected = options.find((option) => option.value === value);
+
+ const choose = (nextValue: T) => {
+ onChange(nextValue);
+ setOpen(false);
+ };
+
+ return (
+
+ {label}
+ setOpen(true)}
+ >
+
+ {selected?.label ?? 'Select'}
+
+ {selected?.description ? (
+
+ {selected.description}
+
+ ) : null}
+
+ setOpen(false)}
+ transparent
+ visible={open}
+ >
+
+
+
+
+ {label}
+
+ setOpen(false)}>
+ Cancel
+
+
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ choose(option.value)}
+ >
+
+ {option.label}
+
+ {option.description ? (
+
+ {option.description}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+ );
+};
diff --git a/apps/expo/src/components/ui/switch-row.tsx b/apps/expo/src/components/ui/switch-row.tsx
new file mode 100644
index 0000000..a29044a
--- /dev/null
+++ b/apps/expo/src/components/ui/switch-row.tsx
@@ -0,0 +1,25 @@
+import { Switch, Text, View } from 'react-native';
+
+export const SwitchRow = ({
+ label,
+ description,
+ value,
+ onValueChange,
+}: {
+ label: string;
+ description?: string;
+ value: boolean;
+ onValueChange: (value: boolean) => void;
+}) => (
+
+
+ {label}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+);
diff --git a/apps/expo/src/components/ui/textarea.tsx b/apps/expo/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..fefb418
--- /dev/null
+++ b/apps/expo/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type { TextInputProps } from 'react-native';
+import { Text, TextInput, View } from 'react-native';
+
+export const Textarea = ({
+ label,
+ ...props
+}: TextInputProps & { label: string }) => (
+
+ {label}
+
+
+);
diff --git a/apps/expo/src/components/workspace/diff-preview.tsx b/apps/expo/src/components/workspace/diff-preview.tsx
new file mode 100644
index 0000000..5e8814c
--- /dev/null
+++ b/apps/expo/src/components/workspace/diff-preview.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+
+import { Button } from '~/components/ui/button';
+
+export const DiffPreview = ({
+ content,
+ initialLines = 120,
+}: {
+ content: string;
+ initialLines?: number;
+}) => {
+ const [expanded, setExpanded] = useState(false);
+ const lines = content.split('\n');
+ const visibleLines = expanded ? lines : lines.slice(0, initialLines);
+ const hiddenCount = Math.max(lines.length - visibleLines.length, 0);
+
+ return (
+
+
+ {visibleLines.map((line, index) => {
+ const color = line.startsWith('+')
+ ? 'text-emerald-300'
+ : line.startsWith('-')
+ ? 'text-red-300'
+ : line.startsWith('@@')
+ ? 'text-sky-300'
+ : 'text-zinc-100';
+ return (
+
+ {line || ' '}
+
+ );
+ })}
+
+ {hiddenCount > 0 ? (
+ setExpanded(true)}>
+ Show {hiddenCount} more lines
+
+ ) : null}
+
+ );
+};
diff --git a/apps/expo/src/components/workspace/workspace-artifacts.tsx b/apps/expo/src/components/workspace/workspace-artifacts.tsx
new file mode 100644
index 0000000..df07b81
--- /dev/null
+++ b/apps/expo/src/components/workspace/workspace-artifacts.tsx
@@ -0,0 +1,71 @@
+import { Text, View } from 'react-native';
+import * as Clipboard from 'expo-clipboard';
+
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { DiffPreview } from './diff-preview';
+
+type Artifact = {
+ _id: string;
+ content: string;
+ contentType: string;
+ kind: string;
+ title: string;
+};
+
+export const WorkspaceArtifacts = ({
+ artifacts,
+ mode,
+}: {
+ artifacts: Artifact[];
+ mode: 'diffs' | 'artifacts';
+}) => {
+ const diffArtifacts = artifacts.filter(
+ (artifact) =>
+ artifact.contentType === 'text/x-diff' || artifact.kind === 'diff',
+ );
+ const visible =
+ mode === 'diffs'
+ ? diffArtifacts
+ : artifacts.filter((artifact) => !diffArtifacts.includes(artifact));
+
+ return (
+
+
+ {mode === 'diffs' ? 'Diffs' : 'Artifacts'}
+
+ {visible.length ? (
+ visible.map((artifact) => (
+
+
+ {artifact.title}
+
+ {mode === 'diffs' ? (
+
+ ) : (
+ <>
+
+ {artifact.content.slice(0, 2_000)}
+
+
+ void Clipboard.setStringAsync(artifact.content)
+ }
+ >
+ Copy artifact
+
+ >
+ )}
+
+ ))
+ ) : (
+
+ {mode === 'diffs'
+ ? 'Diff artifacts will appear here when the worker records them.'
+ : 'No non-diff artifacts recorded.'}
+
+ )}
+
+ );
+};
diff --git a/apps/expo/src/components/workspace/workspace-events.tsx b/apps/expo/src/components/workspace/workspace-events.tsx
new file mode 100644
index 0000000..225e495
--- /dev/null
+++ b/apps/expo/src/components/workspace/workspace-events.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+
+import { Card } from '~/components/ui/card';
+import { ChipRow } from '~/components/ui/chip-row';
+import { formatDateTime, titleize } from '~/utils/format';
+
+type Event = {
+ _id: string;
+ createdAt: number;
+ level: string;
+ message: string;
+ phase: string;
+};
+
+export const WorkspaceEvents = ({ events }: { events: Event[] }) => {
+ const [level, setLevel] = useState<'all' | 'info' | 'warn' | 'error'>('all');
+ const filtered =
+ level === 'all' ? events : events.filter((event) => event.level === level);
+
+ return (
+
+ Events
+
+ {filtered.length ? (
+ filtered.map((event) => (
+
+
+ {formatDateTime(event.createdAt)} · {titleize(event.phase)} ·{' '}
+ {titleize(event.level)}
+
+ {event.message}
+
+ ))
+ ) : (
+ No events.
+ )}
+
+ );
+};
diff --git a/apps/expo/src/components/workspace/workspace-messages.tsx b/apps/expo/src/components/workspace/workspace-messages.tsx
new file mode 100644
index 0000000..77a4c92
--- /dev/null
+++ b/apps/expo/src/components/workspace/workspace-messages.tsx
@@ -0,0 +1,26 @@
+import { Text, View } from 'react-native';
+
+import { Card } from '~/components/ui/card';
+import { titleize } from '~/utils/format';
+
+export const WorkspaceMessages = ({
+ messages,
+}: {
+ messages: { _id: string; content: string; role: string; status: string }[];
+}) => (
+
+ Messages
+ {messages.length ? (
+ messages.map((message) => (
+
+
+ {titleize(message.role)} · {titleize(message.status)}
+
+ {message.content}
+
+ ))
+ ) : (
+ No messages yet.
+ )}
+
+);
diff --git a/apps/expo/src/components/workspace/workspace-summary.tsx b/apps/expo/src/components/workspace/workspace-summary.tsx
new file mode 100644
index 0000000..06f0194
--- /dev/null
+++ b/apps/expo/src/components/workspace/workspace-summary.tsx
@@ -0,0 +1,68 @@
+import { Linking, Text, View } from 'react-native';
+
+import { Badge } from '~/components/ui/badge';
+import { Button } from '~/components/ui/button';
+import { Card } from '~/components/ui/card';
+import { ConfirmButton } from '~/components/ui/confirm-button';
+import { CopyRow } from '~/components/ui/copy-row';
+import { formatDateTime, titleize } from '~/utils/format';
+
+export const WorkspaceSummary = ({
+ cancelling,
+ job,
+ onCancel,
+}: {
+ cancelling: boolean;
+ job: {
+ completedAt?: number;
+ model: string;
+ pullRequestUrl?: string;
+ reasoningEffort: string;
+ startedAt?: number;
+ status: string;
+ workBranch: string;
+ workspaceStatus?: string;
+ };
+ onCancel: () => void;
+}) => (
+
+
+
+
+
+
+ Branch: {job.workBranch}
+
+ Model: {job.model}
+
+ Reasoning: {titleize(job.reasoningEffort)}
+
+
+ Started: {formatDateTime(job.startedAt)}
+
+ {job.completedAt ? (
+
+ Completed: {formatDateTime(job.completedAt)}
+
+ ) : null}
+
+ {job.pullRequestUrl ? (
+ void Linking.openURL(job.pullRequestUrl ?? '')}>
+ Open draft PR
+
+ ) : null}
+
+ {cancelling ? 'Cancelling...' : 'Cancel job'}
+
+
+);
diff --git a/apps/expo/src/utils/env.ts b/apps/expo/src/utils/env.ts
new file mode 100644
index 0000000..5f4320f
--- /dev/null
+++ b/apps/expo/src/utils/env.ts
@@ -0,0 +1,26 @@
+export type ParsedEnvSecret = {
+ name: string;
+ value: string;
+};
+
+export const parseEnvText = (text: string): ParsedEnvSecret[] => {
+ const secrets: ParsedEnvSecret[] = [];
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ const normalized = line.startsWith('export ') ? line.slice(7).trim() : line;
+ const separator = normalized.indexOf('=');
+ if (separator <= 0) continue;
+ const name = normalized.slice(0, separator).trim();
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
+ let value = normalized.slice(separator + 1).trim();
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ secrets.push({ name: name.toUpperCase(), value });
+ }
+ return secrets;
+};
diff --git a/apps/expo/src/utils/format.ts b/apps/expo/src/utils/format.ts
new file mode 100644
index 0000000..ea24273
--- /dev/null
+++ b/apps/expo/src/utils/format.ts
@@ -0,0 +1,18 @@
+export const formatDate = (value?: number) =>
+ value
+ ? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
+ : 'Never';
+
+export const formatDateTime = (value?: number) =>
+ value
+ ? new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(value)
+ : 'Never';
+
+export const titleize = (value?: string) =>
+ value?.replaceAll('_', ' ') ?? 'unknown';
+
+export const truncate = (value: string, length = 80) =>
+ value.length > length ? `${value.slice(0, length - 3)}...` : value;
diff --git a/apps/expo/tests/component/forms.test.tsx b/apps/expo/tests/component/forms.test.tsx
new file mode 100644
index 0000000..c65fd11
--- /dev/null
+++ b/apps/expo/tests/component/forms.test.tsx
@@ -0,0 +1,187 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+import { AiProviderProfileForm } from '../../src/components/settings/ai-provider-profile-form';
+import { SpoonAgentSettingsForm } from '../../src/components/spoons/spoon-agent-settings-form';
+import { SpoonSecretsPanel } from '../../src/components/spoons/spoon-secrets-panel';
+
+describe('mobile forms', () => {
+ test('SpoonSecretsPanel previews secret names only and imports parsed env values', async () => {
+ const onImportSecrets = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('AUTH_SECRET=...'), {
+ target: {
+ value: 'AUTH_SECRET=super-secret\nexport AUTHENTIK_CLIENT_ID=client',
+ },
+ });
+
+ expect(screen.getAllByText(/AUTH_SECRET/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/AUTHENTIK_CLIENT_ID/).length).toBeGreaterThan(
+ 0,
+ );
+ expect(screen.getByText(/valid secrets found/).textContent).not.toContain(
+ 'super-secret',
+ );
+
+ fireEvent.click(screen.getByText('Import secrets'));
+
+ await waitFor(() =>
+ expect(onImportSecrets).toHaveBeenCalledWith([
+ { name: 'AUTH_SECRET', value: 'super-secret' },
+ { name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
+ ]),
+ );
+ });
+
+ test('SpoonSecretsPanel disables import with no parsed secrets', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Import secrets').closest('button')).toBeDisabled();
+ });
+
+ test('AiProviderProfileForm selects default model from model options', async () => {
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('gpt-5.1-codex'));
+ fireEvent.click(screen.getByText('gpt-5.5'));
+ fireEvent.click(screen.getByText('Save provider'));
+
+ await waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ defaultModel: 'gpt-5.5' }),
+ ),
+ );
+ });
+
+ test('AiProviderProfileForm shows Codex auth JSON instructions', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/~\/.codex\/auth.json/)).toBeTruthy();
+ });
+
+ test('SpoonAgentSettingsForm disables provider/model controls without provider profiles', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText('Configure an AI provider in Settings'),
+ ).toBeTruthy();
+ expect(
+ screen.getByText('No models available').closest('button'),
+ ).toBeDisabled();
+ });
+
+ test('SpoonAgentSettingsForm applies selected provider defaults', async () => {
+ const onUpdate = vi.fn().mockResolvedValue(undefined);
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('OpenAI'));
+ fireEvent.click(screen.getByText('Anthropic'));
+
+ await waitFor(() =>
+ expect(onUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentModel: 'claude-sonnet-4-5',
+ reasoningEffort: 'low',
+ }),
+ ),
+ );
+ });
+});
diff --git a/apps/expo/tests/component/routes-smoke.test.tsx b/apps/expo/tests/component/routes-smoke.test.tsx
new file mode 100644
index 0000000..2ca8884
--- /dev/null
+++ b/apps/expo/tests/component/routes-smoke.test.tsx
@@ -0,0 +1,127 @@
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import DashboardRoute from '../../src/app/(app)/dashboard';
+import SettingsRoute from '../../src/app/(app)/settings';
+import SpoonsRoute from '../../src/app/(app)/spoons';
+import ThreadsRoute from '../../src/app/(app)/threads';
+import WorkspaceRoute from '../../src/app/(app)/workspace/[jobId]';
+import { mockedUseQuery } from '../setup';
+
+describe('mobile route smoke tests', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedUseQuery.mockReset();
+ });
+
+ test('Dashboard renders metrics from mocked Convex data', () => {
+ mockedUseQuery
+ .mockReturnValueOnce([
+ {
+ _id: 'spoon-1',
+ status: 'active',
+ syncStatus: 'behind',
+ upstreamAheadBy: 3,
+ },
+ ] as never)
+ .mockReturnValueOnce([] as never)
+ .mockReturnValueOnce([
+ {
+ _id: 'thread-1',
+ source: 'user_request',
+ status: 'open',
+ title: 'Update auth',
+ updatedAt: Date.UTC(2026, 0, 1),
+ },
+ ] as never);
+
+ render( );
+
+ expect(screen.getByText('Dashboard')).toBeTruthy();
+ expect(screen.getByText('Update auth')).toBeTruthy();
+ expect(screen.getByText('Upstream commits')).toBeTruthy();
+ });
+
+ test('Spoons list renders empty state and one row', () => {
+ mockedUseQuery
+ .mockReturnValueOnce([
+ {
+ _id: 'spoon-1',
+ forkOwner: 'gib',
+ forkRepo: 'usesend',
+ name: 'usesend-authentik',
+ status: 'active',
+ syncStatus: 'up_to_date',
+ upstreamAheadBy: 0,
+ upstreamOwner: 'usesend',
+ upstreamRepo: 'usesend',
+ },
+ ] as never)
+ .mockReturnValueOnce([] as never);
+
+ render( );
+
+ expect(screen.getByText('Spoons')).toBeTruthy();
+ expect(screen.getByText('usesend-authentik')).toBeTruthy();
+ });
+
+ test('Threads list renders filters and rows', () => {
+ mockedUseQuery.mockReturnValueOnce([
+ {
+ _id: 'thread-1',
+ source: 'upstream_update',
+ status: 'waiting_for_user',
+ title: 'Upstream auth changes landed',
+ updatedAt: Date.UTC(2026, 0, 1),
+ },
+ ] as never);
+
+ render( );
+
+ expect(screen.getByText('Waiting')).toBeTruthy();
+ expect(screen.getByText('Upstream auth changes landed')).toBeTruthy();
+ });
+
+ test('Workspace route renders tabs and job status', () => {
+ mockedUseQuery
+ .mockReturnValueOnce({
+ _id: 'job-1',
+ model: 'gpt-5.1-codex',
+ reasoningEffort: 'medium',
+ status: 'running',
+ workBranch: 'spoon/thread/example',
+ workspaceStatus: 'active',
+ } as never)
+ .mockReturnValueOnce([] as never)
+ .mockReturnValueOnce([] as never)
+ .mockReturnValueOnce([] as never);
+
+ render( );
+
+ expect(screen.getByText('Workspace review')).toBeTruthy();
+ expect(screen.getByText('Messages')).toBeTruthy();
+ expect(screen.getByText('running')).toBeTruthy();
+ });
+
+ test('Settings index renders GitHub and AI provider summaries', () => {
+ mockedUseQuery
+ .mockReturnValueOnce({ email: 'gib@example.com' } as never)
+ .mockReturnValueOnce({
+ displayName: 'gibbyb',
+ status: 'active',
+ } as never)
+ .mockReturnValueOnce([
+ {
+ _id: 'provider-1',
+ isDefault: true,
+ name: 'Codex',
+ },
+ ] as never);
+
+ render( );
+
+ expect(screen.getByText('gib@example.com')).toBeTruthy();
+ expect(screen.getByText('GitHub connected as gibbyb')).toBeTruthy();
+ expect(screen.getByText('1 provider, default Codex')).toBeTruthy();
+ });
+});
diff --git a/apps/expo/tests/component/ui-primitives.test.tsx b/apps/expo/tests/component/ui-primitives.test.tsx
new file mode 100644
index 0000000..a51dc50
--- /dev/null
+++ b/apps/expo/tests/component/ui-primitives.test.tsx
@@ -0,0 +1,124 @@
+import { Alert } from 'react-native';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge';
+import { ThreadStatusBadge } from '../../src/components/threads/thread-status-badge';
+import { ConfirmButton } from '../../src/components/ui/confirm-button';
+import { PillTabs } from '../../src/components/ui/pill-tabs';
+import { SheetSelect } from '../../src/components/ui/sheet-select';
+import { DiffPreview } from '../../src/components/workspace/diff-preview';
+
+describe('mobile UI primitives', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('PillTabs renders labels and changes selection', () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Settings'));
+
+ expect(screen.getByText('Overview')).toBeTruthy();
+ expect(onChange).toHaveBeenCalledWith('settings');
+ });
+
+ test('SheetSelect opens and chooses an option', () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('OpenAI'));
+ fireEvent.click(screen.getByText('Anthropic'));
+
+ expect(onChange).toHaveBeenCalledWith('anthropic');
+ });
+
+ test('SheetSelect respects disabled state', () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('OpenAI'));
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ test('ConfirmButton delegates confirmation to Alert', () => {
+ const onConfirm = vi.fn();
+
+ render(
+
+ Remove
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Remove'));
+ const calls = vi.mocked(Alert.alert).mock.calls;
+ const confirm = calls[0]?.[2]?.[1];
+ confirm?.onPress?.();
+
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ test('DiffPreview truncates and expands long diffs', () => {
+ const diff = Array.from({ length: 125 }, (_, index) =>
+ index % 2 === 0 ? `+added ${index}` : `-removed ${index}`,
+ ).join('\n');
+
+ render( );
+
+ expect(screen.getByText('+added 0')).toBeTruthy();
+ expect(screen.queryByText('-removed 5')).toBeNull();
+
+ fireEvent.click(screen.getByText('Show 122 more lines'));
+
+ expect(screen.getByText('-removed 5')).toBeTruthy();
+ });
+
+ test('status badges render readable labels', () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(screen.getByText('up to date')).toBeTruthy();
+ expect(screen.getByText('waiting for user')).toBeTruthy();
+ });
+});
diff --git a/apps/expo/tests/setup.ts b/apps/expo/tests/setup.ts
new file mode 100644
index 0000000..5a286ca
--- /dev/null
+++ b/apps/expo/tests/setup.ts
@@ -0,0 +1,138 @@
+import React from 'react';
+
+import '@testing-library/jest-dom/vitest';
+
+import { cleanup } from '@testing-library/react';
+import { afterEach, vi } from 'vitest';
+
+Object.defineProperty(globalThis, '__DEV__', {
+ configurable: true,
+ value: false,
+});
+
+const createElement =
+ (tag: string) =>
+ ({
+ children,
+ onChangeText,
+ onPress,
+ value,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ onChangeText?: (value: string) => void;
+ onPress?: () => void;
+ value?: string;
+ [key: string]: unknown;
+ }) => {
+ const safeProps: Record = {
+ ...props,
+ className:
+ typeof props.className === 'string' ? props.className : undefined,
+ disabled: props.disabled as boolean | undefined,
+ onChange: onChangeText
+ ? (event: React.ChangeEvent) =>
+ onChangeText(event.currentTarget.value)
+ : undefined,
+ onClick: onPress,
+ value,
+ };
+ delete safeProps.keyboardType;
+ delete safeProps.keyboardShouldPersistTaps;
+ delete safeProps.placeholderTextColor;
+ delete safeProps.secureTextEntry;
+ delete safeProps.showsHorizontalScrollIndicator;
+ delete safeProps.textAlignVertical;
+
+ return React.createElement(tag, safeProps, children);
+ };
+
+const TextInput = ({
+ multiline,
+ ...props
+}: {
+ multiline?: boolean;
+ [key: string]: unknown;
+}) => createElement(multiline ? 'textarea' : 'input')(props);
+
+const mocks = vi.hoisted(() => ({
+ alert: vi.fn(),
+ useAction: vi.fn(() => vi.fn()),
+ useMutation: vi.fn(() => vi.fn()),
+ useQuery: vi.fn(() => undefined),
+}));
+
+vi.mock('react-native', () => ({
+ Alert: { alert: mocks.alert },
+ Linking: { openURL: vi.fn() },
+ Modal: ({
+ children,
+ visible,
+ }: {
+ children?: React.ReactNode;
+ visible?: boolean;
+ }) => (visible ? React.createElement('div', {}, children) : null),
+ Pressable: createElement('button'),
+ Platform: {
+ OS: 'web',
+ select: (values: Record) => values.web ?? values.default,
+ },
+ RefreshControl: createElement('div'),
+ ScrollView: createElement('div'),
+ Switch: createElement('input'),
+ Text: createElement('span'),
+ TextInput,
+ TurboModuleRegistry: {
+ get: vi.fn(() => undefined),
+ getEnforcing: vi.fn(() => ({})),
+ },
+ View: createElement('div'),
+}));
+
+vi.mock('expo-clipboard', () => ({
+ setStringAsync: vi.fn(),
+}));
+
+vi.mock('expo-haptics', () => ({
+ impactAsync: vi.fn(),
+ notificationAsync: vi.fn(),
+ selectionAsync: vi.fn(),
+}));
+
+vi.mock('react-native-safe-area-context', () => ({
+ SafeAreaView: createElement('div'),
+}));
+
+vi.mock('expo-router', () => ({
+ Link: ({ children }: { children?: React.ReactNode }) => children,
+ Stack: {
+ Screen: () => null,
+ },
+ useLocalSearchParams: () => ({}),
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ }),
+}));
+
+vi.mock('convex/react', () => ({
+ useAction: mocks.useAction,
+ useMutation: mocks.useMutation,
+ useQuery: mocks.useQuery,
+}));
+
+vi.mock('@convex-dev/auth/react', () => ({
+ useAuthActions: () => ({
+ signIn: vi.fn(),
+ signOut: vi.fn(),
+ }),
+}));
+
+export const mockedAlert = mocks.alert;
+export const mockedUseAction = mocks.useAction;
+export const mockedUseMutation = mocks.useMutation;
+export const mockedUseQuery = mocks.useQuery;
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/apps/expo/tests/unit/env.test.ts b/apps/expo/tests/unit/env.test.ts
new file mode 100644
index 0000000..15f5c3e
--- /dev/null
+++ b/apps/expo/tests/unit/env.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from 'vitest';
+
+import { parseEnvText } from '../../src/utils/env';
+
+describe('parseEnvText', () => {
+ test('parses dotenv content without exposing invalid rows', () => {
+ expect(
+ parseEnvText(`
+ # comment
+ AUTH_SECRET="secret=value"
+ export authentik_client_id='client'
+ 1INVALID=nope
+ EMPTY=
+ `),
+ ).toEqual([
+ { name: 'AUTH_SECRET', value: 'secret=value' },
+ { name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
+ { name: 'EMPTY', value: '' },
+ ]);
+ });
+
+ test('ignores blank lines and strips matching quotes only', () => {
+ expect(
+ parseEnvText(`
+
+ PLAIN=value
+ QUOTED="value"
+ SINGLE='value'
+ UNMATCHED="value
+ `),
+ ).toEqual([
+ { name: 'PLAIN', value: 'value' },
+ { name: 'QUOTED', value: 'value' },
+ { name: 'SINGLE', value: 'value' },
+ { name: 'UNMATCHED', value: '"value' },
+ ]);
+ });
+});
diff --git a/apps/expo/tests/unit/format.test.ts b/apps/expo/tests/unit/format.test.ts
new file mode 100644
index 0000000..324d7ef
--- /dev/null
+++ b/apps/expo/tests/unit/format.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+ formatDate,
+ formatDateTime,
+ titleize,
+ truncate,
+} from '../../src/utils/format';
+
+describe('format utilities', () => {
+ test('formats missing timestamps as never', () => {
+ expect(formatDate(undefined)).toBe('Never');
+ expect(formatDateTime(undefined)).toBe('Never');
+ });
+
+ test('formats known timestamps', () => {
+ const value = Date.UTC(2026, 0, 2, 3, 4, 5);
+
+ expect(formatDate(value)).toContain('2026');
+ expect(formatDateTime(value)).toContain('2026');
+ });
+
+ test('titleizes machine values', () => {
+ expect(titleize('waiting_for_user')).toBe('waiting for user');
+ });
+
+ test('truncates long text', () => {
+ expect(truncate('abcdef', 4)).toBe('a...');
+ expect(truncate('abc', 4)).toBe('abc');
+ });
+});
diff --git a/apps/expo/tsconfig.json b/apps/expo/tsconfig.json
index 63a873c..e0d4aeb 100644
--- a/apps/expo/tsconfig.json
+++ b/apps/expo/tsconfig.json
@@ -10,6 +10,7 @@
},
"include": [
"src",
+ "tests",
"*.ts",
"*.js",
".expo/types/**/*.ts",
diff --git a/apps/expo/vitest.config.ts b/apps/expo/vitest.config.ts
new file mode 100644
index 0000000..3e495d9
--- /dev/null
+++ b/apps/expo/vitest.config.ts
@@ -0,0 +1,35 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+import { jsdomProject, nodeProject } from '@spoon/vitest-config';
+
+const srcRoot = fileURLToPath(new URL('./src', import.meta.url));
+const setupFile = fileURLToPath(new URL('./tests/setup.ts', import.meta.url));
+const alias = {
+ '~': srcRoot,
+ '~/': `${srcRoot}/`,
+};
+
+const componentProject = jsdomProject('component', [
+ 'tests/component/**/*.test.{ts,tsx}',
+]);
+
+export default defineConfig({
+ resolve: {
+ alias,
+ },
+ test: {
+ projects: [
+ nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
+ nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
+ {
+ ...componentProject,
+ resolve: { alias },
+ test: {
+ ...componentProject.test,
+ setupFiles: [setupFile],
+ },
+ },
+ ],
+ },
+});
diff --git a/bun.lock b/bun.lock
index 0539531..5a272b8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,6 +51,7 @@
"convex": "catalog:convex",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
+ "expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
@@ -81,11 +82,14 @@
"@spoon/prettier-config": "workspace:*",
"@spoon/tailwind-config": "workspace:*",
"@spoon/tsconfig": "workspace:*",
+ "@spoon/vitest-config": "workspace:*",
+ "@testing-library/react": "catalog:test",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
+ "vitest": "catalog:test",
},
},
"apps/next": {
@@ -523,7 +527,7 @@
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
- "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
@@ -2097,6 +2101,8 @@
"expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="],
+ "expo-clipboard": ["expo-clipboard@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA=="],
+
"expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="],
"expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
@@ -3369,10 +3375,6 @@
"@babel/traverse--for-generate-function-map/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
- "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
-
- "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
-
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
@@ -3723,16 +3725,12 @@
"@tailwindcss/postcss/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
- "@testing-library/dom/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
-
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
- "@testing-library/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
-
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@types/babel__template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -3869,6 +3867,8 @@
"eslint-plugin-turbo/dotenv": ["dotenv@16.0.3", "", {}, "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="],
+ "expo/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"expo-dev-launcher/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"expo-modules-autolinking/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -3959,6 +3959,8 @@
"metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
+ "metro-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"metro-transform-plugins/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -4025,6 +4027,8 @@
"react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "react-native-web/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
@@ -4937,12 +4941,16 @@
"@react-native/community-cli-plugin/metro-config/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+ "@react-native/community-cli-plugin/metro-config/metro-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"@react-native/community-cli-plugin/metro/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="],
"@react-native/community-cli-plugin/metro/metro-cache/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+ "@react-native/community-cli-plugin/metro/metro-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"@react-native/community-cli-plugin/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
"@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="],