Update expo application
Build and Push Next App / quality (push) Successful in 1m27s
Build and Push Next App / build-next (push) Successful in 3m58s

This commit is contained in:
Gabriel Brown
2026-06-22 12:13:02 -04:00
parent ddce5efb13
commit 42f95530de
78 changed files with 5315 additions and 421 deletions
+375 -224
View File
@@ -1,269 +1,420 @@
# Spoon <p align="center">
<img src="apps/next/public/favicon.png" alt="Spoon logo" width="96" height="96" />
</p>
Spoon is a self-hostable fork maintenance cockpit. <h1 align="center">Spoon</h1>
Forking a project should not mean supporting it alone. Spoon tracks managed <p align="center">
forks, called **Spoons**, watches upstream for drift, automatically syncs clean <strong>Fork freely & keep them all intimately close to upstream.</strong>
forks when it can, and opens durable **Threads** when upstream changes need </p>
review, context, or code.
This repository is the Spoon application itself, not a generic starter. <p align="center">
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
durable maintenance threads, and OpenCode-powered workspaces.
</p>
## What Spoon Does <p align="center">
<a href="#what-this-is">What this is</a>
·
<a href="#product-model">Product model</a>
·
<a href="#architecture">Architecture</a>
·
<a href="#environment-reference">Environment</a>
</p>
- 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. Forking a project is easy. Keeping that fork close to upstream after you add
- Authenticated web routes: custom changes is the hard part. Spoon treats a fork as an ongoing relationship:
- `/dashboard` it watches upstream, understands fork-only commits, automatically syncs clean
- `/spoons` drift when it can, and opens a durable **Thread** when a decision needs context
- `/spoons/new` or code.
- `/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.
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. ## Highlights
- Git provider automation beyond GitHub.
- Additional remotes as push targets. - **Managed forks, called Spoons**
- Long-running service-stack orchestration inside agent jobs. Track upstream metadata, fork metadata, clone URLs, extra remotes, sync
- Direct browser access to worker containers. cadence, production-ref strategy, fork-only commits, and pull requests.
- Production mobile build/release setup.
- **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
<details open>
<summary><strong>Spoons</strong></summary>
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?
</details>
<details open>
<summary><strong>Threads</strong></summary>
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.
</details>
<details open>
<summary><strong>Maintenance decisions</strong></summary>
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.
</details>
<details>
<summary><strong>OpenCode workspaces</strong></summary>
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.
</details>
## Architecture ## Architecture
- `apps/next`: Next.js 16 web app and primary product UI. <details open>
- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and <summary><strong>Workspace layout</strong></summary>
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:
```txt ```txt
.local/<environment>.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. </details>
Useful helpers: <details>
<summary><strong>Core tables</strong></summary>
| 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 |
</details>
<details>
<summary><strong>Important routes</strong></summary>
| 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`.
</details>
## Mobile App
<details open>
<summary><strong>Current Expo scope</strong></summary>
`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.
</details>
<details>
<summary><strong>Expo validation</strong></summary>
Useful mobile checks:
```sh ```sh
sh scripts/with-env dev -- <command> bun --filter @spoon/expo lint
sh scripts/export-env dev bun --filter @spoon/expo typecheck
bun sync:convex bun --filter @spoon/expo test:unit
bun sync:convex:staging 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 </details>
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 ## Environment Reference
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
values into local Convex first. Run it manually when needed:
```sh This project is currently private, so this section is a reference for what the
sh scripts/sync-convex-env dev application expects rather than public setup documentation.
sh scripts/sync-convex-env staging
INFISICAL_ENV=staging bun sync:convex
```
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`, <details open>
`SPOON_WORKER_TOKEN`, and related generated values are created automatically if <summary><strong>Public Next variables</strong></summary>
they are not already present. The generated Convex admin key remains
machine-local in `.local/dev.generated.env`; do not put it in Infisical.
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 </details>
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 <details>
the full PEM contents to Infisical as `GITHUB_APP_PRIVATE_KEY` and rerun the <summary><strong>Auth and email</strong></summary>
sync command.
## 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 </details>
bun dev:next
bun dev:expo
bun dev:agent
```
Physical devices cannot resolve their own `localhost`; override the public <details>
Convex URL with the development host's LAN address when testing Expo on-device. <summary><strong>GitHub App</strong></summary>
Shared dependency versions belong in root catalogs. Edit the root catalog, run | Variable | Used for |
`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace. | ---------------------------- | ---------------------------------- |
| `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 </details>
Routine checks: <details>
<summary><strong>Convex, storage, and runtime</strong></summary>
```sh | Variable | Used for |
bun lint:ws | ----------------------------------- | ----------------------------------------------- |
bun format | `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
bun lint | `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
bun typecheck | `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
bun run test | `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: </details>
```sh <details>
SKIP_E2E=1 bun run ci:check <summary><strong>Deployment and observability</strong></summary>
```
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 </details>
bun test:e2e
```
`bun test:e2e` starts the isolated local stack when needed and stops it ## Current Status
afterward only when it was not already running.
Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in <details open>
test runner instead of the repo's Turbo/Vitest test script. <summary><strong>Implemented</strong></summary>
## 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, </details>
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, <details>
builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA <summary><strong>Intentionally not done yet</strong></summary>
and `latest` tags. CI never installs or invokes Infisical.
- 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
</details>
## 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.
+8 -1
View File
@@ -12,6 +12,9 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore", "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config", "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", "typecheck": "tsc --noEmit",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --" "with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
}, },
@@ -27,6 +30,7 @@
"convex": "catalog:convex", "convex": "catalog:convex",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8", "expo-apple-authentication": "~8.0.8",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
@@ -57,11 +61,14 @@
"@spoon/prettier-config": "workspace:*", "@spoon/prettier-config": "workspace:*",
"@spoon/tailwind-config": "workspace:*", "@spoon/tailwind-config": "workspace:*",
"@spoon/tsconfig": "workspace:*", "@spoon/tsconfig": "workspace:*",
"@spoon/vitest-config": "workspace:*",
"@testing-library/react": "catalog:test",
"@types/react": "catalog:react19", "@types/react": "catalog:react19",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:" "typescript": "catalog:",
"vitest": "catalog:test"
}, },
"prettier": "@spoon/prettier-config" "prettier": "@spoon/prettier-config"
} }
+56
View File
@@ -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 <LoadingState />;
if (!isAuthenticated) return <Redirect href='/sign-in' />;
return (
<Tabs
screenOptions={({ route }) => ({
headerShown: false,
tabBarActiveTintColor: '#0f766e',
tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b',
tabBarStyle: {
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0',
},
tabBarIcon: ({ color, focused, size }) => (
<Ionicons
color={color}
name={iconName(route.name, focused)}
size={size}
/>
),
})}
>
<Tabs.Screen name='dashboard' options={{ title: 'Dashboard' }} />
<Tabs.Screen name='spoons' options={{ title: 'Spoons' }} />
<Tabs.Screen name='threads' options={{ title: 'Threads' }} />
<Tabs.Screen name='workspace/[jobId]' options={{ href: null }} />
<Tabs.Screen name='settings' options={{ title: 'Settings' }} />
</Tabs>
);
};
export default AppTabs;
+148
View File
@@ -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 (
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
<Stack.Screen options={{ title: 'Dashboard' }} />
<View className='flex-row items-start justify-between gap-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground text-3xl font-bold'>Dashboard</Text>
<Text className='text-muted-foreground mt-1'>
Managed forks, upstream drift, and open maintenance threads.
</Text>
</View>
<Link href='/spoons/new' asChild>
<Button>New</Button>
</Link>
</View>
<View className='gap-3'>
<View className='flex-row gap-3'>
<MetricCard
label='Spoons'
note={`${active} active`}
value={spoons.length}
/>
<MetricCard
label='Behind'
note={`${diverged} diverged`}
value={behind}
/>
</View>
<View className='flex-row gap-3'>
<MetricCard label='Open threads' value={openThreads.length} />
<MetricCard label='Upstream commits' value={upstreamWaiting} />
</View>
</View>
<View className='gap-3'>
<Text className='text-foreground text-lg font-semibold'>
Maintenance queue
</Text>
{openThreads.length ? (
openThreads
.slice(0, 5)
.map((thread) => (
<ThreadListRow
key={thread._id}
thread={thread}
onPress={() => router.push(`/threads/${thread._id}`)}
/>
))
) : (
<EmptyState
description='Threads appear when you request work or upstream changes need review.'
title='No open maintenance threads'
/>
)}
</View>
<View className='gap-3'>
<Text className='text-foreground text-lg font-semibold'>
Recent Spoons
</Text>
{spoons.length ? (
spoons
.slice(0, 5)
.map((spoon) => (
<SpoonListRow
key={spoon._id}
spoon={spoon}
onPress={() => router.push(`/spoons/${spoon._id}`)}
/>
))
) : (
<EmptyState
description='Create your first managed fork to start tracking upstream drift.'
title='No Spoons yet'
/>
)}
</View>
<View className='gap-3'>
<Text className='text-foreground text-lg font-semibold'>
Recent activity
</Text>
<Card className='gap-3'>
{syncRuns.length ? (
syncRuns.map((run) => (
<View key={run._id} className='border-border border-b pb-3'>
<Text className='text-foreground font-medium'>
{titleize(run.kind)}
</Text>
<Text className='text-muted-foreground text-sm'>
{titleize(run.status)}
</Text>
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>
Upstream checks will appear here.
</Text>
)}
</Card>
</View>
</AppScreen>
);
};
export default DashboardRoute;
@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
const SettingsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
export default SettingsLayout;
@@ -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<typeof save>[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 (
<AppScreen>
<Stack.Screen options={{ title: 'Edit provider' }} />
<Text className='text-muted-foreground'>Loading provider...</Text>
</AppScreen>
);
}
return (
<AppScreen>
<Stack.Screen
options={{ title: profileId ? 'Edit provider' : 'New provider' }}
/>
<Text className='text-foreground text-3xl font-bold'>
{profileId ? 'Edit provider' : 'New provider'}
</Text>
<AiProviderProfileForm
existing={existing ?? undefined}
saving={saving}
onSubmit={submit}
/>
</AppScreen>
);
};
export default AiProviderFormRoute;
@@ -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 (
<AppScreen>
<Stack.Screen options={{ title: 'AI providers' }} />
<View className='flex-row items-start justify-between gap-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground text-3xl font-bold'>
AI providers
</Text>
<Text className='text-muted-foreground mt-1'>
Provider profiles for OpenCode workspaces.
</Text>
</View>
<Link href='/settings/ai-provider-form' asChild>
<Button>New</Button>
</Link>
</View>
{profiles.length ? (
profiles.map((profile) => (
<ListRow
key={profile._id}
subtitle={`${titleize(profile.provider)} · ${profile.defaultModel}`}
title={profile.name}
onPress={() =>
router.push(`/settings/ai-provider-form?profileId=${profile._id}`)
}
>
<View className='flex-row flex-wrap gap-2'>
<Badge
label={profile.configured ? 'configured' : 'missing credential'}
tone={profile.configured ? 'success' : 'warning'}
/>
{profile.isDefault ? (
<Badge label='default' tone='primary' />
) : null}
<Badge label={profile.enabled ? 'enabled' : 'disabled'} />
</View>
<View className='mt-3 flex-row gap-2'>
<Button
disabled={!profile.configured || !profile.enabled}
variant='outline'
onPress={() => void setDefault({ profileId: profile._id })}
>
Set default
</Button>
<Button
variant='danger'
onPress={() =>
Alert.alert('Remove provider', `Remove ${profile.name}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => void remove({ profileId: profile._id }),
},
])
}
>
Remove
</Button>
</View>
</ListRow>
))
) : (
<EmptyState
description='Add an OpenAI, Codex/OpenCode, Anthropic, OpenRouter, or compatible provider before queueing agent work.'
title='No AI providers'
/>
)}
</AppScreen>
);
};
export default AiProvidersRoute;
@@ -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 (
<AppScreen>
<Stack.Screen options={{ title: 'Settings' }} />
<Text className='text-foreground text-3xl font-bold'>Settings</Text>
<Link href='/settings/profile' asChild>
<ListRow
subtitle={
user?.email ?? 'Name, email, provider, and password settings'
}
title='Profile'
/>
</Link>
<Link href='/settings/integrations' asChild>
<ListRow
subtitle={
connection
? `GitHub connected as ${connection.displayName}`
: 'GitHub App connection and accessible repositories'
}
title='Integrations'
/>
</Link>
<Link href='/settings/ai-providers' asChild>
<ListRow
subtitle={
defaultProvider
? `${providers.length} provider${providers.length === 1 ? '' : 's'}, default ${defaultProvider.name}`
: 'OpenCode, Codex auth, API keys, and default models'
}
title='AI providers'
/>
</Link>
<Button
variant='danger'
onPress={() =>
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
</Button>
</AppScreen>
);
};
export default SettingsRoute;
@@ -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 (
<AppScreen onRefresh={() => void sync()} refreshing={syncing}>
<Stack.Screen options={{ title: 'Integrations' }} />
<Text className='text-foreground text-3xl font-bold'>Integrations</Text>
<GitHubIntegrationPanel
connection={connection}
installUrl={installUrl}
loadingRepos={loadingRepos}
runtimeStatus={status}
syncing={syncing}
onListRepos={listRepos}
onSync={sync}
/>
</AppScreen>
);
};
export default IntegrationsRoute;
@@ -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 (
<AppScreen>
<Stack.Screen options={{ title: 'Profile' }} />
<Text className='text-foreground text-3xl font-bold'>Profile</Text>
<Card className='gap-4'>
<Text className='text-muted-foreground text-sm'>
Email is currently managed by {titleize(provider ?? 'your provider')}.
</Text>
<Field label='Name' value={name} onChangeText={setName} />
<Field
keyboardType='email-address'
label='Email'
value={email}
onChangeText={setEmail}
/>
<Button disabled={savingProfile} onPress={() => void saveProfile()}>
{savingProfile ? 'Saving...' : 'Save profile'}
</Button>
</Card>
{provider === 'password' ? (
<Card className='gap-4'>
<Text className='text-foreground font-semibold'>Password</Text>
<Field
label='Current password'
secureTextEntry
value={currentPassword}
onChangeText={setCurrentPassword}
/>
<Field
label='New password'
secureTextEntry
value={newPassword}
onChangeText={setNewPassword}
/>
<Button
disabled={savingPassword}
variant='outline'
onPress={() => void savePassword()}
>
{savingPassword ? 'Updating...' : 'Update password'}
</Button>
</Card>
) : (
<Card>
<Text className='text-muted-foreground text-sm leading-5'>
Password changes are hidden because this account is currently using{' '}
{titleize(provider ?? 'an OAuth provider')}.
</Text>
</Card>
)}
</AppScreen>
);
};
export default ProfileRoute;
@@ -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<SpoonDetailSegment>('overview');
const [threadPrompt, setThreadPrompt] = useState('');
const [pending, setPending] = useState<string | undefined>();
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 (
<AppScreen>
<Stack.Screen options={{ title: 'Spoon' }} />
<Text className='text-muted-foreground'>Loading Spoon...</Text>
</AppScreen>
);
}
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<string, unknown>) => {
setPending('settings');
try {
await updateAgentSettings({ spoonId, ...patch });
} finally {
setPending(undefined);
}
},
updateMaintenance: async (patch: Record<string, unknown>) => {
setPending('settings');
try {
await updateMaintenanceSettings({ spoonId, ...patch });
} finally {
setPending(undefined);
}
},
updateSpoon: async (patch: Record<string, unknown>) => {
setPending('settings');
try {
await updateSpoonSettings({ spoonId, ...patch });
} finally {
setPending(undefined);
}
},
};
return (
<AppScreen onRefresh={() => void runRefresh()} refreshing={refreshing}>
<Stack.Screen options={{ title: spoon.name }} />
<View className='gap-2'>
<Text className='text-foreground text-3xl font-bold'>{spoon.name}</Text>
<View className='flex-row flex-wrap gap-2'>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
<SpoonStatusBadge status={spoon.status} />
</View>
</View>
<View className='flex-row gap-3'>
<Button
disabled={pending === 'refresh'}
onPress={() => void runRefresh()}
>
{pending === 'refresh' ? 'Refreshing...' : 'Refresh'}
</Button>
<Button
disabled={!canSync || pending === 'sync'}
variant='outline'
onPress={() => void runSync()}
>
{pending === 'sync' ? 'Syncing...' : 'Sync fork'}
</Button>
</View>
<SegmentControl value={segment} onChange={setSegment} />
{segment === 'overview' ? (
<SpoonDetailOverview
effectiveUpstreamAheadBy={effectiveUpstreamAheadBy}
remotes={remotes}
spoon={spoon}
/>
) : null}
{segment === 'upstream' ? (
<SpoonDetailUpstream commits={upstreamCommits} />
) : null}
{segment === 'fork' ? <SpoonDetailFork commits={forkCommits} /> : null}
{segment === 'prs' ? (
<SpoonDetailPrs pullRequests={pullRequests} />
) : null}
{segment === 'threads' ? (
<SpoonDetailThreads
creating={pending === 'thread'}
prompt={threadPrompt}
setPrompt={setThreadPrompt}
threads={threads}
onCreate={() => void submitThread()}
onOpenThread={(threadId) => router.push(`/threads/${threadId}`)}
/>
) : null}
{segment === 'settings' ? (
<SpoonDetailSettings
actions={settingsActions}
agentSettings={agentSettings ?? undefined}
maintenanceSettings={spoonSettings ?? undefined}
pending={{
addingRemote: pending === 'addRemote',
addingSecret: pending === 'addSecret',
importingSecrets: pending === 'importSecrets',
removingRemoteId: pending?.startsWith('remote:')
? pending.slice('remote:'.length)
: undefined,
removingSecretId: pending?.startsWith('secret:')
? pending.slice('secret:'.length)
: undefined,
savingSettings: pending === 'settings',
}}
providerProfiles={providerProfiles}
remotes={remotes}
secrets={secrets}
spoon={spoon}
/>
) : null}
</AppScreen>
);
};
export default SpoonDetailRoute;
@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
const SpoonsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
export default SpoonsLayout;
+81
View File
@@ -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 (
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
<Stack.Screen options={{ title: 'Spoons' }} />
<View className='flex-row items-start justify-between gap-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground text-3xl font-bold'>Spoons</Text>
<Text className='text-muted-foreground mt-1'>
Managed forks and their relationship with upstream.
</Text>
</View>
<Link href='/spoons/new' asChild>
<Button>New</Button>
</Link>
</View>
<View className='flex-row gap-3'>
<MetricCard label='Managed' value={spoons.length} />
<MetricCard label='Active' value={active} />
<MetricCard label='Waiting' value={upstreamWaiting} />
</View>
<View className='gap-3'>
{spoons.length ? (
spoons.map((spoon) => (
<SpoonListRow
key={spoon._id}
openThreads={openThreadsFor(spoon._id)}
spoon={spoon}
onPress={() => router.push(`/spoons/${spoon._id}`)}
/>
))
) : (
<EmptyState
description='Create a manual Spoon record to start shaping fork maintenance.'
title='No managed forks yet'
/>
)}
</View>
</AppScreen>
);
};
export default SpoonsRoute;
+396
View File
@@ -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<typeof api.githubNode.listInstallationRepositories>
>
>
>[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<CreateMode>('manual');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [provider, setProvider] = useState<Provider>('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<Visibility>('unknown');
const [maintenanceMode, setMaintenanceMode] =
useState<MaintenanceMode>('watch');
const [syncCadence, setSyncCadence] = useState<SyncCadence>('daily');
const [productionRefStrategy, setProductionRefStrategy] =
useState<ProductionRefStrategy>('default_branch');
const [tagPattern, setTagPattern] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loadingRepos, setLoadingRepos] = useState(false);
const [repositories, setRepositories] = useState<Repository[]>([]);
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 (
<AppScreen>
<Stack.Screen options={{ title: 'New Spoon' }} />
<View>
<Text className='text-foreground text-3xl font-bold'>New Spoon</Text>
<Text className='text-muted-foreground mt-1'>
Create a managed fork record manually or from GitHub.
</Text>
</View>
<PillTabs
tabs={[
{ label: 'Manual', value: 'manual' },
{ label: 'GitHub', value: 'github' },
]}
value={mode}
onChange={setMode}
/>
{mode === 'manual' ? (
<>
<FormSection title='Basics'>
<Field label='Spoon name' value={name} onChangeText={setName} />
<Field
label='Description'
multiline
value={description}
onChangeText={setDescription}
/>
<SheetSelect
label='Git provider'
options={[
{ label: 'GitHub', value: 'github' },
{ label: 'Gitea', value: 'gitea' },
{ label: 'GitLab', value: 'gitlab' },
{ label: 'Other', value: 'other' },
]}
value={provider}
onChange={setProvider}
/>
</FormSection>
<FormSection title='Upstream'>
<Field
label='Owner/org'
value={upstreamOwner}
onChangeText={setUpstreamOwner}
/>
<Field
label='Repository'
value={upstreamRepo}
onChangeText={setUpstreamRepo}
/>
<Field
label='Default branch'
value={upstreamDefaultBranch}
onChangeText={setUpstreamDefaultBranch}
/>
<Field
keyboardType='url'
label='Upstream URL'
value={upstreamUrl}
onChangeText={setUpstreamUrl}
/>
</FormSection>
<FormSection title='Fork'>
<Field
label='Owner/org'
value={forkOwner}
onChangeText={setForkOwner}
/>
<Field
label='Repository'
value={forkRepo}
onChangeText={setForkRepo}
/>
<Field
label='Default branch'
value={forkDefaultBranch}
onChangeText={setForkDefaultBranch}
/>
<Field
keyboardType='url'
label='Fork URL'
value={forkUrl}
onChangeText={setForkUrl}
/>
</FormSection>
<FormSection title='Maintenance'>
<SheetSelect
label='Visibility'
options={[
{ label: 'Unknown', value: 'unknown' },
{ label: 'Public', value: 'public' },
{ label: 'Private', value: 'private' },
{ label: 'Internal', value: 'internal' },
]}
value={visibility}
onChange={setVisibility}
/>
<SheetSelect
label='Maintenance mode'
options={[
{ label: 'Watch', value: 'watch' },
{ label: 'Auto PR', value: 'auto_pr' },
{ label: 'Paused', value: 'paused' },
]}
value={maintenanceMode}
onChange={setMaintenanceMode}
/>
<SheetSelect
label='Sync cadence'
options={[
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Manual', value: 'manual' },
]}
value={syncCadence}
onChange={setSyncCadence}
/>
<SheetSelect
label='Production ref'
options={[
{ label: 'Default branch', value: 'default_branch' },
{ label: 'Latest release', value: 'latest_release' },
{ label: 'Tag pattern', value: 'tag_pattern' },
]}
value={productionRefStrategy}
onChange={setProductionRefStrategy}
/>
{productionRefStrategy === 'tag_pattern' ? (
<Field
label='Tag pattern'
value={tagPattern}
onChangeText={setTagPattern}
/>
) : null}
</FormSection>
<Button disabled={submitting} onPress={() => void submitManual()}>
{submitting ? 'Creating...' : 'Create Spoon'}
</Button>
</>
) : (
<FormSection
description='Repository listing is read from the GitHub App installation.'
title='GitHub'
>
<View className='flex-row items-center justify-between'>
<Text className='text-foreground font-medium'>Connection</Text>
<Badge
label={connection?.status ?? 'not connected'}
tone={connection ? 'success' : 'warning'}
/>
</View>
{installUrl ? (
<Button onPress={() => void Linking.openURL(installUrl)}>
Install or manage GitHub App
</Button>
) : null}
<View className='flex-row gap-3'>
<Button
disabled={loadingRepos}
variant='outline'
onPress={() => void syncGithub()}
>
Sync
</Button>
<Button
disabled={!connection || loadingRepos}
onPress={() => void loadRepos()}
>
{loadingRepos ? 'Loading...' : 'Load repositories'}
</Button>
</View>
<Text className='text-muted-foreground text-sm leading-5'>
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.
</Text>
<Field
label='Upstream owner/org'
value={upstreamOwner}
onChangeText={setUpstreamOwner}
/>
<Field
label='Upstream repository'
value={upstreamRepo}
onChangeText={setUpstreamRepo}
/>
<Field
keyboardType='url'
label='Upstream URL'
value={upstreamUrl}
onChangeText={setUpstreamUrl}
/>
{!loadingRepos && connection && repositories.length === 0 ? (
<Card>
<Text className='text-muted-foreground text-sm leading-5'>
Load accessible repositories to create a Spoon from GitHub
metadata.
</Text>
</Card>
) : null}
{repositories.map((repo) => (
<Card key={repo.id} className='gap-2'>
<Text className='text-foreground font-semibold'>
{repo.fullName}
</Text>
<Text className='text-muted-foreground text-xs'>
{repo.private ? 'Private' : 'Public'} ·{' '}
{repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch}
</Text>
<Button
disabled={submitting}
variant='outline'
onPress={() => confirmCreateFromRepo(repo)}
>
Create Spoon from metadata
</Button>
</Card>
))}
</FormSection>
)}
</AppScreen>
);
};
export default NewSpoonRoute;
@@ -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<string | undefined>();
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 (
<AppScreen>
<Stack.Screen options={{ title: 'Thread' }} />
<Text className='text-muted-foreground'>Loading thread...</Text>
</AppScreen>
);
}
const { thread, spoon, latestJob } = details;
const pullRequestUrl = latestJob?.pullRequestUrl;
const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
);
return (
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
<Stack.Screen options={{ title: thread.title }} />
<View className='gap-2'>
<Text className='text-foreground text-3xl font-bold'>
{thread.title}
</Text>
<View className='flex-row flex-wrap gap-2'>
<ThreadStatusBadge status={thread.status} />
<Badge label={titleize(thread.source)} />
{thread.maintenanceOutcome ? (
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
) : null}
</View>
<Text className='text-muted-foreground text-sm'>
Updated {formatDateTime(thread.updatedAt)}
</Text>
</View>
{spoon ? (
<Card>
<Text className='text-muted-foreground text-xs'>Spoon</Text>
<Text className='text-foreground mt-1 font-semibold'>
{spoon.name}
</Text>
<Link href={`/spoons/${spoon._id}`} asChild>
<Button variant='outline'>Open Spoon</Button>
</Link>
</Card>
) : null}
{latestJob ? (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Latest job</Text>
<Text className='text-muted-foreground text-sm'>
{titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)}
</Text>
<Text className='text-muted-foreground text-sm'>
Branch: {latestJob.workBranch}
</Text>
<Link href={`/workspace/${latestJob._id}`} asChild>
<Button variant='outline'>Open workspace review</Button>
</Link>
{pullRequestUrl ? (
<Button onPress={() => void Linking.openURL(pullRequestUrl)}>
Open draft PR
</Button>
) : null}
</Card>
) : null}
<ThreadMessageList messages={messages} />
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Reply</Text>
<Field
label='Message'
multiline
value={message}
onChangeText={setMessage}
/>
<Button
disabled={completed || pending === 'send'}
onPress={() => void send()}
>
{pending === 'send' ? 'Sending...' : 'Send message'}
</Button>
</Card>
<View className='flex-row gap-3'>
<Button
disabled={completed || pending === 'resolve'}
variant='outline'
onPress={() => void resolveThread()}
>
{pending === 'resolve' ? 'Resolving...' : 'Resolve'}
</Button>
<ConfirmButton
confirmLabel='Cancel thread'
destructive
disabled={completed || pending === 'cancel'}
message='Cancel this thread?'
title='Cancel thread'
onConfirm={() => void cancelThread()}
>
{pending === 'cancel' ? 'Cancelling...' : 'Cancel'}
</ConfirmButton>
</View>
</AppScreen>
);
};
export default ThreadDetailRoute;
@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
const ThreadsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
export default ThreadsLayout;
+74
View File
@@ -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<StatusFilter>[] = [
{ 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<StatusFilter>('all');
const [refreshing, setRefreshing] = useState(false);
const threads =
useQuery(api.threads.listMine, {
limit: 50,
status,
}) ?? [];
const softRefresh = () => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 600);
};
return (
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
<Stack.Screen options={{ title: 'Threads' }} />
<View>
<Text className='text-foreground text-3xl font-bold'>Threads</Text>
<Text className='text-muted-foreground mt-1'>
Maintenance decisions, user requests, and workspace handoffs.
</Text>
</View>
<PillTabs onChange={setStatus} tabs={filters} value={status} />
<View className='gap-3'>
{threads.length ? (
threads.map((thread) => (
<ThreadListRow
key={thread._id}
thread={thread}
onPress={() => router.push(`/threads/${thread._id}`)}
/>
))
) : (
<EmptyState
description='Threads appear when you ask Spoon to change a fork or upstream changes need review.'
title='No threads'
/>
)}
</View>
</AppScreen>
);
};
export default ThreadsRoute;
@@ -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<WorkspaceTab>[] = [
{ 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<WorkspaceTab>('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 (
<AppScreen>
<Stack.Screen options={{ title: 'Workspace' }} />
<Text className='text-muted-foreground'>Loading workspace...</Text>
</AppScreen>
);
}
return (
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
<Stack.Screen options={{ title: 'Workspace' }} />
<View className='gap-2'>
<Text className='text-foreground text-3xl font-bold'>
Workspace review
</Text>
<Text className='text-muted-foreground'>
Inspect the active job without exposing worker internals to mobile.
</Text>
</View>
<PillTabs onChange={setTab} tabs={tabs} value={tab} />
{tab === 'status' ? (
<WorkspaceSummary
cancelling={cancelling}
job={job}
onCancel={() => void cancelJob()}
/>
) : null}
{tab === 'messages' ? <WorkspaceMessages messages={messages} /> : null}
{tab === 'diffs' ? (
<WorkspaceArtifacts artifacts={artifacts} mode='diffs' />
) : null}
{tab === 'events' ? <WorkspaceEvents events={events} /> : null}
{tab === 'artifacts' ? (
<WorkspaceArtifacts artifacts={artifacts} mode='artifacts' />
) : null}
</AppScreen>
);
};
export default WorkspaceRoute;
@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
const WorkspaceLayout = () => <Stack screenOptions={{ headerShown: false }} />;
export default WorkspaceLayout;
+12
View File
@@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
import { SignInScreen } from '~/components/auth/sign-in-screen';
const SignInRoute = () => (
<>
<Stack.Screen options={{ title: 'Sign in' }} />
<SignInScreen />
</>
);
export default SignInRoute;
+17 -166
View File
@@ -1,177 +1,28 @@
import { useMemo, useState } from 'react'; import { useEffect } from 'react';
import { Alert, Pressable, Text, TextInput, View } from 'react-native'; import { Stack, useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context'; import { useConvexAuth } from 'convex/react';
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 { api } from '@spoon/backend/convex/_generated/api.js'; import { LoadingState } from '~/components/ui/loading-state';
WebBrowser.maybeCompleteAuthSession(); const IndexRoute = () => {
const Stat = ({ label, value }: { label: string; value: number }) => (
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
<Text className='text-muted-foreground text-xs'>{label}</Text>
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
</View>
);
const Index = () => {
const { isAuthenticated, isLoading } = useConvexAuth(); const { isAuthenticated, isLoading } = useConvexAuth();
const { signIn, signOut } = useAuthActions(); const router = useRouter();
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 handlePasswordSignIn = async () => { useEffect(() => {
setSubmitting(true); if (isLoading) return;
try { if (isAuthenticated) {
await signIn('password', { email, password, flow: 'signIn' }); router.replace('/dashboard');
} catch (error) { } else {
console.error(error); router.replace('/sign-in');
Alert.alert('Sign in failed', 'Check your email and password.');
} finally {
setSubmitting(false);
} }
}; }, [isAuthenticated, isLoading, router]);
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);
}
};
return ( return (
<SafeAreaView className='bg-background flex-1'> <>
<Stack.Screen options={{ title: 'Spoon' }} /> <Stack.Screen options={{ title: 'Spoon' }} />
<View className='flex-1 gap-5 p-6'> <LoadingState label='Opening Spoon...' />
<View> </>
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
<Text className='text-muted-foreground mt-2 text-base leading-6'>
Fork freely. Stay close to upstream.
</Text>
</View>
{isLoading ? (
<Text className='text-muted-foreground'>Loading...</Text>
) : isAuthenticated ? (
<View className='gap-5'>
<View>
<Text className='text-foreground text-xl font-semibold'>
Welcome{user?.name ? `, ${user.name}` : ''}
</Text>
<Text className='text-muted-foreground mt-1'>
Monitor your managed forks from anywhere.
</Text>
</View>
<View className='flex-row gap-3'>
<Stat label='Spoons' value={spoons.length} />
<Stat label='Checks' value={syncRuns.length} />
<Stat label='Threads' value={threads.length} />
</View>
<View className='border-border bg-card rounded-lg border p-4'>
<Text className='text-foreground font-semibold'>
Recent Spoons
</Text>
{spoons.length ? (
spoons.slice(0, 4).map((spoon) => (
<Text key={spoon._id} className='text-muted-foreground mt-3'>
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
</Text>
))
) : (
<Text className='text-muted-foreground mt-3'>
Create your first Spoon from the web dashboard.
</Text>
)}
</View>
<Pressable
className='bg-primary items-center rounded-md p-3'
onPress={() => void signOut()}
>
<Text className='text-primary-foreground font-semibold'>
Sign out
</Text>
</Pressable>
</View>
) : (
<View className='gap-4'>
<TextInput
className='border-input text-foreground rounded-md border px-3 py-3'
autoCapitalize='none'
keyboardType='email-address'
placeholder='Email'
placeholderTextColor='#64748b'
value={email}
onChangeText={setEmail}
/>
<TextInput
className='border-input text-foreground rounded-md border px-3 py-3'
secureTextEntry
placeholder='Password'
placeholderTextColor='#64748b'
value={password}
onChangeText={setPassword}
/>
<Pressable
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
disabled={submitting}
onPress={() => void handlePasswordSignIn()}
>
<Text className='text-primary-foreground font-semibold'>
Sign in with password
</Text>
</Pressable>
<Pressable
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
disabled={submitting}
onPress={() => void handleAuthentikSignIn()}
>
<Text className='text-foreground font-semibold'>
Continue with Authentik
</Text>
</Pressable>
<Text className='text-muted-foreground text-sm'>
Register the native redirect URI based on spoon:// in Authentik.
</Text>
</View>
)}
</View>
</SafeAreaView>
); );
}; };
export default Index; export default IndexRoute;
-21
View File
@@ -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 (
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Post' }} />
<View className='flex-1 p-4'>
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
<Text className='text-muted-foreground mt-2'>
Implement your post detail screen here using Convex queries.
</Text>
</View>
</SafeAreaView>
);
};
export default Post;
@@ -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 (
<AppScreen>
<View className='gap-2'>
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
<Text className='text-muted-foreground text-base leading-6'>
Fork freely & keep them close to upstream.
</Text>
</View>
<Card className='gap-3'>
<Button
disabled={submitting}
onPress={() => void signInWithOAuth('github')}
>
Continue with GitHub
</Button>
<Button
disabled={submitting}
variant='outline'
onPress={() => void signInWithOAuth('authentik')}
>
Continue with Authentik
</Button>
</Card>
<Card className='gap-4'>
<Text className='text-foreground font-semibold'>
Sign in with email
</Text>
<Field
keyboardType='email-address'
label='Email'
placeholder='you@example.com'
value={email}
onChangeText={setEmail}
/>
<Field
label='Password'
placeholder='Password'
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Button disabled={submitting} onPress={() => void signInWithPassword()}>
Sign in with email
</Button>
</Card>
<Text className='text-muted-foreground text-sm leading-5'>
Native OAuth callbacks should allow the `spoon://` redirect scheme.
</Text>
</AppScreen>
);
};
@@ -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<void>;
saving: boolean;
}) => {
const [name, setName] = useState(existing?.name ?? 'OpenCode provider');
const [provider, setProvider] = useState<Provider>(
existing?.provider ?? 'opencode_openai_login',
);
const [authType, setAuthType] = useState<AuthType>(
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<ReasoningEffort>(
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 (
<FormSection title={existing ? 'Edit provider' : 'New provider'}>
<Field label='Name' value={name} onChangeText={setName} />
<SheetSelect
label='Provider'
options={[
{ label: 'OpenCode OpenAI login', value: 'opencode_openai_login' },
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Google', value: 'google' },
{ label: 'OpenRouter', value: 'openrouter' },
{ label: 'Requesty', value: 'requesty' },
{ label: 'LiteLLM', value: 'litellm' },
{ label: 'Cloudflare AI Gateway', value: 'cloudflare_ai_gateway' },
{
label: 'Custom OpenAI compatible',
value: 'custom_openai_compatible',
},
]}
value={provider}
onChange={changeProvider}
/>
<SheetSelect
label='Auth type'
options={[
{ label: 'API key', value: 'api_key' },
{ label: 'OpenCode auth JSON', value: 'opencode_auth_json' },
{ label: 'None', value: 'none' },
]}
value={authType}
onChange={setAuthType}
/>
{authType === 'opencode_auth_json' ? (
<Text className='text-muted-foreground text-sm leading-5'>
Copy auth.json from your Codex/OpenCode auth folder, for example
~/.codex/auth.json, and paste it here.
</Text>
) : null}
{authType !== 'none' ? (
<Field
label={authType === 'api_key' ? 'API key' : 'Auth JSON'}
multiline={authType === 'opencode_auth_json'}
secureTextEntry={authType === 'api_key'}
value={secret}
onChangeText={setSecret}
/>
) : null}
<Field label='Base URL' value={baseUrl} onChangeText={setBaseUrl} />
<Textarea
label='Model options'
value={modelOptions}
onChangeText={setModelOptions}
/>
<SheetSelect
disabled={!models.length}
label='Default model'
options={
models.length
? models.map((model) => ({ label: model, value: model }))
: [{ label: 'Add model options first', value: '' }]
}
value={models.includes(defaultModel) ? defaultModel : (models[0] ?? '')}
onChange={setDefaultModel}
/>
<SheetSelect
label='Reasoning effort'
options={[
{ label: 'None', value: 'none' },
{ label: 'Minimal', value: 'minimal' },
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
{ label: 'XHigh', value: 'xhigh' },
]}
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SwitchRow label='Enabled' value={enabled} onValueChange={setEnabled} />
<Button disabled={saving || !models.length} onPress={submit}>
{saving ? 'Saving...' : 'Save provider'}
</Button>
</FormSection>
);
};
@@ -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<string[]>;
onSync: () => Promise<void>;
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 (
<>
<Card className='gap-3'>
<View className='flex-row items-center justify-between'>
<Text className='text-foreground font-semibold'>GitHub App</Text>
<Badge
label={connection?.status ?? 'not connected'}
tone={connection ? 'success' : 'warning'}
/>
</View>
{connection ? (
<>
<Text className='text-muted-foreground text-sm'>
{connection.displayName}
</Text>
<Text className='text-muted-foreground text-xs'>
Installation {connection.installationId ?? 'unknown'}
</Text>
</>
) : (
<Text className='text-muted-foreground text-sm'>
Connect GitHub so Spoon can create forks, compare branches, and open
draft PRs.
</Text>
)}
{installUrl ? (
<Button onPress={() => void Linking.openURL(installUrl)}>
Install or manage GitHub App
</Button>
) : null}
<Button
disabled={syncing}
variant='outline'
onPress={() => void sync()}
>
{syncing ? 'Syncing...' : 'Sync installation'}
</Button>
<Button
disabled={loadingRepos}
variant='outline'
onPress={() => void showRepos()}
>
{loadingRepos ? 'Loading...' : 'List repositories'}
</Button>
</Card>
<Card>
<Text className='text-foreground font-semibold'>Runtime status</Text>
<Text className='text-muted-foreground mt-2 text-sm'>
Encryption configured:{' '}
{runtimeStatus?.encryptionConfigured ? 'yes' : 'not reported'}
</Text>
</Card>
{!connection ? (
<EmptyState
description='Install the GitHub App, then sync the installation.'
title='GitHub is not connected yet'
/>
) : null}
</>
);
};
@@ -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<SpoonDetailSegment>[] = [
{ 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;
}) => <PillTabs onChange={onChange} tabs={tabs} value={value} />;
@@ -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<void>;
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<typeof onUpdate>[0]) =>
void onUpdate(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save agent settings.');
});
return (
<FormSection
description='Mobile can configure the runtime, but code editing still happens from the web workspace.'
title='Agent settings'
>
<SwitchRow
label='Enabled'
value={agent?.enabled ?? true}
onValueChange={(enabled) => save({ enabled })}
/>
<SwitchRow
label='Auto-detect commands'
value={agent?.autoDetectCommands ?? true}
onValueChange={(autoDetectCommands) => save({ autoDetectCommands })}
/>
<SwitchRow
label='Materialize env file'
value={agent?.materializeEnvFileByDefault ?? false}
onValueChange={(materializeEnvFileByDefault) =>
save({ materializeEnvFileByDefault })
}
/>
<SheetSelect
disabled={!enabledProfiles.length}
label='AI provider'
options={
enabledProfiles.length
? enabledProfiles.map((profile) => ({
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,
});
}
}}
/>
<SheetSelect
disabled={!models.length}
label='Model'
options={
models.length
? models.map((model) => ({ label: model, value: model }))
: [{ label: 'No models available', value: '' }]
}
value={currentModel}
onChange={(agentModel) => save({ agentModel })}
/>
<SheetSelect
label='Reasoning effort'
options={[
{ label: 'None', value: 'none' },
{ label: 'Minimal', value: 'minimal' },
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
{ label: 'XHigh', value: 'xhigh' },
]}
value={
agent?.reasoningEffort ?? selectedProfile?.reasoningEffort ?? 'medium'
}
onChange={(reasoningEffort) => save({ reasoningEffort })}
/>
{!enabledProfiles.length ? (
<Text className='text-muted-foreground text-sm leading-5'>
Configure an AI provider in Settings before queueing agent work.
</Text>
) : null}
<View className='gap-3'>
<Field
label='Branch prefix'
value={agent?.branchPrefix ?? 'spoon/agent'}
onChangeText={(branchPrefix) => save({ branchPrefix })}
/>
<Field
label='Install command'
value={agent?.installCommand ?? ''}
onChangeText={(installCommand) => save({ installCommand })}
/>
<Field
label='Check command'
value={agent?.checkCommand ?? ''}
onChangeText={(checkCommand) => save({ checkCommand })}
/>
<Field
label='Test command'
value={agent?.testCommand ?? ''}
onChangeText={(testCommand) => save({ testCommand })}
/>
<Field
label='Env file path'
value={agent?.envFilePath ?? '.env.local'}
onChangeText={(envFilePath) => save({ envFilePath })}
/>
</View>
</FormSection>
);
};
@@ -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;
}) => (
<View className='gap-3'>
{intro ? (
<Text className='text-muted-foreground text-sm'>{intro}</Text>
) : null}
{commits.length ? (
commits.map((commit) => (
<Card key={commit._id}>
<Text className='text-foreground font-medium'>
{truncate(commit.message, 100)}
</Text>
<Text className='text-muted-foreground mt-2 text-xs'>
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
{formatDateTime(commit.committedAt)}
</Text>
{showOpenButton && commit.htmlUrl ? (
<Button
variant='ghost'
onPress={() => void Linking.openURL(commit.htmlUrl ?? '')}
>
Open commit
</Button>
) : null}
</Card>
))
) : (
<EmptyState description={emptyDescription} title={emptyTitle} />
)}
</View>
);
@@ -0,0 +1,14 @@
import { SpoonCommitList } from './spoon-commit-list';
export const SpoonDetailFork = ({
commits,
}: {
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
}) => (
<SpoonCommitList
commits={commits}
emptyDescription='Fork-only commits appear after Spoon compares your fork with upstream.'
emptyTitle='No fork-only commits cached'
intro='Fork-only commits are customizations Spoon should preserve.'
/>
);
@@ -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;
}) => (
<View className='gap-4'>
<View className='flex-row gap-3'>
<MetricCard label='Raw upstream' value={spoon.upstreamAheadBy ?? 0} />
<MetricCard label='Effective' value={effectiveUpstreamAheadBy} />
<MetricCard label='Fork-only' value={spoon.forkAheadBy ?? 0} />
</View>
{spoon.description ? (
<Card>
<Text className='text-foreground font-semibold'>Description</Text>
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
{spoon.description}
</Text>
</Card>
) : null}
<Card>
<CopyRow label='Upstream' value={spoon.upstreamUrl} />
<CopyRow label='Fork clone URL' value={spoon.forkUrl} />
{remotes.map((remote) => (
<CopyRow key={remote._id} label={remote.label} value={remote.url} />
))}
</Card>
<Card>
<Text className='text-foreground font-semibold'>Details</Text>
<Text className='text-muted-foreground mt-2 text-sm'>
Last checked: {formatDate(spoon.lastCheckedAt)}
</Text>
<Text className='text-muted-foreground mt-1 text-sm'>
Cadence: {titleize(spoon.syncCadence)}
</Text>
<Text className='text-muted-foreground mt-1 text-sm'>
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
</Text>
{spoon.forkOwner && spoon.forkRepo ? (
<Text className='text-muted-foreground mt-1 text-sm'>
Fork: {spoon.forkOwner}/{spoon.forkRepo}
</Text>
) : null}
</Card>
</View>
);
@@ -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[];
}) => (
<View className='gap-3'>
{pullRequests.length ? (
pullRequests.map((pullRequest) => (
<Card key={pullRequest._id}>
<Text className='text-foreground font-medium'>
#{pullRequest.number} {pullRequest.title}
</Text>
<Text className='text-muted-foreground mt-2 text-xs'>
{titleize(pullRequest.state)} · {pullRequest.repoFullName}
</Text>
<Button
variant='outline'
onPress={() => void Linking.openURL(pullRequest.htmlUrl)}
>
Open PR
</Button>
</Card>
))
) : (
<EmptyState
description='Cached fork and upstream pull requests appear here.'
title='No pull requests cached'
/>
)}
</View>
);
@@ -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<void>;
addSecret: (name: string, value: string) => Promise<void>;
importSecrets: (
secrets: { name: string; value: string }[],
) => Promise<void>;
removeRemote: (remoteId: string) => Promise<void>;
removeSecret: (secretId: string) => Promise<void>;
updateAgent: (patch: Record<string, unknown>) => Promise<void>;
updateMaintenance: (patch: Record<string, unknown>) => Promise<void>;
updateSpoon: (patch: Record<string, unknown>) => Promise<void>;
};
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';
};
}) => (
<View className='gap-4'>
<SpoonMaintenanceSettingsForm
maintenance={maintenanceSettings}
saving={pending.savingSettings}
spoon={spoon}
onUpdateMaintenance={actions.updateMaintenance}
onUpdateSpoon={actions.updateSpoon}
/>
<SpoonAgentSettingsForm
agent={agentSettings}
profiles={providerProfiles}
onUpdate={actions.updateAgent}
/>
<SpoonSecretsPanel
adding={pending.addingSecret}
importing={pending.importingSecrets}
removingId={pending.removingSecretId}
secrets={secrets}
onAddSecret={actions.addSecret}
onImportSecrets={actions.importSecrets}
onRemoveSecret={actions.removeSecret}
/>
<SpoonRemotesPanel
adding={pending.addingRemote}
remotes={remotes}
removingId={pending.removingRemoteId}
onAddRemote={actions.addRemote}
onRemoveRemote={actions.removeRemote}
/>
</View>
);
@@ -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<typeof ThreadListRow>[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[];
}) => (
<View className='gap-3'>
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>New thread</Text>
<Textarea
label='Prompt'
placeholder='Ask Spoon to review or change this fork...'
value={prompt}
onChangeText={setPrompt}
/>
<Button disabled={creating || !prompt.trim()} onPress={onCreate}>
{creating ? 'Creating...' : 'Create thread'}
</Button>
</Card>
{threads.length ? (
threads.map((thread) => (
<ThreadListRow
key={thread._id}
thread={thread}
onPress={() => onOpenThread(thread._id)}
/>
))
) : (
<EmptyState
description='Create a thread when this fork needs review or code.'
title='No threads yet'
/>
)}
</View>
);
@@ -0,0 +1,14 @@
import { SpoonCommitList } from './spoon-commit-list';
export const SpoonDetailUpstream = ({
commits,
}: {
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
}) => (
<SpoonCommitList
commits={commits}
emptyDescription='Upstream commits waiting for this fork will appear after refresh.'
emptyTitle='No upstream commits cached'
showOpenButton
/>
);
@@ -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;
}) => (
<ListRow
meta={formatDate(spoon.lastCheckedAt)}
subtitle={`${spoon.upstreamOwner}/${spoon.upstreamRepo}`}
title={spoon.name}
onPress={onPress}
>
<View className='gap-3'>
<View className='flex-row flex-wrap items-center gap-2'>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
{spoon.forkOwner && spoon.forkRepo ? (
<Text className='text-muted-foreground text-xs'>
fork {spoon.forkOwner}/{spoon.forkRepo}
</Text>
) : (
<Text className='text-muted-foreground text-xs'>missing fork</Text>
)}
</View>
<View className='flex-row gap-4'>
<Text className='text-muted-foreground text-xs'>
{spoon.upstreamAheadBy ?? 0} upstream
</Text>
<Text className='text-muted-foreground text-xs'>
{spoon.forkAheadBy ?? 0} fork-only
</Text>
<Text className='text-muted-foreground text-xs'>
{openThreads ?? 0} threads
</Text>
</View>
</View>
</ListRow>
);
@@ -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<void>;
onUpdateSpoon: (patch: {
maintenanceMode?: MaintenanceMode;
syncCadence?: Cadence;
}) => Promise<void>;
saving: boolean;
spoon: { maintenanceMode: MaintenanceMode; syncCadence: Cadence };
}) => {
const updateMaintenance = (
patch: Parameters<typeof onUpdateMaintenance>[0],
) =>
void onUpdateMaintenance(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save maintenance settings.');
});
const updateSpoon = (patch: Parameters<typeof onUpdateSpoon>[0]) =>
void onUpdateSpoon(patch).catch((error: unknown) => {
console.error(error);
Alert.alert('Could not save Spoon settings.');
});
return (
<>
<FormSection
description='These settings control scheduled checks and safe automation.'
title='Maintenance settings'
>
<SwitchRow
description='Let scheduled checks consider this Spoon.'
label='Auto refresh'
value={maintenance?.autoRefreshEnabled ?? true}
onValueChange={(autoRefreshEnabled) =>
updateMaintenance({ autoRefreshEnabled })
}
/>
<SwitchRow
label='Auto review'
value={maintenance?.autoReviewEnabled ?? true}
onValueChange={(autoReviewEnabled) =>
updateMaintenance({ autoReviewEnabled })
}
/>
<SwitchRow
label='Auto sync'
value={maintenance?.autoSyncEnabled ?? false}
onValueChange={(autoSyncEnabled) =>
updateMaintenance({ autoSyncEnabled })
}
/>
{saving ? (
<Text className='text-muted-foreground text-xs'>Saving...</Text>
) : null}
</FormSection>
<FormSection title='Spoon settings'>
<SheetSelect
label='Cadence'
options={[
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Manual', value: 'manual' },
]}
value={spoon.syncCadence}
onChange={(syncCadence) => updateSpoon({ syncCadence })}
/>
<SheetSelect
label='Maintenance mode'
options={[
{ label: 'Watch', value: 'watch' },
{ label: 'Auto PR', value: 'auto_pr' },
{ label: 'Paused', value: 'paused' },
]}
value={spoon.maintenanceMode}
onChange={(maintenanceMode) => updateSpoon({ maintenanceMode })}
/>
</FormSection>
</>
);
};
@@ -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<void>;
onRemoveRemote: (remoteId: string) => Promise<void>;
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 (
<FormSection title='Additional remotes'>
{remotes.map((remote) => (
<View
key={remote._id}
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{remote.label}</Text>
<Text className='text-muted-foreground text-xs'>{remote.url}</Text>
</View>
<ConfirmButton
confirmLabel='Remove'
destructive
disabled={removingId === remote._id}
message={`Remove ${remote.label} from this Spoon?`}
title='Remove remote'
onConfirm={() => void onRemoveRemote(remote._id)}
>
{removingId === remote._id ? 'Removing...' : 'Remove'}
</ConfirmButton>
</View>
))}
<Field label='Label' value={label} onChangeText={setLabel} />
<Field keyboardType='url' label='URL' value={url} onChangeText={setUrl} />
<Button disabled={adding || !label.trim() || !url.trim()} onPress={add}>
{adding ? 'Adding...' : 'Add remote'}
</Button>
</FormSection>
);
};
@@ -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<void>;
onImportSecrets: (
secrets: { name: string; value: string }[],
) => Promise<void>;
onRemoveSecret: (secretId: string) => Promise<void>;
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 (
<FormSection
description='Secret values are encrypted and never shown after saving.'
title='Secrets'
>
{secrets.map((secret) => (
<View
key={secret._id}
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{secret.name}</Text>
<Text className='text-muted-foreground text-xs'>
{secret.valuePreview ?? 'configured'}
</Text>
</View>
<ConfirmButton
confirmLabel='Remove'
destructive
disabled={removingId === secret._id}
message={`Remove ${secret.name} from this Spoon?`}
title='Remove secret'
onConfirm={() => void onRemoveSecret(secret._id)}
>
{removingId === secret._id ? 'Removing...' : 'Remove'}
</ConfirmButton>
</View>
))}
<View className='gap-3'>
<Text className='text-foreground font-semibold'>Add one secret</Text>
<Field label='Name' value={name} onChangeText={setName} />
<Field
label='Value'
secureTextEntry
value={value}
onChangeText={setValue}
/>
<Button
disabled={adding || !name.trim() || !value.trim()}
onPress={add}
>
{adding ? 'Adding...' : 'Add secret'}
</Button>
</View>
<View className='gap-3'>
<Text className='text-foreground font-semibold'>Import .env</Text>
<Textarea
label='.env contents'
placeholder='AUTH_SECRET=...'
value={envText}
onChangeText={setEnvText}
/>
<Text className='text-muted-foreground text-sm'>
{parsed.length
? `${parsed.length} valid secrets found: ${preview
.map((secret) => secret.name)
.join(', ')}${parsed.length > preview.length ? ', ...' : ''}`
: 'Paste .env contents to preview secret names.'}
</Text>
<View className='flex-row gap-3'>
<Button
disabled={importing || !parsed.length}
onPress={() => void importAll()}
>
{importing ? 'Importing...' : 'Import secrets'}
</Button>
<Button variant='outline' onPress={() => setEnvText('')}>
Clear
</Button>
</View>
</View>
</FormSection>
);
};
@@ -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 }) => (
<Badge label={titleize(status)} tone={toneForStatus(status)} />
);
@@ -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;
}) => (
<ListRow
meta={formatDateTime(thread.updatedAt)}
subtitle={thread.summary ? truncate(thread.summary, 90) : undefined}
title={thread.title}
onPress={onPress}
>
<View className='flex-row flex-wrap gap-2'>
<ThreadStatusBadge status={thread.status} />
<Badge label={titleize(thread.source)} />
{thread.maintenanceOutcome ? (
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
) : null}
</View>
{thread.upstreamTo ? (
<Text className='text-muted-foreground mt-3 text-xs'>
upstream {thread.upstreamTo.slice(0, 12)}
</Text>
) : null}
</ListRow>
);
@@ -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'>[];
}) => (
<View className='gap-3'>
{messages.map((message) => (
<View
key={message._id}
className={
message.role === 'user'
? 'border-primary/30 bg-primary/10 rounded-lg border p-3'
: 'border-border bg-card rounded-lg border p-3'
}
>
<Text className='text-muted-foreground text-xs'>
{titleize(message.role)} · {titleize(message.status)}
</Text>
<Text className='text-foreground mt-2 leading-5'>
{message.content}
</Text>
</View>
))}
</View>
);
@@ -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 }) => (
<Badge label={titleize(status)} tone={toneForStatus(status)} />
);
@@ -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;
}) => (
<Pressable
className='border-border min-h-14 flex-row items-center justify-between gap-3 border-b py-3'
{...props}
>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{label}</Text>
{detail ? (
<Text className='text-muted-foreground mt-1 text-xs'>{detail}</Text>
) : null}
</View>
<Text className='text-muted-foreground text-lg'></Text>
</Pressable>
);
@@ -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 (
<SafeAreaView className='bg-background flex-1'>
<View className='flex-1 p-4'>{children}</View>
</SafeAreaView>
);
}
return (
<SafeAreaView className='bg-background flex-1'>
<ScrollView
className='flex-1'
contentContainerClassName='gap-4 p-4 pb-10'
keyboardShouldPersistTaps='handled'
refreshControl={
onRefresh ? (
<RefreshControl onRefresh={onRefresh} refreshing={refreshing} />
) : undefined
}
>
{children}
</ScrollView>
</SafeAreaView>
);
};
+27
View File
@@ -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 (
<Text
className={`self-start rounded-md px-2 py-1 text-xs font-semibold capitalize ${toneClass}`}
>
{label}
</Text>
);
};
+39
View File
@@ -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<ComponentProps<typeof Pressable>, '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 (
<Pressable
className={`items-center rounded-md px-4 py-3 disabled:opacity-50 ${variantClass}`}
disabled={disabled}
onPress={onPress}
{...props}
>
<Text className={`font-semibold ${textClass}`}>{children}</Text>
</Pressable>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { View } from 'react-native';
export const Card = ({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) => (
<View className={`border-border bg-card rounded-lg border p-4 ${className}`}>
{children}
</View>
);
+42
View File
@@ -0,0 +1,42 @@
import { Pressable, ScrollView, Text } from 'react-native';
export const ChipRow = <T extends string>({
onChange,
options,
value,
}: {
onChange: (value: T) => void;
options: { label: string; value: T }[];
value: T;
}) => (
<ScrollView
horizontal
contentContainerClassName='gap-2'
showsHorizontalScrollIndicator={false}
>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'bg-primary rounded-md px-3 py-2'
: 'bg-muted rounded-md px-3 py-2'
}
onPress={() => onChange(option.value)}
>
<Text
className={
active
? 'text-primary-foreground text-xs font-semibold'
: 'text-muted-foreground text-xs font-semibold'
}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
);
@@ -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;
}) => (
<Button
disabled={disabled}
variant={destructive ? 'danger' : 'outline'}
onPress={() =>
Alert.alert(title, message, [
{ style: 'cancel', text: 'Cancel' },
{
onPress: onConfirm,
style: destructive ? 'destructive' : 'default',
text: confirmLabel,
},
])
}
>
{children}
</Button>
);
+27
View File
@@ -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 (
<Pressable className='border-border border-b py-3' onPress={copy}>
<Text className='text-muted-foreground text-xs'>{label}</Text>
<View className='mt-1 flex-row items-center justify-between gap-3'>
<Text className='text-foreground min-w-0 flex-1 text-sm'>{value}</Text>
<Text className='text-primary text-sm font-semibold'>Copy</Text>
</View>
</Pressable>
);
};
@@ -0,0 +1,18 @@
import { Text } from 'react-native';
import { Card } from './card';
export const EmptyState = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Card>
<Text className='text-foreground font-semibold'>{title}</Text>
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
{description}
</Text>
</Card>
);
@@ -0,0 +1,10 @@
import { Text } from 'react-native';
import { Card } from './card';
export const ErrorState = ({ message }: { message: string }) => (
<Card>
<Text className='font-semibold text-red-600'>Something went wrong</Text>
<Text className='text-muted-foreground mt-2 text-sm'>{message}</Text>
</Card>
);
+34
View File
@@ -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';
}) => (
<View className='gap-2'>
<Text className='text-foreground text-sm font-medium'>{label}</Text>
<TextInput
className='border-input text-foreground rounded-md border px-3 py-3'
keyboardType={keyboardType}
multiline={multiline}
placeholder={placeholder}
placeholderTextColor='#64748b'
secureTextEntry={secureTextEntry}
textAlignVertical={multiline ? 'top' : 'center'}
value={value}
onChangeText={onChangeText}
/>
</View>
);
@@ -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;
}) => (
<Card className='gap-4'>
<Text className='text-foreground font-semibold'>{title}</Text>
{description ? (
<Text className='text-muted-foreground -mt-2 text-sm leading-5'>
{description}
</Text>
) : null}
{children}
</Card>
);
+36
View File
@@ -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<ComponentProps<typeof Pressable>, 'children'>) => (
<Pressable
className='border-border bg-card rounded-lg border p-4'
onPress={onPress}
{...props}
>
<View className='flex-row items-start justify-between gap-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-semibold'>{title}</Text>
{subtitle ? (
<Text className='text-muted-foreground mt-1 text-sm'>{subtitle}</Text>
) : null}
</View>
{meta ? (
<Text className='text-muted-foreground text-xs'>{meta}</Text>
) : null}
</View>
{children ? <View className='mt-3'>{children}</View> : null}
</Pressable>
);
@@ -0,0 +1,7 @@
import { Text, View } from 'react-native';
export const LoadingState = ({ label = 'Loading...' }: { label?: string }) => (
<View className='flex-1 items-center justify-center p-6'>
<Text className='text-muted-foreground'>{label}</Text>
</View>
);
@@ -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;
}) => (
<Card className='flex-1'>
<Text className='text-muted-foreground text-xs'>{label}</Text>
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
{note ? (
<Text className='text-muted-foreground mt-1 text-xs'>{note}</Text>
) : null}
</Card>
);
+61
View File
@@ -0,0 +1,61 @@
import { Pressable, ScrollView, Text } from 'react-native';
export type PillTab<T extends string> = {
badge?: number | string;
label: string;
value: T;
};
export const PillTabs = <T extends string>({
onChange,
tabs,
value,
}: {
onChange: (value: T) => void;
tabs: PillTab<T>[];
value: T;
}) => (
<ScrollView
horizontal
className='-mx-1'
contentContainerClassName='gap-2 px-1'
keyboardShouldPersistTaps='handled'
showsHorizontalScrollIndicator={false}
>
{tabs.map((tab) => {
const active = tab.value === value;
return (
<Pressable
key={tab.value}
className={
active
? 'bg-primary min-h-9 flex-row items-center gap-2 rounded-md px-3'
: 'bg-muted min-h-9 flex-row items-center gap-2 rounded-md px-3'
}
onPress={() => onChange(tab.value)}
>
<Text
className={
active
? 'text-primary-foreground text-xs font-semibold'
: 'text-muted-foreground text-xs font-semibold'
}
>
{tab.label}
</Text>
{tab.badge === undefined ? null : (
<Text
className={
active
? 'text-primary-foreground text-xs'
: 'text-muted-foreground text-xs'
}
>
{tab.badge}
</Text>
)}
</Pressable>
);
})}
</ScrollView>
);
@@ -0,0 +1,46 @@
import { Pressable, Text, View } from 'react-native';
export type RadioOption<T extends string> = {
description?: string;
label: string;
value: T;
};
export const RadioList = <T extends string>({
label,
onChange,
options,
value,
}: {
label: string;
onChange: (value: T) => void;
options: RadioOption<T>[];
value: T;
}) => (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<View className='gap-2'>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'border-primary bg-primary/10 rounded-md border p-3'
: 'border-border rounded-md border p-3'
}
onPress={() => onChange(option.value)}
>
<Text className='text-foreground font-medium'>{option.label}</Text>
{option.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{option.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
);
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Modal, Pressable, Text, View } from 'react-native';
import { Button } from './button';
export type SheetSelectOption<T extends string> = {
description?: string;
label: string;
value: T;
};
export const SheetSelect = <T extends string>({
disabled = false,
label,
onChange,
options,
value,
}: {
disabled?: boolean;
label: string;
onChange: (value: T) => void;
options: SheetSelectOption<T>[];
value: T;
}) => {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
const choose = (nextValue: T) => {
onChange(nextValue);
setOpen(false);
};
return (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<Pressable
className={
disabled
? 'border-border bg-muted/50 rounded-md border px-3 py-3 opacity-60'
: 'border-border bg-background rounded-md border px-3 py-3'
}
disabled={disabled}
onPress={() => setOpen(true)}
>
<Text className='text-foreground font-medium'>
{selected?.label ?? 'Select'}
</Text>
{selected?.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{selected.description}
</Text>
) : null}
</Pressable>
<Modal
animationType='slide'
onRequestClose={() => setOpen(false)}
transparent
visible={open}
>
<View className='flex-1 justify-end bg-black/40'>
<View className='bg-background border-border max-h-[80%] gap-3 rounded-t-lg border-t p-4'>
<View className='flex-row items-center justify-between'>
<Text className='text-foreground text-lg font-semibold'>
{label}
</Text>
<Button variant='ghost' onPress={() => setOpen(false)}>
Cancel
</Button>
</View>
<View className='gap-2'>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
className={
active
? 'border-primary bg-primary/10 rounded-md border p-3'
: 'border-border rounded-md border p-3'
}
onPress={() => choose(option.value)}
>
<Text className='text-foreground font-medium'>
{option.label}
</Text>
{option.description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{option.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
</View>
</Modal>
</View>
);
};
@@ -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;
}) => (
<View className='border-border flex-row items-center justify-between gap-4 border-b py-3'>
<View className='min-w-0 flex-1'>
<Text className='text-foreground font-medium'>{label}</Text>
{description ? (
<Text className='text-muted-foreground mt-1 text-xs'>
{description}
</Text>
) : null}
</View>
<Switch value={value} onValueChange={onValueChange} />
</View>
);
+18
View File
@@ -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 }) => (
<View className='gap-2'>
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
<TextInput
className='border-border bg-background text-foreground min-h-28 rounded-md border px-3 py-3 align-top'
multiline
placeholderTextColor='#71717a'
textAlignVertical='top'
{...props}
/>
</View>
);
@@ -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 (
<View className='gap-3 rounded-lg bg-zinc-950 p-3'>
<View>
{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 (
<Text
key={`${index}-${line.slice(0, 12)}`}
className={`font-mono text-xs leading-5 ${color}`}
>
{line || ' '}
</Text>
);
})}
</View>
{hiddenCount > 0 ? (
<Button variant='outline' onPress={() => setExpanded(true)}>
Show {hiddenCount} more lines
</Button>
) : null}
</View>
);
};
@@ -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 (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>
{mode === 'diffs' ? 'Diffs' : 'Artifacts'}
</Text>
{visible.length ? (
visible.map((artifact) => (
<View key={artifact._id} className='gap-2'>
<Text className='text-muted-foreground text-sm'>
{artifact.title}
</Text>
{mode === 'diffs' ? (
<DiffPreview content={artifact.content} />
) : (
<>
<Text className='bg-muted text-foreground rounded-md p-3 font-mono text-xs leading-5'>
{artifact.content.slice(0, 2_000)}
</Text>
<Button
variant='outline'
onPress={() =>
void Clipboard.setStringAsync(artifact.content)
}
>
Copy artifact
</Button>
</>
)}
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>
{mode === 'diffs'
? 'Diff artifacts will appear here when the worker records them.'
: 'No non-diff artifacts recorded.'}
</Text>
)}
</Card>
);
};
@@ -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 (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Events</Text>
<ChipRow
options={[
{ label: 'All', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
]}
value={level}
onChange={setLevel}
/>
{filtered.length ? (
filtered.map((event) => (
<View key={event._id} className='border-border border-b pb-2'>
<Text className='text-muted-foreground text-xs'>
{formatDateTime(event.createdAt)} · {titleize(event.phase)} ·{' '}
{titleize(event.level)}
</Text>
<Text className='text-foreground mt-1'>{event.message}</Text>
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>No events.</Text>
)}
</Card>
);
};
@@ -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 }[];
}) => (
<Card className='gap-3'>
<Text className='text-foreground font-semibold'>Messages</Text>
{messages.length ? (
messages.map((message) => (
<View key={message._id} className='border-border border-b pb-2'>
<Text className='text-muted-foreground text-xs'>
{titleize(message.role)} · {titleize(message.status)}
</Text>
<Text className='text-foreground mt-1'>{message.content}</Text>
</View>
))
) : (
<Text className='text-muted-foreground text-sm'>No messages yet.</Text>
)}
</Card>
);
@@ -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;
}) => (
<Card className='gap-3'>
<View className='flex-row flex-wrap gap-2'>
<Badge label={titleize(job.status)} tone='primary' />
<Badge label={titleize(job.workspaceStatus ?? 'not_started')} />
</View>
<Text className='text-muted-foreground text-sm'>
Branch: {job.workBranch}
</Text>
<Text className='text-muted-foreground text-sm'>Model: {job.model}</Text>
<Text className='text-muted-foreground text-sm'>
Reasoning: {titleize(job.reasoningEffort)}
</Text>
<Text className='text-muted-foreground text-sm'>
Started: {formatDateTime(job.startedAt)}
</Text>
{job.completedAt ? (
<Text className='text-muted-foreground text-sm'>
Completed: {formatDateTime(job.completedAt)}
</Text>
) : null}
<CopyRow label='Draft PR' value={job.pullRequestUrl} />
{job.pullRequestUrl ? (
<Button onPress={() => void Linking.openURL(job.pullRequestUrl ?? '')}>
Open draft PR
</Button>
) : null}
<ConfirmButton
confirmLabel='Cancel job'
destructive
disabled={
cancelling ||
['cancelled', 'draft_pr_opened', 'failed'].includes(job.status)
}
message='Cancel this workspace job? Running work will be stopped where possible.'
title='Cancel job'
onConfirm={onCancel}
>
{cancelling ? 'Cancelling...' : 'Cancel job'}
</ConfirmButton>
</Card>
);
+26
View File
@@ -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;
};
+18
View File
@@ -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;
+187
View File
@@ -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(
<SpoonSecretsPanel
adding={false}
importing={false}
removingId={undefined}
secrets={[]}
onAddSecret={vi.fn()}
onImportSecrets={onImportSecrets}
onRemoveSecret={vi.fn()}
/>,
);
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(
<SpoonSecretsPanel
adding={false}
importing={false}
removingId={undefined}
secrets={[]}
onAddSecret={vi.fn()}
onImportSecrets={vi.fn()}
onRemoveSecret={vi.fn()}
/>,
);
expect(screen.getByText('Import secrets').closest('button')).toBeDisabled();
});
test('AiProviderProfileForm selects default model from model options', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<AiProviderProfileForm
saving={false}
onSubmit={onSubmit}
existing={{
_id: 'profile' as never,
authType: 'api_key',
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex', 'gpt-5.5'],
name: 'OpenAI',
provider: 'openai',
reasoningEffort: 'medium',
}}
/>,
);
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(
<AiProviderProfileForm
saving={false}
onSubmit={vi.fn()}
existing={{
_id: 'profile' as never,
authType: 'opencode_auth_json',
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex'],
name: 'Codex',
provider: 'opencode_openai_login',
reasoningEffort: 'medium',
}}
/>,
);
expect(screen.getByText(/~\/.codex\/auth.json/)).toBeTruthy();
});
test('SpoonAgentSettingsForm disables provider/model controls without provider profiles', () => {
render(
<SpoonAgentSettingsForm
profiles={[]}
onUpdate={vi.fn()}
agent={{
agentModel: '',
autoDetectCommands: true,
branchPrefix: 'spoon/agent',
enabled: true,
materializeEnvFileByDefault: false,
reasoningEffort: 'medium',
}}
/>,
);
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(
<SpoonAgentSettingsForm
agent={{
agentModel: 'gpt-5.1-codex',
autoDetectCommands: true,
branchPrefix: 'spoon/agent',
enabled: true,
materializeEnvFileByDefault: false,
reasoningEffort: 'high',
}}
profiles={[
{
_id: 'profile-a' as never,
defaultModel: 'gpt-5.1-codex',
enabled: true,
modelOptions: ['gpt-5.1-codex'],
name: 'OpenAI',
reasoningEffort: 'medium',
},
{
_id: 'profile-b' as never,
defaultModel: 'claude-sonnet-4-5',
enabled: true,
modelOptions: ['claude-sonnet-4-5'],
name: 'Anthropic',
reasoningEffort: 'low',
},
]}
onUpdate={onUpdate}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
fireEvent.click(screen.getByText('Anthropic'));
await waitFor(() =>
expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({
agentModel: 'claude-sonnet-4-5',
reasoningEffort: 'low',
}),
),
);
});
});
@@ -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(<DashboardRoute />);
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(<SpoonsRoute />);
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(<ThreadsRoute />);
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(<WorkspaceRoute />);
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(<SettingsRoute />);
expect(screen.getByText('gib@example.com')).toBeTruthy();
expect(screen.getByText('GitHub connected as gibbyb')).toBeTruthy();
expect(screen.getByText('1 provider, default Codex')).toBeTruthy();
});
});
@@ -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(
<PillTabs
tabs={[
{ label: 'Overview', value: 'overview' },
{ label: 'Settings', value: 'settings' },
]}
value='overview'
onChange={onChange}
/>,
);
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(
<SheetSelect
label='Provider'
options={[
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
]}
value='openai'
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
fireEvent.click(screen.getByText('Anthropic'));
expect(onChange).toHaveBeenCalledWith('anthropic');
});
test('SheetSelect respects disabled state', () => {
const onChange = vi.fn();
render(
<SheetSelect
disabled
label='Provider'
options={[{ label: 'OpenAI', value: 'openai' }]}
value='openai'
onChange={onChange}
/>,
);
fireEvent.click(screen.getByText('OpenAI'));
expect(onChange).not.toHaveBeenCalled();
});
test('ConfirmButton delegates confirmation to Alert', () => {
const onConfirm = vi.fn();
render(
<ConfirmButton
confirmLabel='Delete'
message='Delete this?'
title='Delete'
onConfirm={onConfirm}
>
Remove
</ConfirmButton>,
);
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(<DiffPreview content={diff} initialLines={3} />);
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(
<>
<SpoonStatusBadge status='up_to_date' />
<ThreadStatusBadge status='waiting_for_user' />
</>,
);
expect(screen.getByText('up to date')).toBeTruthy();
expect(screen.getByText('waiting for user')).toBeTruthy();
});
});
+138
View File
@@ -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<string, unknown> = {
...props,
className:
typeof props.className === 'string' ? props.className : undefined,
disabled: props.disabled as boolean | undefined,
onChange: onChangeText
? (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
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<string, unknown>) => 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();
});
+38
View File
@@ -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' },
]);
});
});
+31
View File
@@ -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');
});
});
+1
View File
@@ -10,6 +10,7 @@
}, },
"include": [ "include": [
"src", "src",
"tests",
"*.ts", "*.ts",
"*.js", "*.js",
".expo/types/**/*.ts", ".expo/types/**/*.ts",
+35
View File
@@ -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],
},
},
],
},
});
+17 -9
View File
@@ -51,6 +51,7 @@
"convex": "catalog:convex", "convex": "catalog:convex",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8", "expo-apple-authentication": "~8.0.8",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
@@ -81,11 +82,14 @@
"@spoon/prettier-config": "workspace:*", "@spoon/prettier-config": "workspace:*",
"@spoon/tailwind-config": "workspace:*", "@spoon/tailwind-config": "workspace:*",
"@spoon/tsconfig": "workspace:*", "@spoon/tsconfig": "workspace:*",
"@spoon/vitest-config": "workspace:*",
"@testing-library/react": "catalog:test",
"@types/react": "catalog:react19", "@types/react": "catalog:react19",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:test",
}, },
}, },
"apps/next": { "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/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=="], "@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-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-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=="], "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=="], "@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-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=="], "@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=="], "@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/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/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/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__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=="], "@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=="], "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-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=="], "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-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-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=="], "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-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/@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=="], "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-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/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/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-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-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=="], "@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=="],