diff --git a/README.md b/README.md index 581e9e1..1ba36d7 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,420 @@ -# Spoon +

+ Spoon logo +

-Spoon is a self-hostable fork maintenance cockpit. +

Spoon

-Forking a project should not mean supporting it alone. Spoon tracks managed -forks, called **Spoons**, watches upstream for drift, automatically syncs clean -forks when it can, and opens durable **Threads** when upstream changes need -review, context, or code. +

+ Fork freely & keep them all intimately close to upstream. +

-This repository is the Spoon application itself, not a generic starter. +

+ Spoon is a self-hostable fork maintenance cockpit built around managed forks, + durable maintenance threads, and OpenCode-powered workspaces. +

-## What Spoon Does +

+ What this is + · + Product model + · + Architecture + · + Environment +

-- Tracks GitHub-backed managed forks and their upstream projects. -- Shows raw and effective drift, fork-only commits, pull requests, clone URLs, - additional remotes, sync history, and open maintenance work. -- Uses Threads as the product center for upstream reviews, merge conflicts, - ignored commits, user-requested changes, worker logs, and draft PR handoff. -- Auto-syncs clean behind forks when there are no fork-only commits. -- Creates maintenance threads when custom fork work means upstream changes need - a decision. -- Runs optional OpenCode-backed workspaces in isolated agent-job containers. -- Lets users configure encrypted AI provider profiles, Codex/OpenCode auth, - per-Spoon secrets, commands, and agent settings. -- Opens draft PRs for code changes instead of auto-merging custom forks. +--- -## Current Scope +## What This Is -Implemented today: +Spoon is a private, actively evolving project for making forks less lonely to +maintain. -- Public Next.js landing page for Spoon's thread-first maintenance model. -- Authenticated web routes: - - `/dashboard` - - `/spoons` - - `/spoons/new` - - `/spoons/[spoonId]` - - `/spoons/[spoonId]/agent/[jobId]` - - `/threads` - - `/threads/[threadId]` - - `/settings/profile` - - `/settings/integrations` - - `/settings/ai-providers` -- Legacy `/updates` and `/agents` routes redirect into Threads. -- GitHub App connection, repository listing, fork creation, drift refresh, - commit/PR cache, and safe sync foundation. -- Thread-first maintenance model with ignored upstream changes and effective - drift. -- Optional `apps/agent-worker` service that claims queued jobs, clones the - current GitHub fork, starts an isolated workspace, exposes file browsing and - edits through server-side Next proxies, runs commands, and opens draft PRs. -- Browser workspace with persisted thread messages, file tree, Monaco editor - with optional Vim mode, diff view, command panel, logs, artifacts, and draft - PR actions. -- Encrypted per-user AI provider profiles and per-Spoon project secrets. -- Password auth and Authentik/GitHub OAuth through Convex Auth. -- Expo companion app shell with password and Authentik sign-in. -- Self-hosted local Convex using Postgres storage. +Forking a project is easy. Keeping that fork close to upstream after you add +custom changes is the hard part. Spoon treats a fork as an ongoing relationship: +it watches upstream, understands fork-only commits, automatically syncs clean +drift when it can, and opens a durable **Thread** when a decision needs context +or code. -Not implemented yet: +The application is currently GitHub-first. Future provider-neutral fields exist +in the data model, but GitHub is the active automation surface today. -- Automatic merge of custom/diverged forks. -- Git provider automation beyond GitHub. -- Additional remotes as push targets. -- Long-running service-stack orchestration inside agent jobs. -- Direct browser access to worker containers. -- Production mobile build/release setup. +## Highlights + +- **Managed forks, called Spoons** + Track upstream metadata, fork metadata, clone URLs, extra remotes, sync + cadence, production-ref strategy, fork-only commits, and pull requests. + +- **Thread-first maintenance** + Upstream updates, conflict review, ignore decisions, user-requested work, + worker output, and draft PR handoff all live inside Threads. + +- **Clean drift auto-sync** + If upstream moves and the fork has no custom commits, Spoon can fast-forward + the fork without creating busywork. + +- **Custom forks get context** + If the fork has custom commits, Spoon creates a maintenance thread rather than + pretending the update is trivial. + +- **Effective drift** + Spoon keeps raw GitHub drift visible while also tracking ignored upstream + changes so irrelevant commits do not keep a fork permanently actionable. + +- **OpenCode workspaces** + Agent work happens in an isolated workspace with a file tree, browser editor, + diff viewer, command panel, logs, artifacts, and draft PR actions. + +- **User-owned providers and secrets** + AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are + encrypted. Secrets are redacted from logs and refused from commits when + materialized into env files. + +- **Draft PR handoff** + Code changes become branches and draft pull requests. Spoon does not + auto-merge custom forks behind the user's back. + +## Product Model + +
+Spoons + +A **Spoon** is a managed fork. It records the upstream project, the fork +repository, default branches, sync policy, extra remotes, current drift, cached +commits, cached pull requests, secrets, and agent settings. + +Spoons are the durable project-level objects. They answer: + +- What did I fork? +- Where does my fork live? +- How far has it drifted? +- Which commits are mine? +- Which upstream changes matter? +- What threads or PRs are open? + +
+ +
+Threads + +A **Thread** is the durable place where Spoon talks about maintenance work. +Threads can be created by a user or by the system. + +Common thread sources: + +- `user_request`: user asks Spoon to change a fork. +- `upstream_update`: upstream moved and the fork needs review. +- `merge_conflict`: a sync conflict needs context or code. +- `manual_review`: user explicitly asks for a review. +- `system`: internal maintenance coordination. + +Threads hold messages, status, outcomes, related sync runs, related jobs, +workspace links, draft PR links, and ignored upstream decisions. + +
+ +
+Maintenance decisions + +Spoon's maintenance policy is intentionally conservative: + +| Situation | Default action | +| ------------------------------------------ | ------------------------------------- | +| No fork-only commits and upstream is ahead | Auto-sync | +| Fork-only commits and upstream is ahead | Create a maintenance thread | +| Merge conflicts | Open or continue a workspace thread | +| Irrelevant upstream changes | Record an intentional ignore decision | +| Agent/code changes | Open a draft PR | + +The goal is to keep forks close without hiding risk or skipping review when +custom work exists. + +
+ +
+OpenCode workspaces + +Spoon's optional agent worker is designed to run outside Convex actions. The +worker claims queued jobs, clones the current GitHub fork, creates a branch, +starts an isolated workspace, and exposes workspace operations to the Next app +through server-only API proxies. + +Workspace capabilities: + +- browse repository files +- edit files in a browser editor +- use optional Vim keybindings +- inspect diffs +- send thread messages to the agent +- run configured commands +- store logs and artifacts +- push a branch +- open a draft PR + +The browser never receives worker tokens and never talks directly to the worker +or job container. + +
## Architecture -- `apps/next`: Next.js 16 web app and primary product UI. -- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and - draft PR jobs. -- `apps/expo`: Expo companion app. -- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and - HTTP routes. -- `packages/ui`: shared shadcn-based UI components. -- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. -- `docker`: local and production Compose files. -- `scripts`: environment, database, codegen, and CI helpers. - -Core domain objects: - -- `spoons`: managed fork records. -- `threads`: durable maintenance and work conversations. -- `threadMessages`: persisted thread messages. -- `syncRuns`: upstream checks, sync attempts, and maintenance decisions. -- `ignoredUpstreamChanges`: intentional ignore decisions that affect effective - drift. -- `gitConnections`: Git provider connection metadata. -- `agentJobs`: worker-executed workspace jobs and PR lifecycle. -- `agentJobEvents` and `agentJobArtifacts`: logs and structured job outputs. -- `agentWorkspaceChanges`: recorded file changes from user, agent, or command - activity. -- `spoonSecrets`: encrypted per-Spoon environment variables. -- `spoonAgentSettings`: per-Spoon runtime, branch, command, and env-file - settings. -- `aiProviderProfiles`: encrypted provider/auth profiles used by OpenCode. - -## Local Setup - -Requirements: - -- Bun 1.3.10 -- Node 22 -- Docker or Podman -- Infisical CLI - -```sh -bun install --frozen-lockfile -infisical login -infisical init -bun db:up -bun dev:next -``` - -Local services: - -- Next.js: `http://localhost:3000` -- Convex API: `http://localhost:3210` -- Convex site HTTP routes: `http://localhost:3211` -- Convex dashboard: `http://localhost:6791` -- Convex Postgres: `localhost:5432` - -Next and Expo run on the host. Local Convex runs in containers with Postgres -storage. Normal `bun db:up` never contacts staging; it starts local Postgres, -Convex, and the dashboard, generates a machine-local Convex admin key in -`.local/dev.generated.env` when needed, deploys functions/schema, and configures -local Convex Auth keys. - -```sh -bun db:down # stop; preserve local data -bun db:down:wipe # remove local data volumes and generated admin key -``` - -Use staging services explicitly: - -```sh -INFISICAL_ENV=staging bun dev:next -``` - -Run the optional local agent worker in a separate terminal: - -```sh -bun dev:agent -``` - -The worker starts an internal HTTP API, defaulting to `http://localhost:3921`, -for server-side Next route handlers. The browser never receives the worker token -or talks to this API directly. - -The Docker Compose local worker service is disabled by default behind the -`agent` profile. Build the job image before using Docker-backed jobs: - -```sh -docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest . -docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker -``` - -The job image includes the OpenCode CLI. Rebuild it after changes to -`docker/agent-job.Dockerfile`. - -## Environment Model - -Local `dev` and `staging` values come from Infisical through -`scripts/with-env`. App commands do not fall back to root `.env` files. - -Generated local state belongs in: +
+Workspace layout ```txt -.local/.generated.env +. +├── apps +│ ├── next # Next.js 16 web app and primary Spoon UI +│ ├── agent-worker # Optional OpenCode workspace / draft PR worker +│ └── expo # Expo companion app scaffold +├── packages +│ ├── backend # Convex backend package +│ │ └── convex # Schema, functions, auth, HTTP routes +│ └── ui # Shared shadcn-based UI components +├── tools # Shared lint, format, Tailwind, TS, Vitest config +├── docker # Compose files and worker/job Dockerfiles +└── scripts # Env, Convex, codegen, database, and CI helpers ``` -CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical. +
-Useful helpers: +
+Core tables + +| Table | Purpose | +| ------------------------ | --------------------------------------------------------- | +| `spoons` | Managed fork records | +| `threads` | Durable maintenance and work conversations | +| `threadMessages` | Messages inside threads | +| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions | +| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift | +| `gitConnections` | Git provider connection metadata | +| `spoonRepositoryStates` | Latest cached upstream/fork state | +| `spoonCommits` | Cached upstream and fork-only commits | +| `spoonPullRequests` | Cached fork/upstream pull requests | +| `spoonSecrets` | Encrypted per-Spoon environment variables | +| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings | +| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode | +| `agentJobs` | Worker-executed workspace jobs and PR lifecycle | +| `agentJobEvents` | Append-only worker event log | +| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts | +| `agentWorkspaceChanges` | Recorded user, agent, and command file changes | + +
+ +
+Important routes + +| Route | Purpose | +| --------------------------------- | --------------------------------------- | +| `/` | Public product landing page | +| `/dashboard` | Maintenance overview | +| `/spoons` | Managed fork list | +| `/spoons/new` | Manual/GitHub Spoon creation | +| `/spoons/[spoonId]` | Spoon detail dashboard | +| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace | +| `/threads` | Global thread queue | +| `/threads/[threadId]` | Thread detail | +| `/settings/profile` | User profile settings | +| `/settings/integrations` | GitHub and service integration settings | +| `/settings/ai-providers` | AI/OpenCode provider profiles | + +Legacy `/updates` and `/agents` routes redirect into `/threads`. + +
+ +## Mobile App + +
+Current Expo scope + +`apps/expo` is the mobile Spoon client. It is designed to mirror the core web +product without exposing worker internals or trying to turn a phone into the +primary code-editing surface. + +The mobile app currently supports: + +- password, GitHub, and Authentik sign-in +- Dashboard, Spoons, Threads, Workspace Review, and Settings tabs/screens +- manual Spoon creation and GitHub-assisted repository tracking +- Spoon detail views for overview, upstream commits, fork-only commits, PRs, + threads, settings, clone URLs, and additional remotes +- Spoon maintenance settings, agent settings, encrypted secrets, and bulk + `.env` paste import +- thread list/detail, message composer, resolve/cancel actions, and workspace + review links +- GitHub integration status and repository listing +- AI provider profile management, including Codex/OpenCode auth JSON +- read-only workspace review for job status, messages, diffs, events, + artifacts, and draft PR links + +The mobile app intentionally does not currently support: + +- live workspace file browsing/editing +- mobile command execution +- direct mobile calls to the agent worker HTTP API +- mobile access to worker/container tokens +- long-running app preview stacks +- production app-store/EAS release flow + +Mobile workspace editing is deferred until worker authorization and mobile +editor UX are designed explicitly. For now, the phone is a strong review and +control surface; the browser remains the code workspace. + +
+ +
+Expo validation + +Useful mobile checks: ```sh -sh scripts/with-env dev -- -sh scripts/export-env dev -bun sync:convex -bun sync:convex:staging +bun --filter @spoon/expo lint +bun --filter @spoon/expo typecheck +bun --filter @spoon/expo test:unit +bun --filter @spoon/expo test:component ``` -### Convex Deployment Env +The Expo unit tests cover pure utilities such as `.env` parsing and formatting. +The component tests use a lightweight React Native mock layer to exercise shared +mobile controls, higher-value forms, and route smoke renders without booting a +native simulator. -Convex functions and HTTP actions read environment variables from the Convex -deployment environment, not directly from the host process. OAuth providers, -GitHub App credentials, UseSend, encryption keys, worker tokens, and Convex Auth -signing keys must be synced into the selected Convex deployment. +
-`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so -`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical -values into local Convex first. Run it manually when needed: +## Environment Reference -```sh -sh scripts/sync-convex-env dev -sh scripts/sync-convex-env staging -INFISICAL_ENV=staging bun sync:convex -``` +This project is currently private, so this section is a reference for what the +application expects rather than public setup documentation. -For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`, -`SPOON_WORKER_TOKEN`, and related generated values are created automatically if -they are not already present. The generated Convex admin key remains -machine-local in `.local/dev.generated.env`; do not put it in Infisical. +
+Public Next variables -Local OAuth callback URLs: +| Variable | Used for | +| --------------------------------- | ------------------------------------------- | +| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL | +| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL | +| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed | +| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint | +| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN | +| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL | +| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization | +| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name | -```txt -http://localhost:3211/api/auth/callback/authentik -http://localhost:3211/api/auth/callback/github -``` +
-If GitHub App actions fail with `GITHUB_APP_PRIVATE_KEY is not configured`, add -the full PEM contents to Infisical as `GITHUB_APP_PRIVATE_KEY` and rerun the -sync command. +
+Auth and email -## Development +| Variable | Used for | +| ----------------------- | ----------------------------- | +| `SITE_URL` | Convex Auth site URL | +| `JWT_PRIVATE_KEY` | Convex Auth signing key | +| `JWKS` | Convex Auth JWKS | +| `AUTH_AUTHENTIK_ID` | Authentik OAuth client ID | +| `AUTH_AUTHENTIK_SECRET` | Authentik OAuth client secret | +| `AUTH_AUTHENTIK_ISSUER` | Authentik issuer URL | +| `AUTH_GITHUB_ID` | GitHub OAuth client ID | +| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret | +| `USESEND_API_KEY` | UseSend API key | +| `USESEND_URL` | UseSend API URL | +| `USESEND_FROM_EMAIL` | Transactional email sender | -```sh -bun dev:next -bun dev:expo -bun dev:agent -``` +
-Physical devices cannot resolve their own `localhost`; override the public -Convex URL with the development host's LAN address when testing Expo on-device. +
+GitHub App -Shared dependency versions belong in root catalogs. Edit the root catalog, run -`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace. +| Variable | Used for | +| ---------------------------- | ---------------------------------- | +| `GITHUB_APP_ID` | GitHub App ID | +| `GITHUB_APP_CLIENT_ID` | GitHub App OAuth client ID | +| `GITHUB_APP_CLIENT_SECRET` | GitHub App OAuth client secret | +| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM private key | +| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification secret | +| `GITHUB_APP_SLUG` | GitHub App slug | +| `GITHUB_APP_INSTALLATION_ID` | Default/local installation ID | +| `GITHUB_APP_OWNER` | Default/local installation owner | -## Validation +
-Routine checks: +
+Convex, storage, and runtime -```sh -bun lint:ws -bun format -bun lint -bun typecheck -bun run test -``` +| Variable | Used for | +| ----------------------------------- | ----------------------------------------------- | +| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL | +| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex | +| `CONVEX_CLOUD_ORIGIN` | Convex backend origin | +| `CONVEX_SITE_ORIGIN` | Convex site-function origin | +| `CONVEX_SITE_URL` | Site URL seen by Convex Auth | +| `POSTGRES_URL` | Convex storage database URL | +| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth | +| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations | +| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next | +| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port | +| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy | +| `SPOON_AGENT_JOB_IMAGE` | Agent job container image | +| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented | +| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit | +| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout | +| `SPOON_AGENT_WORKDIR` | Worker work directory | +| `SPOON_AGENT_NETWORK` | Optional job container network | -Full local gate without e2e: +
-```sh -SKIP_E2E=1 bun run ci:check -``` +
+Deployment and observability -Local-stack smoke e2e: +| Variable | Used for | +| ----------------------- | --------------------------------- | +| `NODE_ENV` | Runtime environment | +| `SENTRY_AUTH_TOKEN` | Sentry source map/upload auth | +| `REDACT_LOGS_TO_CLIENT` | Convex log redaction setting | +| `DISABLE_BEACON` | Self-hosted Convex beacon setting | +| `DO_NOT_REQUIRE_SSL` | Self-hosted Convex SSL behavior | +| `CI_ENV_FILE` | CI-provided env file path | -```sh -bun test:e2e -``` +
-`bun test:e2e` starts the isolated local stack when needed and stops it -afterward only when it was not already running. +## Current Status -Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in -test runner instead of the repo's Turbo/Vitest test script. +
+Implemented -## Deployment +- Thread-first Next.js product shell +- GitHub App connection and fork creation foundation +- GitHub drift refresh, commit cache, PR cache, and sync-run history +- Effective drift and ignored upstream change records +- Global Threads page and Spoon-scoped Threads tab +- OpenCode-oriented agent worker and browser workspace foundation +- Monaco editor with optional Vim mode +- Diff viewer, command panel, worker logs, and artifacts +- Encrypted Spoon secrets and bulk `.env` import +- Encrypted AI provider profiles, including Codex/OpenCode auth support +- Authentik, GitHub, and password auth through Convex Auth +- Self-hosted Convex/Postgres deployment model -Production Compose runs the Next image, self-hosted Convex backend/dashboard, -and Postgres. The deployed Next image is expected to be named -`spoon-next:latest` in the Gitea registry. +
-Gitea runs the quality gate first, runs Convex codegen with deployment env, -builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA -and `latest` tags. CI never installs or invokes Infisical. +
+Intentionally not done yet + +- Autonomous merging for custom/diverged forks +- Non-GitHub provider automation +- Pushing agent branches to additional remotes +- Long-running preview stacks for arbitrary forked projects +- Direct browser access to worker containers +- Public self-hosting setup documentation +- Production mobile release flow + +
+ +## Notes + +Spoon is built for a very specific maintenance problem: "I want to fork this +project, but I do not want to permanently become its maintenance team." + +The current product direction is to make that maintenance visible, threaded, +reviewable, and increasingly automated where it is safe. Clean forks can stay +close automatically. Custom forks get context, workspace help, and draft PRs. diff --git a/apps/expo/package.json b/apps/expo/package.json index 33d413f..49b157f 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -12,6 +12,9 @@ "ios": "expo run:ios", "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore", "lint": "eslint --flag unstable_native_nodejs_ts_config", + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration --passWithNoTests", + "test:component": "vitest run --project component", "typecheck": "tsc --noEmit", "with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --" }, @@ -27,6 +30,7 @@ "convex": "catalog:convex", "expo": "~54.0.33", "expo-apple-authentication": "~8.0.8", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-dev-client": "~6.0.20", "expo-font": "~14.0.11", @@ -57,11 +61,14 @@ "@spoon/prettier-config": "workspace:*", "@spoon/tailwind-config": "workspace:*", "@spoon/tsconfig": "workspace:*", + "@spoon/vitest-config": "workspace:*", + "@testing-library/react": "catalog:test", "@types/react": "catalog:react19", "eslint": "catalog:", "prettier": "catalog:", "tailwindcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:test" }, "prettier": "@spoon/prettier-config" } diff --git a/apps/expo/src/app/(app)/_layout.tsx b/apps/expo/src/app/(app)/_layout.tsx new file mode 100644 index 0000000..b133a41 --- /dev/null +++ b/apps/expo/src/app/(app)/_layout.tsx @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import { useColorScheme } from 'react-native'; +import { Redirect, Tabs } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useConvexAuth } from 'convex/react'; + +import { LoadingState } from '~/components/ui/loading-state'; + +const iconName = (route: string, focused: boolean) => { + if (route === 'dashboard') return focused ? 'grid' : 'grid-outline'; + if (route === 'spoons') return focused ? 'git-branch' : 'git-branch-outline'; + if (route === 'threads') + return focused ? 'chatbubbles' : 'chatbubbles-outline'; + return focused ? 'settings' : 'settings-outline'; +}; + +const AppTabs = () => { + const { isAuthenticated, isLoading } = useConvexAuth(); + const colorScheme = useColorScheme(); + + useEffect(() => { + // Keeps the auth subscription warm while tab routes mount. + }, [isAuthenticated]); + + if (isLoading) return ; + if (!isAuthenticated) return ; + + return ( + ({ + headerShown: false, + tabBarActiveTintColor: '#0f766e', + tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b', + tabBarStyle: { + backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc', + borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0', + }, + tabBarIcon: ({ color, focused, size }) => ( + + ), + })} + > + + + + + + + ); +}; + +export default AppTabs; diff --git a/apps/expo/src/app/(app)/dashboard.tsx b/apps/expo/src/app/(app)/dashboard.tsx new file mode 100644 index 0000000..819bf57 --- /dev/null +++ b/apps/expo/src/app/(app)/dashboard.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { Text, View } from 'react-native'; +import { Link, Stack, useRouter } from 'expo-router'; +import { useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { SpoonListRow } from '~/components/spoons/spoon-list-row'; +import { ThreadListRow } from '~/components/threads/thread-list-row'; +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { EmptyState } from '~/components/ui/empty-state'; +import { MetricCard } from '~/components/ui/metric-card'; +import { titleize } from '~/utils/format'; + +const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled']; + +const DashboardRoute = () => { + const router = useRouter(); + const [refreshing, setRefreshing] = useState(false); + const spoons = useQuery(api.spoons.listMine, {}) ?? []; + const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; + const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? []; + const active = spoons.filter((spoon) => spoon.status === 'active').length; + const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length; + const diverged = spoons.filter( + (spoon) => spoon.syncStatus === 'diverged', + ).length; + const openThreads = threads.filter( + (thread) => !openThreadStatuses.includes(thread.status), + ); + const upstreamWaiting = spoons.reduce( + (total, spoon) => total + (spoon.upstreamAheadBy ?? 0), + 0, + ); + + const softRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 600); + }; + + return ( + + + + + Dashboard + + Managed forks, upstream drift, and open maintenance threads. + + + + + + + + + + + + + + + + + + + + + Maintenance queue + + {openThreads.length ? ( + openThreads + .slice(0, 5) + .map((thread) => ( + router.push(`/threads/${thread._id}`)} + /> + )) + ) : ( + + )} + + + + + Recent Spoons + + {spoons.length ? ( + spoons + .slice(0, 5) + .map((spoon) => ( + router.push(`/spoons/${spoon._id}`)} + /> + )) + ) : ( + + )} + + + + + Recent activity + + + {syncRuns.length ? ( + syncRuns.map((run) => ( + + + {titleize(run.kind)} + + + {titleize(run.status)} + + + )) + ) : ( + + Upstream checks will appear here. + + )} + + + + ); +}; + +export default DashboardRoute; diff --git a/apps/expo/src/app/(app)/settings/_layout.tsx b/apps/expo/src/app/(app)/settings/_layout.tsx new file mode 100644 index 0000000..b6911b9 --- /dev/null +++ b/apps/expo/src/app/(app)/settings/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +const SettingsLayout = () => ; + +export default SettingsLayout; diff --git a/apps/expo/src/app/(app)/settings/ai-provider-form.tsx b/apps/expo/src/app/(app)/settings/ai-provider-form.tsx new file mode 100644 index 0000000..d932ce5 --- /dev/null +++ b/apps/expo/src/app/(app)/settings/ai-provider-form.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { Alert, Text } from 'react-native'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useAction, useMutation, useQuery } from 'convex/react'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { AiProviderProfileForm } from '~/components/settings/ai-provider-profile-form'; +import { AppScreen } from '~/components/ui/app-screen'; + +const AiProviderFormRoute = () => { + const router = useRouter(); + const { profileId: rawProfileId } = useLocalSearchParams<{ + profileId?: string; + }>(); + const profileId = rawProfileId as Id<'aiProviderProfiles'> | undefined; + const existing = useQuery( + api.aiProviderProfiles.get, + profileId ? { profileId } : 'skip', + ); + const save = useAction(api.aiProviderProfilesNode.save); + const updateMetadata = useMutation(api.aiProviderProfiles.updateMetadata); + const [saving, setSaving] = useState(false); + + const submit = async (values: Parameters[0]) => { + setSaving(true); + try { + if (profileId && !values.secret) { + await updateMetadata({ + baseUrl: values.baseUrl, + defaultModel: values.defaultModel, + enabled: values.enabled, + modelOptions: values.modelOptions, + name: values.name, + profileId, + reasoningEffort: values.reasoningEffort, + }); + } else { + await save({ ...values, profileId }); + } + router.replace('/settings/ai-providers'); + } catch (error) { + console.error(error); + Alert.alert('Could not save AI provider.'); + } finally { + setSaving(false); + } + }; + + if (profileId && !existing) { + return ( + + + Loading provider... + + ); + } + + return ( + + + + {profileId ? 'Edit provider' : 'New provider'} + + + + ); +}; + +export default AiProviderFormRoute; diff --git a/apps/expo/src/app/(app)/settings/ai-providers.tsx b/apps/expo/src/app/(app)/settings/ai-providers.tsx new file mode 100644 index 0000000..e0ccecb --- /dev/null +++ b/apps/expo/src/app/(app)/settings/ai-providers.tsx @@ -0,0 +1,92 @@ +import { Alert, Text, View } from 'react-native'; +import { Link, Stack, useRouter } from 'expo-router'; +import { useMutation, useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { AppScreen } from '~/components/ui/app-screen'; +import { Badge } from '~/components/ui/badge'; +import { Button } from '~/components/ui/button'; +import { EmptyState } from '~/components/ui/empty-state'; +import { ListRow } from '~/components/ui/list-row'; +import { titleize } from '~/utils/format'; + +const AiProvidersRoute = () => { + const router = useRouter(); + const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; + const setDefault = useMutation(api.aiProviderProfiles.setDefault); + const remove = useMutation(api.aiProviderProfiles.remove); + + return ( + + + + + + AI providers + + + Provider profiles for OpenCode workspaces. + + + + + + + {profiles.length ? ( + profiles.map((profile) => ( + + router.push(`/settings/ai-provider-form?profileId=${profile._id}`) + } + > + + + {profile.isDefault ? ( + + ) : null} + + + + + + + + )) + ) : ( + + )} + + ); +}; + +export default AiProvidersRoute; diff --git a/apps/expo/src/app/(app)/settings/index.tsx b/apps/expo/src/app/(app)/settings/index.tsx new file mode 100644 index 0000000..fd302fc --- /dev/null +++ b/apps/expo/src/app/(app)/settings/index.tsx @@ -0,0 +1,70 @@ +import { Alert, Text } from 'react-native'; +import { Link, Stack } from 'expo-router'; +import { useAuthActions } from '@convex-dev/auth/react'; +import { useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; +import { ListRow } from '~/components/ui/list-row'; + +const SettingsRoute = () => { + const { signOut } = useAuthActions(); + const user = useQuery(api.auth.getUser, {}); + const connection = useQuery(api.github.getConnection, {}); + const providers = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; + const defaultProvider = providers.find((provider) => provider.isDefault); + + return ( + + + Settings + + + + + + + + + + + + ); +}; + +export default SettingsRoute; diff --git a/apps/expo/src/app/(app)/settings/integrations.tsx b/apps/expo/src/app/(app)/settings/integrations.tsx new file mode 100644 index 0000000..0774429 --- /dev/null +++ b/apps/expo/src/app/(app)/settings/integrations.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { Text } from 'react-native'; +import { Stack } from 'expo-router'; +import { useAction, useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { GitHubIntegrationPanel } from '~/components/settings/github-integration-panel'; +import { AppScreen } from '~/components/ui/app-screen'; + +const IntegrationsRoute = () => { + const installUrl = useQuery(api.github.getInstallUrl, {}); + const connection = useQuery(api.github.getConnection, {}); + const status = useQuery(api.integrations.getStatus, {}); + const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation); + const repositories = useAction(api.githubNode.listInstallationRepositories); + const [syncing, setSyncing] = useState(false); + const [loadingRepos, setLoadingRepos] = useState(false); + + const sync = async () => { + setSyncing(true); + try { + await syncInstallation({}); + } finally { + setSyncing(false); + } + }; + + const listRepos = async () => { + setLoadingRepos(true); + try { + const result = await repositories({}); + return result.map((repo) => repo.fullName); + } finally { + setLoadingRepos(false); + } + }; + + return ( + void sync()} refreshing={syncing}> + + Integrations + + + ); +}; + +export default IntegrationsRoute; diff --git a/apps/expo/src/app/(app)/settings/profile.tsx b/apps/expo/src/app/(app)/settings/profile.tsx new file mode 100644 index 0000000..8ce9b60 --- /dev/null +++ b/apps/expo/src/app/(app)/settings/profile.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Alert, Text } from 'react-native'; +import { Stack } from 'expo-router'; +import { useAction, useMutation, useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { Field } from '~/components/ui/field'; +import { titleize } from '~/utils/format'; + +const ProfileRoute = () => { + const user = useQuery(api.auth.getUser, {}); + const provider = useQuery(api.auth.getUserProvider, {}); + const updateUser = useMutation(api.auth.updateUser); + const updatePassword = useAction(api.auth.updateUserPassword); + const [name, setName] = useState(user?.name ?? ''); + const [email, setEmail] = useState(user?.email ?? ''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [savingProfile, setSavingProfile] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + + const saveProfile = async () => { + setSavingProfile(true); + try { + await updateUser({ name, email }); + Alert.alert('Saved', 'Profile updated.'); + } catch (error) { + console.error(error); + Alert.alert('Could not save profile.'); + } finally { + setSavingProfile(false); + } + }; + + const savePassword = async () => { + setSavingPassword(true); + try { + await updatePassword({ currentPassword, newPassword }); + setCurrentPassword(''); + setNewPassword(''); + Alert.alert('Saved', 'Password updated.'); + } catch (error) { + console.error(error); + Alert.alert('Could not update password.'); + } finally { + setSavingPassword(false); + } + }; + + return ( + + + Profile + + + Email is currently managed by {titleize(provider ?? 'your provider')}. + + + + + + {provider === 'password' ? ( + + Password + + + + + ) : ( + + + Password changes are hidden because this account is currently using{' '} + {titleize(provider ?? 'an OAuth provider')}. + + + )} + + ); +}; + +export default ProfileRoute; diff --git a/apps/expo/src/app/(app)/spoons/[spoonId].tsx b/apps/expo/src/app/(app)/spoons/[spoonId].tsx new file mode 100644 index 0000000..c6a2bad --- /dev/null +++ b/apps/expo/src/app/(app)/spoons/[spoonId].tsx @@ -0,0 +1,296 @@ +import { useState } from 'react'; +import { Alert, Text, View } from 'react-native'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useAction, useMutation, useQuery } from 'convex/react'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import type { SpoonDetailSegment } from '~/components/spoons/segment-control'; +import { SegmentControl } from '~/components/spoons/segment-control'; +import { SpoonDetailFork } from '~/components/spoons/spoon-detail-fork'; +import { SpoonDetailOverview } from '~/components/spoons/spoon-detail-overview'; +import { SpoonDetailPrs } from '~/components/spoons/spoon-detail-prs'; +import { SpoonDetailSettings } from '~/components/spoons/spoon-detail-settings'; +import { SpoonDetailThreads } from '~/components/spoons/spoon-detail-threads'; +import { SpoonDetailUpstream } from '~/components/spoons/spoon-detail-upstream'; +import { SpoonStatusBadge } from '~/components/spoons/spoon-status-badge'; +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; + +const SpoonDetailRoute = () => { + const router = useRouter(); + const { spoonId: rawSpoonId } = useLocalSearchParams<{ spoonId: string }>(); + const spoonId = rawSpoonId as Id<'spoons'>; + const [segment, setSegment] = useState('overview'); + const [threadPrompt, setThreadPrompt] = useState(''); + const [pending, setPending] = useState(); + const [refreshing, setRefreshing] = useState(false); + + const details = useQuery(api.spoons.getDetails, { spoonId }); + const upstreamCommits = + useQuery(api.spoonCommits.listForSpoon, { + limit: 50, + side: 'upstream', + spoonId, + }) ?? []; + const forkCommits = + useQuery(api.spoonCommits.listForSpoon, { + limit: 50, + side: 'fork', + spoonId, + }) ?? []; + const pullRequests = + useQuery(api.spoonPullRequests.listForSpoon, { limit: 50, spoonId }) ?? []; + const remotes = useQuery(api.spoonRemotes.listForSpoon, { spoonId }) ?? []; + const threads = + useQuery(api.threads.listForSpoon, { limit: 25, spoonId }) ?? []; + const spoonSettings = useQuery(api.spoonSettings.getForSpoon, { spoonId }); + const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, { + spoonId, + }); + const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? []; + const providerProfiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; + + const refresh = useAction(api.githubSync.refreshSpoonGithubState); + const sync = useAction(api.githubSync.syncForkWithUpstream); + const updateSpoonSettings = useMutation(api.spoons.updateSettings); + const updateMaintenanceSettings = useMutation(api.spoonSettings.update); + const updateAgentSettings = useMutation(api.spoonAgentSettings.update); + const createThread = useMutation(api.threads.createUserThread); + const createSecret = useAction(api.spoonSecretsNode.create); + const removeSecretMutation = useMutation(api.spoonSecrets.remove); + const createRemote = useMutation(api.spoonRemotes.create); + const removeRemoteMutation = useMutation(api.spoonRemotes.remove); + + const runRefresh = async () => { + setRefreshing(true); + setPending('refresh'); + try { + await refresh({ spoonId }); + Alert.alert('Refresh started', 'Spoon is checking GitHub state.'); + } catch (error) { + console.error(error); + Alert.alert('Could not refresh this Spoon.'); + } finally { + setRefreshing(false); + setPending(undefined); + } + }; + + const runSync = async () => { + setPending('sync'); + try { + await sync({ spoonId }); + Alert.alert('Sync started', 'Spoon is syncing the fork.'); + } catch (error) { + console.error(error); + Alert.alert('Could not sync this Spoon.'); + } finally { + setPending(undefined); + } + }; + + const submitThread = async () => { + if (!threadPrompt.trim()) return; + setPending('thread'); + try { + const threadId = await createThread({ + envFilePath: + agentSettings?.envFilePath === 'custom' + ? agentSettings.customEnvFilePath + : agentSettings?.envFilePath, + materializeEnvFile: agentSettings?.materializeEnvFileByDefault, + prompt: threadPrompt, + spoonId, + }); + setThreadPrompt(''); + router.push(`/threads/${threadId}`); + } catch (error) { + console.error(error); + Alert.alert('Could not create thread.'); + } finally { + setPending(undefined); + } + }; + + if (!details) { + return ( + + + Loading Spoon... + + ); + } + + const { effectiveUpstreamAheadBy, spoon } = details; + const canSync = + spoon.provider === 'github' && + (spoon.syncStatus === 'behind' || spoon.syncStatus === 'up_to_date') && + (spoon.forkAheadBy ?? 0) === 0; + + const settingsActions = { + addRemote: async (label: string, url: string) => { + setPending('addRemote'); + try { + await createRemote({ label, spoonId, url }); + } finally { + setPending(undefined); + } + }, + addSecret: async (name: string, value: string) => { + setPending('addSecret'); + try { + await createSecret({ name, spoonId, value }); + } finally { + setPending(undefined); + } + }, + importSecrets: async (items: { name: string; value: string }[]) => { + setPending('importSecrets'); + let failed = 0; + try { + for (const item of items) { + try { + await createSecret({ name: item.name, spoonId, value: item.value }); + } catch (error) { + failed += 1; + console.error(error); + } + } + if (failed > 0) { + throw new Error( + `${items.length - failed} imported, ${failed} failed.`, + ); + } + } finally { + setPending(undefined); + } + }, + removeRemote: async (remoteId: string) => { + setPending(`remote:${remoteId}`); + try { + await removeRemoteMutation({ + remoteId: remoteId as Id<'spoonRemotes'>, + }); + } finally { + setPending(undefined); + } + }, + removeSecret: async (secretId: string) => { + setPending(`secret:${secretId}`); + try { + await removeSecretMutation({ + secretId: secretId as Id<'spoonSecrets'>, + }); + } finally { + setPending(undefined); + } + }, + updateAgent: async (patch: Record) => { + setPending('settings'); + try { + await updateAgentSettings({ spoonId, ...patch }); + } finally { + setPending(undefined); + } + }, + updateMaintenance: async (patch: Record) => { + setPending('settings'); + try { + await updateMaintenanceSettings({ spoonId, ...patch }); + } finally { + setPending(undefined); + } + }, + updateSpoon: async (patch: Record) => { + setPending('settings'); + try { + await updateSpoonSettings({ spoonId, ...patch }); + } finally { + setPending(undefined); + } + }, + }; + + return ( + void runRefresh()} refreshing={refreshing}> + + + {spoon.name} + + + + + + + + + + + + + + {segment === 'overview' ? ( + + ) : null} + {segment === 'upstream' ? ( + + ) : null} + {segment === 'fork' ? : null} + {segment === 'prs' ? ( + + ) : null} + {segment === 'threads' ? ( + void submitThread()} + onOpenThread={(threadId) => router.push(`/threads/${threadId}`)} + /> + ) : null} + {segment === 'settings' ? ( + + ) : null} + + ); +}; + +export default SpoonDetailRoute; diff --git a/apps/expo/src/app/(app)/spoons/_layout.tsx b/apps/expo/src/app/(app)/spoons/_layout.tsx new file mode 100644 index 0000000..f911cc4 --- /dev/null +++ b/apps/expo/src/app/(app)/spoons/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +const SpoonsLayout = () => ; + +export default SpoonsLayout; diff --git a/apps/expo/src/app/(app)/spoons/index.tsx b/apps/expo/src/app/(app)/spoons/index.tsx new file mode 100644 index 0000000..b53edde --- /dev/null +++ b/apps/expo/src/app/(app)/spoons/index.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { Text, View } from 'react-native'; +import { Link, Stack, useRouter } from 'expo-router'; +import { useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { SpoonListRow } from '~/components/spoons/spoon-list-row'; +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; +import { EmptyState } from '~/components/ui/empty-state'; +import { MetricCard } from '~/components/ui/metric-card'; + +const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled']; + +const SpoonsRoute = () => { + const router = useRouter(); + const [refreshing, setRefreshing] = useState(false); + const spoons = useQuery(api.spoons.listMine, {}) ?? []; + const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? []; + const active = spoons.filter((spoon) => spoon.status === 'active').length; + const upstreamWaiting = spoons.reduce( + (total, spoon) => total + (spoon.upstreamAheadBy ?? 0), + 0, + ); + + const openThreadsFor = (spoonId: string) => + threads.filter( + (thread) => + thread.spoonId === spoonId && + !openThreadStatuses.includes(thread.status), + ).length; + + const softRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 600); + }; + + return ( + + + + + Spoons + + Managed forks and their relationship with upstream. + + + + + + + + + + + + + + + {spoons.length ? ( + spoons.map((spoon) => ( + router.push(`/spoons/${spoon._id}`)} + /> + )) + ) : ( + + )} + + + ); +}; + +export default SpoonsRoute; diff --git a/apps/expo/src/app/(app)/spoons/new.tsx b/apps/expo/src/app/(app)/spoons/new.tsx new file mode 100644 index 0000000..10ec40e --- /dev/null +++ b/apps/expo/src/app/(app)/spoons/new.tsx @@ -0,0 +1,396 @@ +import { useState } from 'react'; +import { Alert, Linking, Text, View } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import { useAction, useMutation, useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { AppScreen } from '~/components/ui/app-screen'; +import { Badge } from '~/components/ui/badge'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { Field } from '~/components/ui/field'; +import { FormSection } from '~/components/ui/form-section'; +import { PillTabs } from '~/components/ui/pill-tabs'; +import { SheetSelect } from '~/components/ui/sheet-select'; + +type CreateMode = 'manual' | 'github'; +type Provider = 'github' | 'gitea' | 'gitlab' | 'other'; +type Visibility = 'public' | 'private' | 'internal' | 'unknown'; +type MaintenanceMode = 'watch' | 'auto_pr' | 'paused'; +type SyncCadence = 'daily' | 'weekly' | 'manual'; +type ProductionRefStrategy = + | 'default_branch' + | 'latest_release' + | 'tag_pattern'; + +type Repository = Awaited< + ReturnType< + ReturnType< + typeof useAction + > + > +>[number]; + +const NewSpoonRoute = () => { + const router = useRouter(); + const createManual = useMutation(api.spoons.createManual); + const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation); + const listRepositories = useAction( + api.githubNode.listInstallationRepositories, + ); + const installUrl = useQuery(api.github.getInstallUrl, {}); + const connection = useQuery(api.github.getConnection, {}); + const [mode, setMode] = useState('manual'); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [provider, setProvider] = useState('github'); + const [upstreamOwner, setUpstreamOwner] = useState(''); + const [upstreamRepo, setUpstreamRepo] = useState(''); + const [upstreamDefaultBranch, setUpstreamDefaultBranch] = useState('main'); + const [upstreamUrl, setUpstreamUrl] = useState(''); + const [forkOwner, setForkOwner] = useState(''); + const [forkRepo, setForkRepo] = useState(''); + const [forkDefaultBranch, setForkDefaultBranch] = useState('main'); + const [forkUrl, setForkUrl] = useState(''); + const [visibility, setVisibility] = useState('unknown'); + const [maintenanceMode, setMaintenanceMode] = + useState('watch'); + const [syncCadence, setSyncCadence] = useState('daily'); + const [productionRefStrategy, setProductionRefStrategy] = + useState('default_branch'); + const [tagPattern, setTagPattern] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [loadingRepos, setLoadingRepos] = useState(false); + const [repositories, setRepositories] = useState([]); + + const submitManual = async () => { + if (!name || !upstreamOwner || !upstreamRepo || !upstreamUrl) { + Alert.alert('Missing fields', 'Name and upstream metadata are required.'); + return; + } + setSubmitting(true); + try { + const spoonId = await createManual({ + description: description || undefined, + forkDefaultBranch: forkDefaultBranch || undefined, + forkOwner: forkOwner || undefined, + forkRepo: forkRepo || undefined, + forkUrl: forkUrl || undefined, + maintenanceMode, + name, + productionRefStrategy, + provider, + syncCadence, + tagPattern: tagPattern || undefined, + upstreamDefaultBranch, + upstreamOwner, + upstreamRepo, + upstreamUrl, + visibility, + }); + router.replace(`/spoons/${spoonId}`); + } catch (error) { + console.error(error); + Alert.alert('Could not create Spoon', 'Check the fields and try again.'); + } finally { + setSubmitting(false); + } + }; + + const loadRepos = async () => { + setLoadingRepos(true); + try { + const result = await listRepositories({}); + setRepositories(result); + } catch (error) { + console.error(error); + Alert.alert('Could not list repositories.'); + } finally { + setLoadingRepos(false); + } + }; + + const createFromRepo = async (repo: Repository) => { + setSubmitting(true); + try { + const upstreamOwnerValue = upstreamOwner.trim() || repo.owner; + const upstreamRepoValue = upstreamRepo.trim() || repo.name; + const upstreamUrlValue = upstreamUrl.trim() || repo.url; + const spoonId = await createManual({ + forkDefaultBranch: repo.defaultBranch, + forkOwner: repo.owner, + forkRepo: repo.name, + forkUrl: repo.url, + maintenanceMode: 'watch', + name: repo.name, + productionRefStrategy: 'default_branch', + provider: 'github', + syncCadence: 'daily', + upstreamDefaultBranch: repo.defaultBranch, + upstreamOwner: upstreamOwnerValue, + upstreamRepo: upstreamRepoValue, + upstreamUrl: upstreamUrlValue, + visibility: repo.private ? 'private' : 'public', + }); + router.replace(`/spoons/${spoonId}`); + } catch (error) { + console.error(error); + Alert.alert('Could not create Spoon from repository.'); + } finally { + setSubmitting(false); + } + }; + + const confirmCreateFromRepo = (repo: Repository) => { + const message = repo.fork + ? 'GitHub did not provide parent repository metadata here. Add upstream fields above if you want Spoon to compare against the original project immediately.' + : 'This will create a manual Spoon record using this repository as both upstream and fork unless you add upstream fields above.'; + + Alert.alert('Create Spoon from repository metadata?', message, [ + { style: 'cancel', text: 'Cancel' }, + { + onPress: () => void createFromRepo(repo), + text: 'Create Spoon', + }, + ]); + }; + + const syncGithub = async () => { + setLoadingRepos(true); + try { + await syncInstallation({}); + } catch (error) { + console.error(error); + Alert.alert('Could not sync GitHub installation.'); + } finally { + setLoadingRepos(false); + } + }; + + return ( + + + + New Spoon + + Create a managed fork record manually or from GitHub. + + + + + {mode === 'manual' ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + {productionRefStrategy === 'tag_pattern' ? ( + + ) : null} + + + + ) : ( + + + Connection + + + {installUrl ? ( + + ) : null} + + + + + + Optional upstream fields are used when the selected repository is a + fork. If you leave them blank, Spoon tracks the selected repository + as both upstream and fork until you correct it later. + + + + + {!loadingRepos && connection && repositories.length === 0 ? ( + + + Load accessible repositories to create a Spoon from GitHub + metadata. + + + ) : null} + {repositories.map((repo) => ( + + + {repo.fullName} + + + {repo.private ? 'Private' : 'Public'} ·{' '} + {repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch} + + + + ))} + + )} + + ); +}; + +export default NewSpoonRoute; diff --git a/apps/expo/src/app/(app)/threads/[threadId].tsx b/apps/expo/src/app/(app)/threads/[threadId].tsx new file mode 100644 index 0000000..6cde4d7 --- /dev/null +++ b/apps/expo/src/app/(app)/threads/[threadId].tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { Alert, Linking, Text, View } from 'react-native'; +import { Link, Stack, useLocalSearchParams } from 'expo-router'; +import { useMutation, useQuery } from 'convex/react'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { ThreadMessageList } from '~/components/threads/thread-message-list'; +import { ThreadStatusBadge } from '~/components/threads/thread-status-badge'; +import { AppScreen } from '~/components/ui/app-screen'; +import { Badge } from '~/components/ui/badge'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { ConfirmButton } from '~/components/ui/confirm-button'; +import { Field } from '~/components/ui/field'; +import { formatDateTime, titleize } from '~/utils/format'; + +const ThreadDetailRoute = () => { + const { threadId: rawThreadId } = useLocalSearchParams<{ + threadId: string; + }>(); + const threadId = rawThreadId as Id<'threads'>; + const details = useQuery(api.threads.get, { threadId }); + const messages = useQuery(api.threads.listMessages, { threadId }) ?? []; + const appendMessage = useMutation(api.threads.appendUserMessage); + const markResolved = useMutation(api.threads.markResolved); + const cancel = useMutation(api.threads.cancel); + const [message, setMessage] = useState(''); + const [pending, setPending] = useState(); + const [refreshing, setRefreshing] = useState(false); + + const send = async () => { + if (!message.trim()) return; + setPending('send'); + try { + await appendMessage({ threadId, content: message }); + setMessage(''); + } catch (error) { + console.error(error); + Alert.alert('Could not send message.'); + } finally { + setPending(undefined); + } + }; + + const softRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 600); + }; + + const resolveThread = async () => { + setPending('resolve'); + try { + await markResolved({ threadId }); + } catch (error) { + console.error(error); + Alert.alert('Could not resolve thread.'); + } finally { + setPending(undefined); + } + }; + + const cancelThread = async () => { + setPending('cancel'); + try { + await cancel({ threadId }); + } catch (error) { + console.error(error); + Alert.alert('Could not cancel thread.'); + } finally { + setPending(undefined); + } + }; + + if (!details) { + return ( + + + Loading thread... + + ); + } + + const { thread, spoon, latestJob } = details; + const pullRequestUrl = latestJob?.pullRequestUrl; + const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes( + thread.status, + ); + + return ( + + + + + {thread.title} + + + + + {thread.maintenanceOutcome ? ( + + ) : null} + + + Updated {formatDateTime(thread.updatedAt)} + + + + {spoon ? ( + + Spoon + + {spoon.name} + + + + + + ) : null} + + {latestJob ? ( + + Latest job + + {titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)} + + + Branch: {latestJob.workBranch} + + + + + {pullRequestUrl ? ( + + ) : null} + + ) : null} + + + + + Reply + + + + + + + void cancelThread()} + > + {pending === 'cancel' ? 'Cancelling...' : 'Cancel'} + + + + ); +}; + +export default ThreadDetailRoute; diff --git a/apps/expo/src/app/(app)/threads/_layout.tsx b/apps/expo/src/app/(app)/threads/_layout.tsx new file mode 100644 index 0000000..8bc6af2 --- /dev/null +++ b/apps/expo/src/app/(app)/threads/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +const ThreadsLayout = () => ; + +export default ThreadsLayout; diff --git a/apps/expo/src/app/(app)/threads/index.tsx b/apps/expo/src/app/(app)/threads/index.tsx new file mode 100644 index 0000000..0487a50 --- /dev/null +++ b/apps/expo/src/app/(app)/threads/index.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { Text, View } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import { useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import type { PillTab } from '~/components/ui/pill-tabs'; +import { ThreadListRow } from '~/components/threads/thread-list-row'; +import { AppScreen } from '~/components/ui/app-screen'; +import { EmptyState } from '~/components/ui/empty-state'; +import { PillTabs } from '~/components/ui/pill-tabs'; + +type StatusFilter = + | 'all' + | 'open' + | 'running' + | 'waiting_for_user' + | 'resolved'; + +const filters: PillTab[] = [ + { label: 'All', value: 'all' }, + { label: 'Open', value: 'open' }, + { label: 'Running', value: 'running' }, + { label: 'Waiting', value: 'waiting_for_user' }, + { label: 'Resolved', value: 'resolved' }, +]; + +const ThreadsRoute = () => { + const router = useRouter(); + const [status, setStatus] = useState('all'); + const [refreshing, setRefreshing] = useState(false); + const threads = + useQuery(api.threads.listMine, { + limit: 50, + status, + }) ?? []; + + const softRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 600); + }; + + return ( + + + + Threads + + Maintenance decisions, user requests, and workspace handoffs. + + + + + {threads.length ? ( + threads.map((thread) => ( + router.push(`/threads/${thread._id}`)} + /> + )) + ) : ( + + )} + + + ); +}; + +export default ThreadsRoute; diff --git a/apps/expo/src/app/(app)/workspace/[jobId].tsx b/apps/expo/src/app/(app)/workspace/[jobId].tsx new file mode 100644 index 0000000..1e4746b --- /dev/null +++ b/apps/expo/src/app/(app)/workspace/[jobId].tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Alert, Text, View } from 'react-native'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useMutation, useQuery } from 'convex/react'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import type { PillTab } from '~/components/ui/pill-tabs'; +import { AppScreen } from '~/components/ui/app-screen'; +import { PillTabs } from '~/components/ui/pill-tabs'; +import { WorkspaceArtifacts } from '~/components/workspace/workspace-artifacts'; +import { WorkspaceEvents } from '~/components/workspace/workspace-events'; +import { WorkspaceMessages } from '~/components/workspace/workspace-messages'; +import { WorkspaceSummary } from '~/components/workspace/workspace-summary'; + +type WorkspaceTab = 'status' | 'messages' | 'diffs' | 'events' | 'artifacts'; + +const tabs: PillTab[] = [ + { label: 'Status', value: 'status' }, + { label: 'Messages', value: 'messages' }, + { label: 'Diffs', value: 'diffs' }, + { label: 'Events', value: 'events' }, + { label: 'Artifacts', value: 'artifacts' }, +]; + +const WorkspaceRoute = () => { + const { jobId: rawJobId } = useLocalSearchParams<{ jobId: string }>(); + const jobId = rawJobId as Id<'agentJobs'>; + const [tab, setTab] = useState('status'); + const [refreshing, setRefreshing] = useState(false); + const [cancelling, setCancelling] = useState(false); + const job = useQuery(api.agentJobs.get, { jobId }); + const messages = useQuery(api.agentJobs.listMessages, { jobId }) ?? []; + const events = + useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? []; + const artifacts = useQuery(api.agentJobs.listArtifacts, { jobId }) ?? []; + const cancel = useMutation(api.agentJobs.cancel); + + const softRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 600); + }; + + const cancelJob = async () => { + setCancelling(true); + try { + await cancel({ jobId }); + } catch (error) { + console.error(error); + Alert.alert('Could not cancel job.'); + } finally { + setCancelling(false); + } + }; + + if (!job) { + return ( + + + Loading workspace... + + ); + } + + return ( + + + + + Workspace review + + + Inspect the active job without exposing worker internals to mobile. + + + + {tab === 'status' ? ( + void cancelJob()} + /> + ) : null} + {tab === 'messages' ? : null} + {tab === 'diffs' ? ( + + ) : null} + {tab === 'events' ? : null} + {tab === 'artifacts' ? ( + + ) : null} + + ); +}; + +export default WorkspaceRoute; diff --git a/apps/expo/src/app/(app)/workspace/_layout.tsx b/apps/expo/src/app/(app)/workspace/_layout.tsx new file mode 100644 index 0000000..3823f21 --- /dev/null +++ b/apps/expo/src/app/(app)/workspace/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +const WorkspaceLayout = () => ; + +export default WorkspaceLayout; diff --git a/apps/expo/src/app/(auth)/sign-in.tsx b/apps/expo/src/app/(auth)/sign-in.tsx new file mode 100644 index 0000000..839e6fa --- /dev/null +++ b/apps/expo/src/app/(auth)/sign-in.tsx @@ -0,0 +1,12 @@ +import { Stack } from 'expo-router'; + +import { SignInScreen } from '~/components/auth/sign-in-screen'; + +const SignInRoute = () => ( + <> + + + +); + +export default SignInRoute; diff --git a/apps/expo/src/app/index.tsx b/apps/expo/src/app/index.tsx index 7d7704c..5a3bdd4 100644 --- a/apps/expo/src/app/index.tsx +++ b/apps/expo/src/app/index.tsx @@ -1,177 +1,28 @@ -import { useMemo, useState } from 'react'; -import { Alert, Pressable, Text, TextInput, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import * as Linking from 'expo-linking'; -import { Stack } from 'expo-router'; -import * as WebBrowser from 'expo-web-browser'; -import { useAuthActions } from '@convex-dev/auth/react'; -import { useConvexAuth, useQuery } from 'convex/react'; +import { useEffect } from 'react'; +import { Stack, useRouter } from 'expo-router'; +import { useConvexAuth } from 'convex/react'; -import { api } from '@spoon/backend/convex/_generated/api.js'; +import { LoadingState } from '~/components/ui/loading-state'; -WebBrowser.maybeCompleteAuthSession(); - -const Stat = ({ label, value }: { label: string; value: number }) => ( - - {label} - {value} - -); - -const Index = () => { +const IndexRoute = () => { const { isAuthenticated, isLoading } = useConvexAuth(); - const { signIn, signOut } = useAuthActions(); - const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip'); - const spoons = - useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? []; - const syncRuns = - useQuery( - api.syncRuns.listRecent, - isAuthenticated ? { limit: 5 } : 'skip', - ) ?? []; - const threads = - useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ?? - []; - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [submitting, setSubmitting] = useState(false); - const redirectTo = useMemo(() => Linking.createURL(''), []); + const router = useRouter(); - const handlePasswordSignIn = async () => { - setSubmitting(true); - try { - await signIn('password', { email, password, flow: 'signIn' }); - } catch (error) { - console.error(error); - Alert.alert('Sign in failed', 'Check your email and password.'); - } finally { - setSubmitting(false); + useEffect(() => { + if (isLoading) return; + if (isAuthenticated) { + router.replace('/dashboard'); + } else { + router.replace('/sign-in'); } - }; - - const handleAuthentikSignIn = async () => { - setSubmitting(true); - try { - const result = await signIn('authentik', { redirectTo }); - if (!result.redirect) return; - const authResult = await WebBrowser.openAuthSessionAsync( - result.redirect.toString(), - redirectTo, - ); - if (authResult.type !== 'success') return; - const parsed = Linking.parse(authResult.url); - const code = parsed.queryParams?.code; - if (typeof code !== 'string') { - Alert.alert('Sign in failed', 'Authentik did not return a code.'); - return; - } - await signIn('authentik', { code }); - } catch (error) { - console.error(error); - Alert.alert('Sign in failed', 'Could not complete Authentik sign in.'); - } finally { - setSubmitting(false); - } - }; + }, [isAuthenticated, isLoading, router]); return ( - + <> - - - Spoon - - Fork freely. Stay close to upstream. - - - - {isLoading ? ( - Loading... - ) : isAuthenticated ? ( - - - - Welcome{user?.name ? `, ${user.name}` : ''} - - - Monitor your managed forks from anywhere. - - - - - - - - - - Recent Spoons - - {spoons.length ? ( - spoons.slice(0, 4).map((spoon) => ( - - {spoon.name} - {spoon.status.replaceAll('_', ' ')} - - )) - ) : ( - - Create your first Spoon from the web dashboard. - - )} - - void signOut()} - > - - Sign out - - - - ) : ( - - - - void handlePasswordSignIn()} - > - - Sign in with password - - - void handleAuthentikSignIn()} - > - - Continue with Authentik - - - - Register the native redirect URI based on spoon:// in Authentik. - - - )} - - + + ); }; -export default Index; +export default IndexRoute; diff --git a/apps/expo/src/app/post/[id].tsx b/apps/expo/src/app/post/[id].tsx deleted file mode 100644 index 9c80beb..0000000 --- a/apps/expo/src/app/post/[id].tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Stack, useLocalSearchParams } from 'expo-router'; - -const Post = () => { - const { id } = useLocalSearchParams<{ id: string }>(); - - return ( - - - - Post {id} - - Implement your post detail screen here using Convex queries. - - - - ); -}; - -export default Post; diff --git a/apps/expo/src/components/auth/sign-in-screen.tsx b/apps/expo/src/components/auth/sign-in-screen.tsx new file mode 100644 index 0000000..45165d1 --- /dev/null +++ b/apps/expo/src/components/auth/sign-in-screen.tsx @@ -0,0 +1,113 @@ +import { useMemo, useState } from 'react'; +import { Alert, Text, View } from 'react-native'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; +import { useAuthActions } from '@convex-dev/auth/react'; + +import { AppScreen } from '~/components/ui/app-screen'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { Field } from '~/components/ui/field'; + +WebBrowser.maybeCompleteAuthSession(); + +type OAuthProvider = 'github' | 'authentik'; + +export const SignInScreen = () => { + const { signIn } = useAuthActions(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const redirectTo = useMemo(() => Linking.createURL(''), []); + + const signInWithPassword = async () => { + setSubmitting(true); + try { + await signIn('password', { email, password, flow: 'signIn' }); + } catch (error) { + console.error(error); + Alert.alert('Sign in failed', 'Check your email and password.'); + } finally { + setSubmitting(false); + } + }; + + const signInWithOAuth = async (provider: OAuthProvider) => { + setSubmitting(true); + try { + const result = await signIn(provider, { redirectTo }); + if (!result.redirect) return; + const authResult = await WebBrowser.openAuthSessionAsync( + result.redirect.toString(), + redirectTo, + ); + if (authResult.type !== 'success') return; + const parsed = Linking.parse(authResult.url); + const code = parsed.queryParams?.code; + if (typeof code !== 'string') { + Alert.alert('Sign in failed', 'The provider did not return a code.'); + return; + } + await signIn(provider, { code }); + } catch (error) { + console.error(error); + Alert.alert('Sign in failed', `Could not complete ${provider} sign in.`); + } finally { + setSubmitting(false); + } + }; + + return ( + + + Spoon + + Fork freely & keep them close to upstream. + + + + + + + + + + + Sign in with email + + + + + + + + Native OAuth callbacks should allow the `spoon://` redirect scheme. + + + ); +}; diff --git a/apps/expo/src/components/settings/ai-provider-profile-form.tsx b/apps/expo/src/components/settings/ai-provider-profile-form.tsx new file mode 100644 index 0000000..28160e3 --- /dev/null +++ b/apps/expo/src/components/settings/ai-provider-profile-form.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState } from 'react'; +import { Text } from 'react-native'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; + +import { Button } from '~/components/ui/button'; +import { Field } from '~/components/ui/field'; +import { FormSection } from '~/components/ui/form-section'; +import { SheetSelect } from '~/components/ui/sheet-select'; +import { SwitchRow } from '~/components/ui/switch-row'; +import { Textarea } from '~/components/ui/textarea'; + +type Provider = + | 'openai' + | 'anthropic' + | 'google' + | 'openrouter' + | 'requesty' + | 'litellm' + | 'cloudflare_ai_gateway' + | 'custom_openai_compatible' + | 'opencode_openai_login'; +type AuthType = 'api_key' | 'opencode_auth_json' | 'none'; +type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + +type ExistingProfile = { + _id: Id<'aiProviderProfiles'>; + authType: AuthType; + baseUrl?: string; + defaultModel: string; + enabled: boolean; + modelOptions?: string[]; + name: string; + provider: Provider; + reasoningEffort: ReasoningEffort; +}; + +const providerDefaults: Record< + Provider, + { authType: AuthType; model: string; name: string } +> = { + anthropic: { + authType: 'api_key', + model: 'claude-sonnet-4-5', + name: 'Anthropic', + }, + cloudflare_ai_gateway: { + authType: 'api_key', + model: 'gpt-5.1-codex', + name: 'Cloudflare AI Gateway', + }, + custom_openai_compatible: { + authType: 'api_key', + model: 'gpt-5.1-codex', + name: 'Custom compatible', + }, + google: { authType: 'api_key', model: 'gemini-2.5-pro', name: 'Google' }, + litellm: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'LiteLLM' }, + opencode_openai_login: { + authType: 'opencode_auth_json', + model: 'gpt-5.1-codex', + name: 'OpenCode provider', + }, + openai: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'OpenAI' }, + openrouter: { + authType: 'api_key', + model: 'openai/gpt-5.1-codex', + name: 'OpenRouter', + }, + requesty: { + authType: 'api_key', + model: 'openai/gpt-5.1-codex', + name: 'Requesty', + }, +}; + +const parseModelOptions = (text: string) => + text + .split(/\r?\n|,/) + .map((model) => model.trim()) + .filter(Boolean); + +export const AiProviderProfileForm = ({ + existing, + onSubmit, + saving, +}: { + existing?: ExistingProfile; + onSubmit: (values: { + authType: AuthType; + baseUrl?: string; + defaultModel: string; + enabled: boolean; + modelOptions: string[]; + name: string; + provider: Provider; + reasoningEffort: ReasoningEffort; + secret?: string; + }) => Promise; + saving: boolean; +}) => { + const [name, setName] = useState(existing?.name ?? 'OpenCode provider'); + const [provider, setProvider] = useState( + existing?.provider ?? 'opencode_openai_login', + ); + const [authType, setAuthType] = useState( + existing?.authType ?? 'opencode_auth_json', + ); + const [secret, setSecret] = useState(''); + const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? ''); + const [modelOptions, setModelOptions] = useState( + (existing?.modelOptions?.length + ? existing.modelOptions + : [existing?.defaultModel ?? 'gpt-5.1-codex'] + ).join('\n'), + ); + const models = useMemo(() => parseModelOptions(modelOptions), [modelOptions]); + const [defaultModel, setDefaultModel] = useState( + existing?.defaultModel ?? 'gpt-5.1-codex', + ); + const [reasoningEffort, setReasoningEffort] = useState( + existing?.reasoningEffort ?? 'medium', + ); + const [enabled, setEnabled] = useState(existing?.enabled ?? true); + + const changeProvider = (nextProvider: Provider) => { + const defaults = providerDefaults[nextProvider]; + setProvider(nextProvider); + setName(defaults.name); + setAuthType(defaults.authType); + setDefaultModel(defaults.model); + setModelOptions(defaults.model); + }; + + const submit = () => + void onSubmit({ + authType, + baseUrl: baseUrl || undefined, + defaultModel: models.includes(defaultModel) + ? defaultModel + : (models[0] ?? defaultModel), + enabled, + modelOptions: models, + name, + provider, + reasoningEffort, + secret: secret || undefined, + }); + + return ( + + + + + {authType === 'opencode_auth_json' ? ( + + Copy auth.json from your Codex/OpenCode auth folder, for example + ~/.codex/auth.json, and paste it here. + + ) : null} + {authType !== 'none' ? ( + + ) : null} + +