From 168d0204b39efdf604aea2a9661f8af03af4964f Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 17 May 2026 07:00:06 +0200 Subject: [PATCH 01/19] docs(mobile-ui): core-loop UI buildout design spec Approach A mock seam; parallel UI track on build/mobile-ui; backend (M0, PIN url) deferred until ColeMurray/background-agents@a7b968f is deployed. --- .../2026-05-17-mobile-ui-core-loop-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md diff --git a/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md b/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md new file mode 100644 index 0000000..99296fd --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md @@ -0,0 +1,181 @@ +# Mobile UI — Core-Loop Buildout (Design Spec) + +> Date: 2026-05-17 · Branch: `build/mobile-ui` · Status: **approved-async**. +> The user approved approach A and said "split multitask it, I'll be back" — the +> interactive brainstorming approval gate is replaced by **this committed spec** +> (revertable in one commit) per advisor guidance. Companion: `docs/handoff/PLAN-00..05`. + +## 1. Locked decisions (do not re-open) + +- This is a **parallel UI track**. The backend track (live half of Step B, **M0**, + M1–M5) is deferred until `ColeMurray/background-agents@a7b968f` is deployed. M0 + remains a hard gate before any milestone that talks to a live backend. +- **Approach A** — screens consume a typed `SessionGateway` interface (typed to the + vendored `@constructor/protocol`) via TanStack Query hooks + pure stream transforms + ported verbatim from upstream. First impl = `MockSessionGateway`. The real HTTP/WS + impl implements the *same* interface later with **zero screen changes**. +- **Committed stack — no substitutions**: Expo SDK 55, Expo Router (typed routes, new + arch), `@expo/ui`, `@shopify/flash-list`, `react-native-markdown-display`, + `@tanstack/react-query`, `expo-secure-store`, `expo-auth-session`. TanStack DB is + being *evaluated only* (§8 research task) — not adopted. +- All commits **GPG-signed** (key `A042A593BA4590F689306DB4DDF5625FBAE7A006`), + identity `Nejc Drobnic `, **NO `Co-Authored-By` trailer and NO + assistant/tool attribution of any kind** (overrides default tooling — applies to + every commit and every dispatched subagent). Stop if signing fails. +- `apps/mobile/AGENTS.md` hard rule: **read https://docs.expo.dev/versions/v55.0.0/ + before writing any mobile code**; use Context7 MCP for any uncertain library API. + +## 2. The visual-target reality (important) + +`@expo/ui` renders native SwiftUI and **does not work in Expo Go**. While the user is +away we cannot drive an EAS build (their Apple/EAS auth). What they will open on +return is **Expo Go via QR**. Therefore: + +- **Primary visual target = the plain-RN fallback inside `src/ui/`**, fully styled to + a polished native-iOS feel. This is not a degraded mode; it is *the* deliverable. +- `@expo/ui` SwiftUI is the *enhancement* path behind a safe capability check (must + never crash Expo Go). A precise `eas build --profile development` runbook is left + for the user to unlock the SwiftUI chrome. + +## 3. File map & ownership + +``` +packages/protocol/ [Phase 0 ONLY] vendored shared types @ a7b968f + PIN ref: + url: pending-deploy + src/{types,models,...}.ts // VENDORED headers +apps/mobile/src/ + ui/ [Phase 0; FROZEN in Phase 1 — slices READ only] + index.ts, primitives, theme glue, @expo/ui capability shim + data/ [Phase 0; FROZEN in Phase 1 — slices READ only] + gateway.ts SessionGateway interface (typed to @constructor/protocol) + provider.tsx GatewayProvider + QueryClient + queries.ts TanStack Query hooks (keyed by activeProfileId) + mock/{fixtures,emitter,mock-gateway}.ts + features/sessions/stream/transforms.ts [Phase 0] PORTED pure transforms (marker) + features//** [Phase 1 — exactly one slice agent owns each] + app/** [Phase 0 pre-creates thin route wrappers] + constants/theme.ts [Phase 0; FROZEN in Phase 1] +``` + +**Ownership rules (parallelization safety):** + +1. **Phase 0 owns ALL `package.json` / `pnpm-lock.yaml` edits.** A slice agent that + thinks it needs a new dependency **HALTS and reports** — never `pnpm add` in a slice. +2. Route files under `src/app/` are pre-created in Phase 0 as thin wrappers that + `import` from `src/features//screen.tsx`. Slice agents edit **only** + `src/features//**`. +3. **Frozen during Phase 1** (slice agents READ, never EDIT): `src/ui/**`, + `src/constants/theme.ts`, `src/data/**`, `features/sessions/stream/transforms.ts`, + `packages/protocol/**`, `src/app/**`. +4. No cross-slice imports. If the shared contract feels too thin, the slice agent + **HALTS and reports** rather than widening it locally. + +## 4. `SessionGateway` contract (Phase 0 owns; reconcile to vendored names) + +Conceptual shape (exact protocol type names confirmed when vendored in Phase 0; the +interface is Phase-0-owned and reconciled there before fan-out): + +```ts +import type { + Session, SessionState, SandboxEvent, CreateSessionRequest, +} from "@constructor/protocol"; + +export type StreamHandle = { unsubscribe(): void }; + +export interface SessionGateway { + listSessions(): Promise; + getSession(id: string): Promise; + createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>; + /** Mirrors real DO: emits `subscribed` (state + replay{events,hasMore,cursor}) + * then a stream of SandboxEvent, then terminal status. */ + subscribe(id: string, on: (e: SandboxEvent) => void): StreamHandle; + sendFollowUp(id: string, text: string): Promise; + stop(id: string): Promise; +} +``` + +Screens never touch the gateway directly — only via `data/queries.ts` hooks +(`useSessions`, `useSession`, `useCreateSession`, `useSessionStream`, …), all keyed by +`activeProfileId` so profiles never bleed (PLAN-02). + +## 5. Mock contract & scripted scenario + +The mock obeys the **real subscribe shape** so screens behave as the real Durable +Object will (no post-M0 rework): `subscribe` → `{ type:"subscribed", state, +replay:{events,hasMore,cursor} }` → ordered `SandboxEvent` stream → terminal. + +**Scenario A (happy path)** — bind discriminant names to the vendored union in Phase 0: + +1. `subscribed` with `state.status = "running"`, `replay.events = []`, `hasMore:false`. +2. user prompt echoed → assistant "thinking" indicator. +3. `tool_call` started: `write_file src/App.tsx` → `tool_output` (success). +4. token stream for one `messageId`: ~20 token events, **cumulative live / coalesced + on replay** (transforms.ts handles folding) rendered as growing markdown. +5. `message` finalized for that `messageId`. +6. terminal `status = "completed"`. + +**Scenario B (short)**: prompt → tool error → `status = "error"` (drives the error UI). + +Emitter replays Scenario A by default with realistic 40–120 ms inter-token timing so +FlashList streaming/`maintainVisibleContentPosition` is exercised. + +## 6. Slices (Phase 1 — one agent each, disjoint dirs) + +| Slice | Route | Feature dir | Gateway/hooks | Required states | +|---|---|---|---|---| +| Profiles/Settings | `/(settings)` | `features/profiles` | profile store (AsyncStorage) + secure-store stub | empty/first-run, list, add/edit/delete, set-active | +| Sign-in shell | `/sign-in` | `features/auth` | mock auth toggle (no real OAuth) | signed-out, in-progress, signed-in | +| Session list | `/(app)/index` | `features/sessions/list` | `useSessions` | loading, empty, populated, error, pull-to-refresh | +| Create session | `/(app)/new` | `features/sessions/create` | `useCreateSession`, `VALID_MODELS` | form, validation, submitting, success→navigate | +| Session detail / stream | `/(app)/s/[id]` | `features/sessions/detail` | `useSession` + `useSessionStream` | connecting, replay, live stream, follow-up composer, stop, completed/error | + +All slices: native-iOS feel via `src/ui` wrapper (never import `@expo/ui` directly), +light/dark via `constants/theme.ts`, typecheck-clean for their files, polished empty/ +loading/error states (no raw spinners-on-white). + +## 7. Phases + +- **Phase 0 (sequential, parent):** branch ✓ → vendor `@constructor/protocol` + @ `a7b968f` (clone, resolve full 40-char SHA, copy pure subset, `// VENDORED` + headers, structured `PIN`: `ref: background-agents@` + `url: + pending-deploy`) → `src/ui` wrapper (RN-primary + `@expo/ui` shim) → data layer + (gateway, mock, emitter, provider, queries) → port pure transforms (marker: + `// Ported from background-agents/packages/web/src/hooks/use-session-socket.ts:63-99,227-274 @ `) + → Expo Router shell + theme + route stubs. **EXIT GATE: `pnpm -w typecheck` clean + AND `expo export`/Metro bundle succeeds.** Signed commit. *Do not dispatch Phase 1 + until the exit gate is green.* +- **Phase 1 (parallel):** 5 slice agents (disjoint dirs, rules §3) + 1 read-only + TanStack DB research agent. Each gets AGENTS.md verbatim + this spec. +- **Phase 2:** integrate, `pnpm -w typecheck` + `expo lint`, confirm Expo-Go + loadable, write EAS dev-build runbook, signed commits, verification-before- + completion, written status report for the user's return. + +## 8. TanStack DB research (read-only, no edits) + +Evaluate https://tanstack.com/db/latest/docs/overview#react-native — output: (a) RN +support reality, (b) fit with socket-stream + protocol-typed + mock-seam architecture, +(c) cost-to-adopt vs current TanStack Query, (d) what Query already gives us that DB +doesn't. **Do not adopt** (committed stack) — surface a recommendation for user decision. + +## 9. Success criteria + +- App loads in **Expo Go via QR**; core loop navigable end-to-end on mock data; + polished native-feel RN UI; light + dark. +- `@expo/ui` SwiftUI path present behind dev build; runbook provided. +- `pnpm -w typecheck` and `expo lint` clean. +- `packages/protocol` vendored @ `a7b968f`, structured `PIN` (`url: pending-deploy`). +- Every commit signed + verified (`git log --show-signature`), no attribution trailer. + +## 10. Deferred / NOT in scope + +M0 + live contract assertions, `PIN` url, real OAuth/auth, real network/WS transport, +history/resume, repo secrets, automations, screenshots, push. (Later slices / gated on +the `a7b968f` deployment.) + +## 11. Open risks + +- `@expo/ui` alpha coverage → mitigated: RN-primary wrapper, one-file swaps. +- Vendored protocol type names may differ from handoff sketches → Phase 0 reconciles + the `SessionGateway`/transforms against the real union *before* fan-out. +- Mock scenario must match the real DO contract shape so screens need no post-M0 + rework → emitter mirrors `subscribed`/`replay` exactly. From e14b8100e576de99ef6ff7c9d9b0f0dcb94dfd9e Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 17 May 2026 07:25:13 +0200 Subject: [PATCH 02/19] feat(mobile-ui): vendor @constructor/protocol @ a7b968f (pure Hermes-safe subset) 6-file type/model closure (types, integrations, triggers-{types,conditions}, models, git) + reproducible scripts/vendor-protocol.sh. PIN url=pending-deploy. Closure typechecks strict; no zod/node/cloudflare/crypto in closure. --- .gitignore | 3 + packages/protocol/PIN | 2 + packages/protocol/src/git.ts | 51 ++ packages/protocol/src/index.ts | 4 + packages/protocol/src/integrations.ts | 139 ++++ packages/protocol/src/models.ts | 252 ++++++ packages/protocol/src/triggers-conditions.ts | 98 +++ packages/protocol/src/triggers-types.ts | 119 +++ packages/protocol/src/types.ts | 779 +++++++++++++++++++ scripts/vendor-protocol.sh | 66 ++ 10 files changed, 1513 insertions(+) create mode 100644 packages/protocol/PIN create mode 100644 packages/protocol/src/git.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/integrations.ts create mode 100644 packages/protocol/src/models.ts create mode 100644 packages/protocol/src/triggers-conditions.ts create mode 100644 packages/protocol/src/triggers-types.ts create mode 100644 packages/protocol/src/types.ts create mode 100644 scripts/vendor-protocol.sh diff --git a/.gitignore b/.gitignore index 1b85a63..a8007eb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist/ .wrangler/ *.log .DS_Store + +# upstream protocol clone (vendor source; never committed) +.upstream/ diff --git a/packages/protocol/PIN b/packages/protocol/PIN new file mode 100644 index 0000000..6e1674c --- /dev/null +++ b/packages/protocol/PIN @@ -0,0 +1,2 @@ +ref: background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c +url: pending-deploy diff --git a/packages/protocol/src/git.ts b/packages/protocol/src/git.ts new file mode 100644 index 0000000..5f9c23f --- /dev/null +++ b/packages/protocol/src/git.ts @@ -0,0 +1,51 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/git.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +/** + * Git utilities for branch management. + */ + +/** + * Branch naming convention for Open-Inspect sessions. + */ +export const BRANCH_PREFIX = "open-inspect"; + +/** + * Normalize a git branch name for consistent Open-Inspect branch handling. + */ +export function normalizeBranchName(branchName: string): string { + return branchName.trim().toLowerCase(); +} + +/** + * Generate a branch name for a session. + * + * @param sessionId - Session ID + * @param title - Optional title for the branch + * @returns Branch name in format: open-inspect/{session-id} + */ +export function generateBranchName(sessionId: string, _title?: string): string { + // Use just session ID to keep it short and unique + return normalizeBranchName(`${BRANCH_PREFIX}/${sessionId}`); +} + +/** + * Extract session ID from a branch name. + * + * @param branchName - Branch name + * @returns Session ID or null if not an Open-Inspect branch + */ +export function extractSessionIdFromBranch(branchName: string): string | null { + const prefix = `${BRANCH_PREFIX}/`; + const normalizedBranchName = normalizeBranchName(branchName); + if (!normalizedBranchName.startsWith(prefix)) { + return null; + } + return normalizedBranchName.slice(prefix.length); +} + +/** + * Check if a branch name is an Open-Inspect branch. + */ +export function isInspectBranch(branchName: string): boolean { + return normalizeBranchName(branchName).startsWith(`${BRANCH_PREFIX}/`); +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..b349a9d --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,4 @@ +// GENERATED by scripts/vendor-protocol.sh — do not edit. Pinned: packages/protocol/PIN +export * from "./types"; +export * from "./models"; +export * from "./git"; diff --git a/packages/protocol/src/integrations.ts b/packages/protocol/src/integrations.ts new file mode 100644 index 0000000..e5770a2 --- /dev/null +++ b/packages/protocol/src/integrations.ts @@ -0,0 +1,139 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/types/integrations.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +// Integration settings types + +export type IntegrationId = "github" | "linear" | "code-server" | "sandbox" | "slack"; + +/** Enforces the common shape for all integration configurations. */ +export interface IntegrationEntry< + TRepo extends object = Record, + TGlobalDefaults extends object = TRepo, +> { + global: { + enabledRepos?: string[]; + defaults?: TGlobalDefaults; + }; + repo: TRepo; +} + +/** Overridable behavior settings for the GitHub bot. Used at both global (defaults) and per-repo (overrides) levels. */ +export interface GitHubBotSettings { + autoReviewOnOpen?: boolean; + model?: string; + reasoningEffort?: string; + allowedTriggerUsers?: string[]; + codeReviewInstructions?: string; + commentActionInstructions?: string; +} + +/** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */ +export interface LinearBotSettings { + model?: string; + reasoningEffort?: string; + allowUserPreferenceOverride?: boolean; + allowLabelModelOverride?: boolean; + emitToolProgressActivities?: boolean; + issueSessionInstructions?: string; +} + +/** Overridable behavior settings for the code-server integration. */ +export interface CodeServerSettings { + enabled?: boolean; +} + +/** Maximum number of tunnel ports a user can configure per sandbox. */ +export const MAX_TUNNEL_PORTS = 10; + +/** Sandbox environment settings. Provider-agnostic: describes what the user wants, not how it's done. */ +export interface SandboxSettings { + /** Extra ports to expose via tunnels (e.g., dev server ports 3000, 5173). */ + tunnelPorts?: number[]; + /** Enable a browser-based terminal (ttyd) in sandbox sessions. */ + terminalEnabled?: boolean; +} + +export type SlackMentionsPolicy = "allow" | "escape" | "strip"; + +/** Per-repo Slack overrides. Mentions policy is workspace-wide and cannot be overridden per repo. */ +export interface SlackRepoSettings { + agentNotificationsEnabled?: boolean; +} + +/** Global Slack defaults: per-repo fields plus workspace-wide policy controls. */ +export interface SlackGlobalSettings extends SlackRepoSettings { + mentionsPolicy?: SlackMentionsPolicy; +} + +/** Maps each integration ID to its global and per-repo settings types. */ +export interface IntegrationSettingsMap { + github: IntegrationEntry; + linear: IntegrationEntry; + "code-server": IntegrationEntry; + sandbox: IntegrationEntry; + slack: IntegrationEntry; +} + +/** Derived type for the GitHub bot global config. */ +export type GitHubGlobalConfig = IntegrationSettingsMap["github"]["global"]; +export type LinearGlobalConfig = IntegrationSettingsMap["linear"]["global"]; +export type CodeServerGlobalConfig = IntegrationSettingsMap["code-server"]["global"]; +export type SandboxGlobalConfig = IntegrationSettingsMap["sandbox"]["global"]; +export type SlackGlobalConfig = IntegrationSettingsMap["slack"]["global"]; + +/** Full MCP server config with decrypted credentials. Internal use only. */ +export interface McpServerConfig { + id: string; + name: string; + type: "local" | "remote"; + command?: string[]; + url?: string; + env?: Record; + headers?: Record; + repoScopes?: string[] | null; + enabled: boolean; +} + +/** MCP server metadata for API responses — no decrypted credentials. */ +export interface McpServerMetadata { + id: string; + name: string; + type: "local" | "remote"; + command?: string[]; + url?: string; + hasEnv: boolean; + hasHeaders: boolean; + repoScopes?: string[] | null; + enabled: boolean; +} + +export const INTEGRATION_DEFINITIONS: { + id: IntegrationId; + name: string; + description: string; +}[] = [ + { + id: "github", + name: "GitHub Bot", + description: "Automated PR reviews and comment-triggered actions", + }, + { + id: "linear", + name: "Linear Agent", + description: "Issue-driven coding sessions from Linear agent mentions", + }, + { + id: "code-server", + name: "Code Server", + description: "Browser-based VS Code editor attached to sandbox sessions", + }, + { + id: "sandbox", + name: "Sandbox", + description: "Sandbox environment settings (tunnel ports, timeouts, etc.)", + }, + { + id: "slack", + name: "Slack", + description: "Agent-driven Slack notifications and mention policy", + }, +]; diff --git a/packages/protocol/src/models.ts b/packages/protocol/src/models.ts new file mode 100644 index 0000000..924a8ad --- /dev/null +++ b/packages/protocol/src/models.ts @@ -0,0 +1,252 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/models.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +/** + * Centralized model definitions and reasoning configuration. + * + * All packages import model-related types and validation from here + * to ensure consistent behavior across control plane, web UI, and Slack bot. + */ + +/** + * Valid model names supported by the system. + * All models use "provider/model" format. + */ +export const VALID_MODELS = [ + "anthropic/claude-haiku-4-5", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-sonnet-4-6", + "anthropic/claude-opus-4-5", + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-7", + "openai/gpt-5.2", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.2-codex", + "openai/gpt-5.3-codex", + "openai/gpt-5.3-codex-spark", + "opencode/kimi-k2.5", + "opencode/minimax-m2.5", + "opencode/glm-5", +] as const; + +export type ValidModel = (typeof VALID_MODELS)[number]; + +/** + * Default model to use when none specified or invalid. + */ +export const DEFAULT_MODEL: ValidModel = "anthropic/claude-sonnet-4-6"; + +/** + * Reasoning effort levels supported across providers. + * + * - "none": No reasoning (OpenAI only) + * - "low"/"medium"/"high"/"xhigh": Progressive reasoning depth + * - "max": Maximum reasoning budget (Anthropic extended thinking) + */ +export type ReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max"; + +export interface ModelReasoningConfig { + efforts: ReasoningEffort[]; + default: ReasoningEffort | undefined; +} + +/** + * Per-model reasoning configuration. + * Models not listed here do not support reasoning controls. + */ +export const MODEL_REASONING_CONFIG: Partial> = { + "anthropic/claude-haiku-4-5": { efforts: ["high", "max"], default: "max" }, + "anthropic/claude-sonnet-4-5": { efforts: ["high", "max"], default: "max" }, + "anthropic/claude-sonnet-4-6": { efforts: ["low", "medium", "high", "max"], default: "high" }, + "anthropic/claude-opus-4-5": { efforts: ["high", "max"], default: "max" }, + "anthropic/claude-opus-4-6": { efforts: ["low", "medium", "high", "max"], default: "high" }, + "anthropic/claude-opus-4-7": { efforts: ["low", "medium", "high", "max"], default: "high" }, + "openai/gpt-5.2": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined }, + "openai/gpt-5.4": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined }, + "openai/gpt-5.5": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined }, + "openai/gpt-5.2-codex": { efforts: ["low", "medium", "high", "xhigh"], default: "high" }, + "openai/gpt-5.3-codex": { efforts: ["low", "medium", "high", "xhigh"], default: "high" }, + "openai/gpt-5.3-codex-spark": { efforts: ["low", "medium", "high", "xhigh"], default: "high" }, +}; + +export interface ModelDisplayInfo { + id: ValidModel; + name: string; + description: string; +} + +export interface ModelCategory { + category: string; + models: ModelDisplayInfo[]; +} + +/** + * Model options grouped by provider, for use in UI dropdowns. + */ +export const MODEL_OPTIONS: ModelCategory[] = [ + { + category: "Anthropic", + models: [ + { + id: "anthropic/claude-haiku-4-5", + name: "Claude Haiku 4.5", + description: "Fast and efficient", + }, + { + id: "anthropic/claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + description: "Balanced performance", + }, + { + id: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + description: "Latest balanced, fast coding", + }, + { + id: "anthropic/claude-opus-4-5", + name: "Claude Opus 4.5", + description: "Most capable", + }, + { + id: "anthropic/claude-opus-4-6", + name: "Claude Opus 4.6", + description: "Most capable, adaptive thinking", + }, + { + id: "anthropic/claude-opus-4-7", + name: "Claude Opus 4.7", + description: "Latest, most capable", + }, + ], + }, + { + category: "OpenAI", + models: [ + { id: "openai/gpt-5.2", name: "GPT 5.2", description: "400K context, fast" }, + { id: "openai/gpt-5.4", name: "GPT 5.4", description: "Flagship model" }, + { id: "openai/gpt-5.5", name: "GPT 5.5", description: "Latest flagship model" }, + { id: "openai/gpt-5.2-codex", name: "GPT 5.2 Codex", description: "Optimized for code" }, + { id: "openai/gpt-5.3-codex", name: "GPT 5.3 Codex", description: "Latest codex" }, + { + id: "openai/gpt-5.3-codex-spark", + name: "GPT 5.3 Codex Spark", + description: "Low-latency codex variant", + }, + ], + }, + { + category: "OpenCode Zen", + models: [ + { id: "opencode/kimi-k2.5", name: "Kimi K2.5", description: "Moonshot AI" }, + { id: "opencode/minimax-m2.5", name: "MiniMax M2.5", description: "MiniMax" }, + { id: "opencode/glm-5", name: "GLM 5", description: "Z.ai 744B MoE" }, + ], + }, +]; + +/** + * Models enabled by default when no preferences are stored. + * Excludes zen models which must be opted into via settings. + */ +export const DEFAULT_ENABLED_MODELS: ValidModel[] = [ + "anthropic/claude-haiku-4-5", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-sonnet-4-6", + "anthropic/claude-opus-4-5", + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-7", + "openai/gpt-5.2", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.2-codex", + "openai/gpt-5.3-codex", + "openai/gpt-5.3-codex-spark", +]; + +// === Normalization === + +/** + * Normalize a model ID to canonical "provider/model" format. + * Adds "anthropic/" prefix to bare Claude model names and "openai/" prefix + * to bare GPT model names for backward compat with existing data in D1, + * SQLite, and Slack KV. + */ +export function normalizeModelId(modelId: string): string { + if (modelId.includes("/")) return modelId; + if (modelId.startsWith("claude-")) return `anthropic/${modelId}`; + if (modelId.startsWith("gpt-")) return `openai/${modelId}`; + return modelId; +} + +// === Validation helpers === + +/** + * Check if a model name is valid. + * Accepts both prefixed ("anthropic/claude-haiku-4-5") and bare ("claude-haiku-4-5") formats. + */ +export function isValidModel(model: string): model is ValidModel { + return VALID_MODELS.includes(normalizeModelId(model) as ValidModel); +} + +/** + * Check if a model supports reasoning controls. + */ +export function supportsReasoning(model: string): boolean { + return getReasoningConfig(model) !== undefined; +} + +/** + * Get reasoning configuration for a model, or undefined if not supported. + */ +export function getReasoningConfig(model: string): ModelReasoningConfig | undefined { + const normalized = normalizeModelId(model); + if (!isValidModel(normalized)) return undefined; + return MODEL_REASONING_CONFIG[normalized as ValidModel]; +} + +/** + * Get the default reasoning effort for a model, or undefined if not supported. + */ +export function getDefaultReasoningEffort(model: string): ReasoningEffort | undefined { + return getReasoningConfig(model)?.default; +} + +/** + * Check if a reasoning effort is valid for a given model. + */ +export function isValidReasoningEffort(model: string, effort: string): boolean { + const config = getReasoningConfig(model); + if (!config) return false; + return config.efforts.includes(effort as ReasoningEffort); +} + +/** + * Extract provider and model from a model ID. + * + * Normalizes bare Claude model names first, then splits on "/". + * + * @example + * extractProviderAndModel("anthropic/claude-haiku-4-5") // { provider: "anthropic", model: "claude-haiku-4-5" } + * extractProviderAndModel("claude-haiku-4-5") // { provider: "anthropic", model: "claude-haiku-4-5" } + * extractProviderAndModel("openai/gpt-5.2-codex") // { provider: "openai", model: "gpt-5.2-codex" } + */ +export function extractProviderAndModel(modelId: string): { provider: string; model: string } { + const normalized = normalizeModelId(modelId); + if (normalized.includes("/")) { + const [provider, ...modelParts] = normalized.split("/"); + return { provider, model: modelParts.join("/") }; + } + // Fallback for truly unknown models + return { provider: "anthropic", model: normalized }; +} + +/** + * Get a valid model or fall back to default. + * Accepts both prefixed and bare formats; always returns canonical prefixed format. + */ +export function getValidModelOrDefault(model: string | undefined | null): ValidModel { + if (model && isValidModel(model)) { + return normalizeModelId(model) as ValidModel; + } + return DEFAULT_MODEL; +} diff --git a/packages/protocol/src/triggers-conditions.ts b/packages/protocol/src/triggers-conditions.ts new file mode 100644 index 0000000..b37487b --- /dev/null +++ b/packages/protocol/src/triggers-conditions.ts @@ -0,0 +1,98 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/triggers/conditions.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +/** + * Condition system for trigger-based automations. + * + * Each condition type's shape is defined once in ConditionConfigMap. + * TypeScript derives the discriminated union and typed handler interfaces from it. + */ + +import type { AutomationEvent, AutomationEventSource } from "./triggers-types"; + +// ─── 1. ConditionConfigMap: single source of truth ─────────────────────────── + +export interface ConditionConfigMap { + branch: { operator: "glob_match" | "exact"; value: string[] }; + label: { operator: "any_of" | "none_of"; value: string[] }; + path_glob: { operator: "any_match"; value: string[] }; + actor: { operator: "include" | "exclude"; value: string[] }; + check_conclusion: { operator: "eq"; value: string }; + linear_status: { operator: "any_of"; value: string[] }; + sentry_project: { operator: "any_of"; value: string[] }; + sentry_level: { operator: "any_of"; value: string[] }; + jsonpath: { operator: "all_match"; value: JsonPathFilter[] }; +} + +export interface JsonPathFilter { + path: string; + comparison: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "exists"; + value?: string | number | boolean; +} + +// ─── 2. Derived discriminated union ────────────────────────────────────────── + +export type TriggerCondition = { + [K in keyof ConditionConfigMap]: { type: K } & ConditionConfigMap[K]; +}[keyof ConditionConfigMap]; + +// ─── 3. Typed handler interface ────────────────────────────────────────────── + +export type ConditionType = keyof ConditionConfigMap; + +type ConditionOf = Extract; + +export interface ConditionHandler { + /** Validate at automation creation time. Returns null if valid, error string otherwise. */ + validate(condition: ConditionOf): string | null; + + /** Evaluate at event matching time. Returns true if the condition passes. */ + evaluate(condition: ConditionOf, event: AutomationEvent): boolean; + + /** Which event sources this condition can be used with. */ + appliesTo: AutomationEventSource[]; +} + +// ─── 4. Typed registry ─────────────────────────────────────────────────────── + +export type ConditionRegistry = { + [K in ConditionType]: ConditionHandler; +}; + +// ─── 5. Dispatch ───────────────────────────────────────────────────────────── + +export function matchesConditions( + conditions: TriggerCondition[], + event: AutomationEvent, + registry: ConditionRegistry +): boolean { + return conditions.every((condition) => { + const handler = registry[condition.type] as ConditionHandler; + return handler.evaluate(condition, event); + }); +} + +// ─── 6. Validation (called at automation creation time) ────────────────────── + +export function validateConditions( + conditions: TriggerCondition[], + triggerSource: AutomationEventSource, + registry: ConditionRegistry +): string[] { + const errors: string[] = []; + for (const condition of conditions) { + const handler = registry[condition.type] as ConditionHandler; + if (!handler.appliesTo.includes(triggerSource)) { + errors.push(`Condition "${condition.type}" does not apply to ${triggerSource} triggers`); + continue; + } + const err = handler.validate(condition); + if (err) errors.push(err); + } + return errors; +} + +// ─── 7. TriggerConfig (stored as JSON in D1) ──────────────────────────────── + +export interface TriggerConfig { + conditions: TriggerCondition[]; +} diff --git a/packages/protocol/src/triggers-types.ts b/packages/protocol/src/triggers-types.ts new file mode 100644 index 0000000..eac37b8 --- /dev/null +++ b/packages/protocol/src/triggers-types.ts @@ -0,0 +1,119 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/triggers/types.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +/** + * Core types for the trigger-based automation event system. + */ + +import type { AutomationTriggerType } from "./types"; +import type { ConditionType } from "./triggers-conditions"; + +// ─── Event Sources ──────────────────────────────────────────────────────────── + +export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook"; + +/** + * Maps AutomationTriggerType → AutomationEventSource. + * Used by control-plane validation and web UI condition builders. + */ +export const TRIGGER_TYPE_TO_SOURCE: Partial> = + { + github_event: "github", + linear_event: "linear", + sentry: "sentry", + webhook: "webhook", + }; + +// ─── Base Event ─────────────────────────────────────────────────────────────── + +interface BaseAutomationEvent { + /** Dot-delimited event type (e.g., "pull_request.opened", "issue.created"). */ + eventType: string; + + /** Trigger key for dedup and concurrency (e.g., "pr:42", "sentry_issue:12345"). */ + triggerKey: string; + + /** Concurrency key — the stable prefix of triggerKey for concurrency scoping. */ + concurrencyKey: string; + + /** Human-readable context block prepended to automation instructions. */ + contextBlock: string; + + /** Raw event metadata for logging/debugging. Not used for matching. */ + meta: Record; +} + +// ─── Source-Specific Variants ───────────────────────────────────────────────── + +export interface GitHubAutomationEvent extends BaseAutomationEvent { + source: "github"; + repoOwner: string; + repoName: string; + branch?: string; + labels?: string[]; + actor?: string; + changedFiles?: string[]; + checkConclusion?: string; +} + +export interface LinearAutomationEvent extends BaseAutomationEvent { + source: "linear"; + repoOwner: string; + repoName: string; + actor?: string; + labels?: string[]; + linearStatus?: string; +} + +export interface SentryAutomationEvent extends BaseAutomationEvent { + source: "sentry"; + automationId: string; + sentryProject: string; + sentryLevel: string; + culpritFile?: string; +} + +export interface WebhookAutomationEvent extends BaseAutomationEvent { + source: "webhook"; + automationId: string; + body: unknown; +} + +// ─── Discriminated Union ────────────────────────────────────────────────────── + +export type AutomationEvent = + | GitHubAutomationEvent + | LinearAutomationEvent + | SentryAutomationEvent + | WebhookAutomationEvent; + +// ─── Trigger Source Definition ──────────────────────────────────────────────── + +export interface TriggerSourceDefinition { + /** Source identifier — must match a member of AutomationEventSource. */ + source: AutomationEventSource; + + /** The trigger_type value stored in D1. */ + triggerType: AutomationTriggerType; + + /** Human-readable name for the UI. */ + displayName: string; + + /** Short description shown in the trigger type selector. */ + description: string; + + /** Supported event types with UI metadata. */ + eventTypes: Array<{ + eventType: string; + displayName: string; + description: string; + }>; + + /** Whether the UI should expose an event type selector for this trigger source. */ + supportsEventTypes?: boolean; + + /** Optional UI placeholder for the event type selector for this trigger source. */ + eventTypePlaceholder?: string; + + /** Condition types this source supports (keys into ConditionConfigMap). */ + supportedConditions: ConditionType[]; +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts new file mode 100644 index 0000000..72ff846 --- /dev/null +++ b/packages/protocol/src/types.ts @@ -0,0 +1,779 @@ +// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/types/index.ts +// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN +/** + * Shared type definitions used across Open-Inspect packages. + */ + +// Session states +export type SessionStatus = + | "created" + | "active" + | "completed" + | "failed" + | "archived" + | "cancelled"; +export type SandboxStatus = + | "pending" + | "spawning" + | "connecting" + | "warming" + | "syncing" + | "ready" + | "running" + | "stale" + | "snapshotting" + | "stopped" + | "failed"; +export type GitSyncStatus = "pending" | "in_progress" | "completed" | "failed"; +export type MessageStatus = "pending" | "processing" | "completed" | "failed"; +export type MessageSource = "web" | "slack" | "linear" | "extension" | "github" | "automation"; +export type ArtifactType = "pr" | "screenshot" | "video" | "preview" | "branch"; +export type EventType = + | "heartbeat" + | "token" + | "tool_call" + | "step_start" + | "step_finish" + | "tool_result" + | "git_sync" + | "error" + | "execution_complete" + | "artifact" + | "push_complete" + | "push_error" + | "user_message"; +export type ParticipantRole = "owner" | "member"; +export type SpawnSource = + | "user" + | "agent" + | "automation" + | "github-bot" + | "linear-bot" + | "slack-bot"; +export type ConfidenceLevel = "high" | "medium" | "low"; + +// Participant in a session +export interface SessionParticipant { + id: string; + userId: string; + scmLogin: string | null; + scmName: string | null; + scmEmail: string | null; + role: ParticipantRole; +} + +// Session state +export interface Session { + id: string; + title: string | null; + repoOwner: string; + repoName: string; + baseBranch: string; + branchName: string | null; + baseSha: string | null; + currentSha: string | null; + opencodeSessionId: string | null; + status: SessionStatus; + parentSessionId: string | null; + spawnSource: SpawnSource; + spawnDepth: number; + createdAt: number; + updatedAt: number; +} + +// Message in a session +export interface SessionMessage { + id: string; + authorId: string; + content: string; + source: MessageSource; + attachments: Attachment[] | null; + status: MessageStatus; + createdAt: number; + startedAt: number | null; + completedAt: number | null; +} + +// Attachment to a message +export interface Attachment { + type: "file" | "image" | "url"; + name: string; + url?: string; + content?: string; + mimeType?: string; +} + +// Agent event +export interface AgentEvent { + id: string; + type: EventType; + data: Record; + messageId: string | null; + createdAt: number; +} + +// Artifact created by session +export interface SessionArtifact { + id: string; + type: ArtifactType; + url: string | null; + metadata: Record | null; + createdAt: number; +} + +/** + * Metadata stored on branch artifacts when PR creation falls back to manual flow. + */ +export interface ManualPullRequestArtifactMetadata { + mode: "manual_pr"; + head: string; + base: string; + createPrUrl: string; + provider?: string; +} + +/** Metadata stored on screenshot artifacts. */ +export interface ScreenshotArtifactMetadata { + /** R2 object key */ + objectKey: string; + /** MIME type: image/png, image/jpeg, image/webp */ + mimeType: "image/png" | "image/jpeg" | "image/webp"; + /** File size in bytes */ + sizeBytes: number; + /** Viewport dimensions at capture time */ + viewport?: { width: number; height: number }; + /** URL that was screenshotted */ + sourceUrl?: string; + /** Whether this is a full-page screenshot */ + fullPage?: boolean; + /** Whether element annotations are overlaid */ + annotated?: boolean; + /** Caption or description provided by the agent */ + caption?: string; +} + +/** Metadata stored on video recording artifacts. */ +export interface VideoArtifactMetadata { + /** R2 object key */ + objectKey: string; + /** MIME type for saved recordings. */ + mimeType: "video/mp4"; + /** File size in bytes */ + sizeBytes: number; + /** Agent-provided title or description of the validation recording */ + caption: string; + /** Recording duration in milliseconds */ + durationMs: number; + /** Artifact creation time as epoch milliseconds */ + createdAt: number; + /** Recording start time as epoch milliseconds */ + recordingStartedAt: number; + /** Recording end time as epoch milliseconds */ + recordingEndedAt: number; + /** Captured viewport dimensions */ + dimensions: { width: number; height: number }; + /** Whether recording stopped at the maximum duration */ + truncated: boolean; + /** Recordings must not include audio */ + hasAudio?: false; + /** Captured surface for v1 */ + captureSurface?: "browser"; + /** Artifact source */ + source?: "agent"; + /** URL at recording start */ + sourceUrl?: string; + /** URL when recording stopped */ + endUrl?: string; +} + +// Pull request info +export interface PullRequest { + number: number; + title: string; + body: string; + url: string; + state: "open" | "closed" | "merged" | "draft"; + headRef: string; + baseRef: string; + createdAt: string; + updatedAt: string; +} + +// Sandbox events (from Modal / control-plane synthesized) +export type SandboxEvent = + | { type: "heartbeat"; sandboxId: string; status: string; timestamp: number } + | { + type: "token"; + content: string; + messageId: string; + sandboxId: string; + timestamp: number; + } + | { + type: "tool_call"; + tool: string; + args: Record; + callId: string; + status?: string; + output?: string; + messageId: string; + sandboxId: string; + timestamp: number; + } + | { + type: "step_start"; + messageId: string; + sandboxId: string; + timestamp: number; + isSubtask?: boolean; + } + | { + type: "step_finish"; + cost?: number; + tokens?: number; + reason?: string; + messageId: string; + sandboxId: string; + timestamp: number; + isSubtask?: boolean; + } + | { + type: "tool_result"; + callId: string; + result: string; + error?: string; + messageId: string; + sandboxId: string; + timestamp: number; + } + | { + type: "git_sync"; + status: GitSyncStatus; + sha?: string; + sandboxId: string; + timestamp: number; + } + | { + type: "error"; + error: string; + messageId: string; + sandboxId: string; + timestamp: number; + } + | { + type: "execution_complete"; + messageId: string; + success: boolean; + error?: string; + sandboxId: string; + timestamp: number; + } + | { + type: "artifact"; + artifactType: string; + artifactId?: string; + url: string; + metadata?: Record; + messageId?: string; + sandboxId: string; + timestamp: number; + } + | { + type: "push_complete"; + branchName: string; + sandboxId?: string; + timestamp: number; + } + | { + type: "push_error"; + branchName: string; + error: string; + sandboxId?: string; + timestamp: number; + } + | { + type: "user_message"; + content: string; + messageId: string; + timestamp: number; + author?: { + participantId: string; + name: string; + avatar?: string; + }; + }; + +// WebSocket message types +export type ClientMessage = + | { type: "ping" } + | { type: "subscribe"; token: string; clientId: string } + | { + type: "prompt"; + content: string; + model?: string; + reasoningEffort?: string; + attachments?: Attachment[]; + } + | { type: "stop" } + | { type: "typing" } + | { type: "presence"; status: "active" | "idle"; cursor?: { line: number; file: string } } + | { type: "fetch_history"; cursor: { timestamp: number; id: string }; limit?: number }; + +export type ServerMessage = + | { type: "pong"; timestamp: number } + | { + type: "subscribed"; + sessionId: string; + state: SessionState; + artifacts: SessionArtifact[]; + participantId: string; + participant?: { participantId: string; name: string; avatar?: string }; + replay?: { + events: SandboxEvent[]; + hasMore: boolean; + cursor: { timestamp: number; id: string } | null; + }; + spawnError?: string | null; + } + | { type: "prompt_queued"; messageId: string; position: number } + | { type: "sandbox_event"; event: SandboxEvent } + | { type: "presence_sync"; participants: ParticipantPresence[] } + | { type: "presence_update"; participants: ParticipantPresence[] } + | { type: "presence_leave"; userId: string } + | { type: "sandbox_warming" } + | { type: "sandbox_spawning" } + | { type: "sandbox_status"; status: SandboxStatus } + | { type: "sandbox_ready" } + | { type: "sandbox_error"; error: string } + | { type: "artifact_created"; artifact: SessionArtifact } + | { type: "session_branch"; branchName: string } + | { type: "snapshot_saved"; imageId: string; reason: string } + | { type: "sandbox_restored"; message: string } + | { type: "sandbox_warning"; message: string } + | { type: "processing_status"; isProcessing: boolean } + | { + type: "history_page"; + items: SandboxEvent[]; + hasMore: boolean; + cursor: { timestamp: number; id: string } | null; + } + | { type: "session_status"; status: SessionStatus } + | { type: "session_title"; title: string } + | { + type: "child_session_update"; + childSessionId: string; + status: SessionStatus; + title: string | null; + } + | { type: "code_server_info"; url: string; password: string } + | { type: "ttyd_info"; url: string; token: string } + | { type: "tunnel_urls"; urls: Record } + | { type: "error"; code: string; message: string }; + +// Session state sent to clients +export interface SessionState { + id: string; + title: string | null; + repoOwner: string; + repoName: string; + baseBranch: string; + branchName: string | null; + status: SessionStatus; + sandboxStatus: SandboxStatus; + messageCount: number; + createdAt: number; + model?: string; + reasoningEffort?: string; + isProcessing?: boolean; + parentSessionId?: string | null; + totalCost?: number; + codeServerUrl?: string | null; + codeServerPassword?: string | null; + tunnelUrls?: Record | null; + ttydUrl?: string | null; + ttydToken?: string | null; +} + +// Participant presence info +export interface ParticipantPresence { + participantId: string; + userId: string; + name: string; + avatar?: string; + status: "active" | "idle" | "away"; + lastSeen: number; +} + +// Repository types for GitHub App installation +export interface InstallationRepository { + id: number; + owner: string; + name: string; + fullName: string; + description: string | null; + private: boolean; + defaultBranch: string; + language?: string | null; + topics?: string[]; +} + +export interface RepoMetadata { + description?: string; + aliases?: string[]; + channelAssociations?: string[]; + keywords?: string[]; +} + +export interface EnrichedRepository extends InstallationRepository { + metadata?: RepoMetadata; +} + +// Bot package shared types +export interface RepoConfig { + id: string; + owner: string; + name: string; + fullName: string; + displayName: string; + description: string; + defaultBranch: string; + private: boolean; + language?: string | null; + topics?: string[]; + aliases?: string[]; + keywords?: string[]; + channelAssociations?: string[]; +} + +export type ControlPlaneRepo = EnrichedRepository; + +export interface ControlPlaneReposResponse { + repos: ControlPlaneRepo[]; + cached: boolean; + cachedAt: string; +} + +export interface ClassificationResult { + repo: RepoConfig | null; + confidence: ConfidenceLevel; + reasoning: string; + alternatives?: RepoConfig[]; + needsClarification: boolean; +} + +export interface EventResponse { + id: string; + type: EventType; + data: Record; + messageId: string | null; + createdAt: number; +} + +export interface ListEventsResponse { + events: EventResponse[]; + cursor?: string; + hasMore: boolean; +} + +export interface ArtifactResponse { + id: string; + type: ArtifactType; + url: string | null; + metadata: Record | null; + createdAt: number; +} + +export interface ListArtifactsResponse { + artifacts: ArtifactResponse[]; +} + +export interface ToolCallSummary { + tool: string; + summary: string; +} + +export interface ArtifactInfo { + type: ArtifactType; + url: string; + label: string; + metadata?: Record | null; +} + +export interface AgentResponse { + textContent: string; + toolCalls: ToolCallSummary[]; + artifacts: ArtifactInfo[]; + success: boolean; + error?: string; +} + +export interface UserPreferences { + userId: string; + model: string; + reasoningEffort?: string; + branch?: string; + updatedAt: number; +} + +export interface Logger { + debug(msg: string, data?: Record): void; + info(msg: string, data?: Record): void; + warn(msg: string, data?: Record): void; + error(msg: string, data?: Record): void; + child(context: Record): Logger; +} + +// ─── Callback Context (discriminated union) ────────────────────────────────── + +export interface SlackCallbackContext { + source: "slack"; + channel: string; + threadTs: string; + repoFullName: string; + model: string; + reasoningEffort?: string; + reactionMessageTs?: string; +} + +export interface LinearCallbackContext { + source: "linear"; + issueId: string; + issueIdentifier: string; + issueUrl: string; + repoFullName: string; + model: string; + agentSessionId?: string; + organizationId?: string; + emitToolProgressActivities?: boolean; +} + +export interface AutomationCallbackContext { + source: "automation"; + automationId: string; + runId: string; + automationName: string; +} + +export type CallbackContext = + | SlackCallbackContext + | LinearCallbackContext + | AutomationCallbackContext; + +// API response types +export interface CreateSessionRequest { + repoOwner: string; + repoName: string; + title?: string; + model?: string; + reasoningEffort?: string; + branch?: string; +} + +export interface CreateSessionResponse { + sessionId: string; + status: SessionStatus; +} + +export interface ListSessionsResponse { + sessions: Session[]; + cursor?: string; + hasMore: boolean; +} + +// --- Agent-spawned sub-sessions --- + +/** Request body for POST /sessions/:parentId/children */ +export interface SpawnChildSessionRequest { + title: string; + prompt: string; + repoOwner?: string; + repoName?: string; + model?: string; + reasoningEffort?: string; +} + +/** Returned by parent DO's GET /internal/spawn-context */ +export interface SpawnContext { + repoOwner: string; + repoName: string; + repoId: number | null; + model: string; + reasoningEffort: string | null; + baseBranch: string | null; + owner: { + userId: string; + scmUserId: string | null; + scmLogin: string | null; + scmName: string | null; + scmEmail: string | null; + scmAccessTokenEncrypted: string | null; + scmRefreshTokenEncrypted: string | null; + scmTokenExpiresAt: number | null; + }; +} + +/** Returned by child DO's GET /internal/child-summary */ +export interface ChildSessionDetail { + session: { + id: string; + title: string; + status: SessionStatus; + repoOwner: string; + repoName: string; + branchName: string | null; + model: string; + createdAt: number; + updatedAt: number; + }; + sandbox: { status: SandboxStatus } | null; + artifacts: Array<{ type: string; url: string; metadata: unknown }>; + recentEvents: Array<{ type: string; data: unknown; createdAt: number }>; +} + +// ─── Analytics ─────────────────────────────────────────────────────────────── + +export const ANALYTICS_DAYS = [7, 14, 30, 90] as const; +export type AnalyticsDays = (typeof ANALYTICS_DAYS)[number]; + +export const ANALYTICS_BREAKDOWN_BY = ["user", "repo"] as const; +export type AnalyticsBreakdownBy = (typeof ANALYTICS_BREAKDOWN_BY)[number]; + +export interface AnalyticsStatusBreakdown { + created: number; + active: number; + completed: number; + failed: number; + archived: number; + cancelled: number; +} + +export interface AnalyticsSummaryResponse { + totalSessions: number; + activeUsers: number; + totalCost: number; + avgCost: number; + totalPrs: number; + statusBreakdown: AnalyticsStatusBreakdown; +} + +export interface AnalyticsTimeseriesPoint { + date: string; + groups: Record; +} + +export interface AnalyticsTimeseriesResponse { + series: AnalyticsTimeseriesPoint[]; +} + +export interface AnalyticsBreakdownEntry { + key: string; + displayName?: string; + sessions: number; + completed: number; + failed: number; + cancelled: number; + cost: number; + prs: number; + messageCount: number; + avgDuration: number; + lastActive: number; +} + +export interface AnalyticsBreakdownResponse { + entries: AnalyticsBreakdownEntry[]; +} + +// ─── Automation Engine ──────────────────────────────────────────────────────── + +export type AutomationTriggerType = + | "schedule" + | "github_event" + | "linear_event" + | "sentry" + | "webhook"; + +export type AutomationRunStatus = "starting" | "running" | "completed" | "failed" | "skipped"; + +// Re-export TriggerConfig for use in automation interfaces below +import type { TriggerConfig } from "./triggers-conditions"; + +export interface Automation { + id: string; + name: string; + repoOwner: string; + repoName: string; + baseBranch: string; + repoId: number | null; + instructions: string; + triggerType: AutomationTriggerType; + scheduleCron: string | null; + scheduleTz: string; + model: string; + reasoningEffort: string | null; + enabled: boolean; + nextRunAt: number | null; + consecutiveFailures: number; + createdBy: string; + createdAt: number; + updatedAt: number; + deletedAt: number | null; + eventType: string | null; + triggerConfig: TriggerConfig | null; +} + +export interface CreateAutomationRequest { + name: string; + repoOwner: string; + repoName: string; + baseBranch?: string; + instructions: string; + triggerType?: AutomationTriggerType; + scheduleCron?: string; + scheduleTz?: string; + model?: string; + reasoningEffort?: string | null; + eventType?: string; + triggerConfig?: TriggerConfig; + sentryClientSecret?: string; +} + +export interface UpdateAutomationRequest { + name?: string; + instructions?: string; + scheduleCron?: string; + scheduleTz?: string; + model?: string; + reasoningEffort?: string | null; + baseBranch?: string; + eventType?: string; + triggerConfig?: TriggerConfig; +} + +export interface AutomationRun { + id: string; + automationId: string; + sessionId: string | null; + status: AutomationRunStatus; + skipReason: string | null; + failureReason: string | null; + scheduledAt: number; + startedAt: number | null; + completedAt: number | null; + createdAt: number; + sessionTitle: string | null; + artifactSummary: string | null; + triggerKey: string | null; + concurrencyKey: string | null; +} + +export interface ListAutomationsResponse { + automations: Automation[]; + total: number; +} + +export interface ListAutomationRunsResponse { + runs: AutomationRun[]; + total: number; +} + +export * from "./integrations"; diff --git a/scripts/vendor-protocol.sh b/scripts/vendor-protocol.sh new file mode 100644 index 0000000..4ae543d --- /dev/null +++ b/scripts/vendor-protocol.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Reproducible vendor of the pinned Open-Inspect protocol subset (PLAN-04). +# Copies ONLY the pure, Hermes-safe type/model closure consumed by the apps. +# Usage: scripts/vendor-protocol.sh [upstream-dir] +set -euo pipefail + +SHA="${1:?usage: scripts/vendor-protocol.sh [upstream-dir]}" +UP="${2:-.upstream/background-agents}" +REPO="https://github.com/ColeMurray/background-agents" +DST="packages/protocol/src" +PINF="packages/protocol/PIN" + +# --- ensure upstream clone is present and at the requested commit --- +if [ ! -d "$UP/.git" ]; then + git clone "$REPO" "$UP" +fi +if ! git -C "$UP" cat-file -e "${SHA}^{commit}" 2>/dev/null; then + git -C "$UP" fetch --unshallow origin 2>/dev/null || git -C "$UP" fetch origin +fi +git -C "$UP" checkout -q "$SHA" +FULL="$(git -C "$UP" rev-parse HEAD)" +S="$UP/packages/shared/src" + +mkdir -p "$DST" +rm -f "$DST"/*.ts + +# --- copy with a VENDORED provenance header (exact upstream bytes preserved) --- +hdr() { # $1 = dest file, $2 = upstream relpath under packages/shared/src + { + printf '// VENDORED from background-agents@%s :: packages/shared/src/%s\n' "$FULL" "$2" + printf '// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN\n' + cat "$S/$2" + } > "$1" +} +hdr "$DST/types.ts" "types/index.ts" +hdr "$DST/integrations.ts" "types/integrations.ts" +hdr "$DST/triggers-conditions.ts" "triggers/conditions.ts" +hdr "$DST/triggers-types.ts" "triggers/types.ts" +hdr "$DST/models.ts" "models.ts" +hdr "$DST/git.ts" "git.ts" + +# --- flatten the types/ and triggers/ subdirs into a flat src/ (rewrite imports) --- +sed -i.bak 's#from "\.\./triggers/conditions"#from "./triggers-conditions"#' "$DST/types.ts" +sed -i.bak 's#from "\./types"#from "./triggers-types"#' "$DST/triggers-conditions.ts" +sed -i.bak -e 's#from "\.\./types"#from "./types"#' \ + -e 's#from "\./conditions"#from "./triggers-conditions"#' "$DST/triggers-types.ts" +rm -f "$DST"/*.bak + +# --- generated barrel (composition, not vendored verbatim) --- +{ + printf '// GENERATED by scripts/vendor-protocol.sh — do not edit. Pinned: packages/protocol/PIN\n' + printf 'export * from "./types";\n' # also re-exports ./integrations via its own export * + printf 'export * from "./models";\n' + printf 'export * from "./git";\n' +} > "$DST/index.ts" + +# --- structured PIN: ref + deployment URL (URL preserved once a real one is set) --- +URL="pending-deploy" +if [ -f "$PINF" ]; then + EX="$(sed -n 's/^url: //p' "$PINF" 2>/dev/null || true)" + if [ -n "${EX:-}" ] && [ "$EX" != "pending-deploy" ]; then URL="$EX"; fi +fi +printf 'ref: background-agents@%s\nurl: %s\n' "$FULL" "$URL" > "$PINF" + +echo "vendored background-agents@$FULL" +ls -1 "$DST" From 2039ce1874a415acd537e3e3bcbd1206cf11ba84 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 17 May 2026 07:43:31 +0200 Subject: [PATCH 03/19] =?UTF-8?q?feat(mobile-ui):=20Phase=200=20foundation?= =?UTF-8?q?=20=E2=80=94=20ui=20wrapper,=20mock=20gateway=20seam,=20nav=20s?= =?UTF-8?q?hell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach-A seam: SessionGateway + MockSessionGateway + TanStack Query hooks; pure stream transforms (collapseTokenEvents/foldEvent) ported @ a7b968f. @expo/ui wrapper with Expo-Go-safe RN primitives. Expo Router shell + runnable placeholder screens. tsc clean; Metro/Hermes iOS bundle succeeds (punycode shim for markdown-it). Removed Expo template starter components. --- apps/mobile/metro.config.js | 6 + apps/mobile/package.json | 2 + apps/mobile/src/app/_layout.tsx | 38 ++- apps/mobile/src/app/explore.tsx | 181 ------------- apps/mobile/src/app/index.tsx | 99 +------ apps/mobile/src/app/new.tsx | 1 + apps/mobile/src/app/s/[id].tsx | 1 + apps/mobile/src/app/settings.tsx | 1 + apps/mobile/src/app/sign-in.tsx | 1 + .../src/components/animated-icon.module.css | 6 - apps/mobile/src/components/animated-icon.tsx | 132 --------- .../src/components/animated-icon.web.tsx | 108 -------- apps/mobile/src/components/app-tabs.tsx | 33 --- apps/mobile/src/components/app-tabs.web.tsx | 116 -------- apps/mobile/src/components/external-link.tsx | 25 -- apps/mobile/src/components/hint-row.tsx | 35 --- apps/mobile/src/components/themed-text.tsx | 73 ----- apps/mobile/src/components/themed-view.tsx | 16 -- apps/mobile/src/components/ui/collapsible.tsx | 65 ----- apps/mobile/src/components/web-badge.tsx | 44 --- apps/mobile/src/data/gateway.ts | 45 +++ apps/mobile/src/data/mock/emitter.ts | 41 +++ apps/mobile/src/data/mock/fixtures.ts | 103 +++++++ apps/mobile/src/data/mock/mock-gateway.ts | 70 +++++ apps/mobile/src/data/provider.tsx | 31 +++ apps/mobile/src/data/queries.ts | 71 +++++ apps/mobile/src/features/auth/screen.tsx | 28 ++ apps/mobile/src/features/profiles/screen.tsx | 34 +++ .../src/features/sessions/create/screen.tsx | 37 +++ .../src/features/sessions/detail/screen.tsx | 74 +++++ .../src/features/sessions/list/screen.tsx | 57 ++++ .../features/sessions/stream/transforms.ts | 90 ++++++ apps/mobile/src/hooks/use-color-scheme.ts | 1 - apps/mobile/src/hooks/use-color-scheme.web.ts | 21 -- apps/mobile/src/hooks/use-theme.ts | 14 - apps/mobile/src/ui/index.tsx | 256 ++++++++++++++++++ apps/mobile/tsconfig.json | 3 + pnpm-lock.yaml | 12 + 38 files changed, 992 insertions(+), 979 deletions(-) delete mode 100644 apps/mobile/src/app/explore.tsx create mode 100644 apps/mobile/src/app/new.tsx create mode 100644 apps/mobile/src/app/s/[id].tsx create mode 100644 apps/mobile/src/app/settings.tsx create mode 100644 apps/mobile/src/app/sign-in.tsx delete mode 100644 apps/mobile/src/components/animated-icon.module.css delete mode 100644 apps/mobile/src/components/animated-icon.tsx delete mode 100644 apps/mobile/src/components/animated-icon.web.tsx delete mode 100644 apps/mobile/src/components/app-tabs.tsx delete mode 100644 apps/mobile/src/components/app-tabs.web.tsx delete mode 100644 apps/mobile/src/components/external-link.tsx delete mode 100644 apps/mobile/src/components/hint-row.tsx delete mode 100644 apps/mobile/src/components/themed-text.tsx delete mode 100644 apps/mobile/src/components/themed-view.tsx delete mode 100644 apps/mobile/src/components/ui/collapsible.tsx delete mode 100644 apps/mobile/src/components/web-badge.tsx create mode 100644 apps/mobile/src/data/gateway.ts create mode 100644 apps/mobile/src/data/mock/emitter.ts create mode 100644 apps/mobile/src/data/mock/fixtures.ts create mode 100644 apps/mobile/src/data/mock/mock-gateway.ts create mode 100644 apps/mobile/src/data/provider.tsx create mode 100644 apps/mobile/src/data/queries.ts create mode 100644 apps/mobile/src/features/auth/screen.tsx create mode 100644 apps/mobile/src/features/profiles/screen.tsx create mode 100644 apps/mobile/src/features/sessions/create/screen.tsx create mode 100644 apps/mobile/src/features/sessions/detail/screen.tsx create mode 100644 apps/mobile/src/features/sessions/list/screen.tsx create mode 100644 apps/mobile/src/features/sessions/stream/transforms.ts delete mode 100644 apps/mobile/src/hooks/use-color-scheme.ts delete mode 100644 apps/mobile/src/hooks/use-color-scheme.web.ts delete mode 100644 apps/mobile/src/hooks/use-theme.ts create mode 100644 apps/mobile/src/ui/index.tsx diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 73cd076..ce15145 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -9,4 +9,10 @@ config.resolver.nodeModulesPaths = [ path.resolve(workspaceRoot, "node_modules"), ]; config.resolver.disableHierarchicalLookup = true; +// markdown-it (via react-native-markdown-display) requires Node's "punycode"; +// alias it to the userland package so Metro can resolve it in RN/Hermes. +config.resolver.extraNodeModules = { + ...config.resolver.extraNodeModules, + punycode: require.resolve("punycode/"), +}; module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a41ac7b..01bce00 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,7 @@ "lint": "expo lint" }, "dependencies": { + "@constructor/protocol": "workspace:*", "@expo/ui": "~55.0.16", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", @@ -34,6 +35,7 @@ "expo-symbols": "~55.0.8", "expo-system-ui": "~55.0.18", "expo-web-browser": "~55.0.16", + "punycode": "^2.3.1", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.6", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index b04d0a8..af268ca 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,16 +1,32 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import React from 'react'; -import { useColorScheme } from 'react-native'; +import { Stack } from 'expo-router'; -import { AnimatedSplashOverlay } from '@/components/animated-icon'; -import AppTabs from '@/components/app-tabs'; +import { AppProviders } from '@/data/provider'; +import { useThemeColors } from '@/ui'; -export default function TabLayout() { - const colorScheme = useColorScheme(); +function StackNav() { + const c = useThemeColors(); return ( - - - - + + + + + + + + ); +} + +export default function RootLayout() { + return ( + + + ); } diff --git a/apps/mobile/src/app/explore.tsx b/apps/mobile/src/app/explore.tsx deleted file mode 100644 index f08c5d3..0000000 --- a/apps/mobile/src/app/explore.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { Image } from 'expo-image'; -import { SymbolView } from 'expo-symbols'; -import React from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { ExternalLink } from '@/components/external-link'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export default function TabTwoScreen() { - const safeAreaInsets = useSafeAreaInsets(); - const insets = { - ...safeAreaInsets, - bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, - }; - const theme = useTheme(); - - const contentPlatformStyle = Platform.select({ - android: { - paddingTop: insets.top, - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: insets.bottom, - }, - web: { - paddingTop: Spacing.six, - paddingBottom: Spacing.four, - }, - }); - - return ( - - - - Explore - - This starter app includes example{'\n'}code to help you get started. - - - - pressed && styles.pressed}> - - Expo documentation - - - - - - - - - - This app has two screens: src/app/index.tsx and{' '} - src/app/explore.tsx - - - The layout file in src/app/_layout.tsx sets up - the tab navigator. - - - Learn more - - - - - - - You can open this project on Android, iOS, and the web. To open the web version, - press w in the terminal running this - project. - - - - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different - screen densities. - - - - Learn more - - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the - user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - - This template includes an example of an animated component. The{' '} - src/components/ui/collapsible.tsx component uses - the powerful react-native-reanimated library to - animate opening this hint. - - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - scrollView: { - flex: 1, - }, - contentContainer: { - flexDirection: 'row', - justifyContent: 'center', - }, - container: { - maxWidth: MaxContentWidth, - flexGrow: 1, - }, - titleContainer: { - gap: Spacing.three, - alignItems: 'center', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.six, - }, - centerText: { - textAlign: 'center', - }, - pressed: { - opacity: 0.7, - }, - linkButton: { - flexDirection: 'row', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - borderRadius: Spacing.five, - justifyContent: 'center', - gap: Spacing.one, - alignItems: 'center', - }, - sectionsWrapper: { - gap: Spacing.five, - paddingHorizontal: Spacing.four, - paddingTop: Spacing.three, - }, - collapsibleContent: { - alignItems: 'center', - }, - imageTutorial: { - width: '100%', - aspectRatio: 296 / 171, - borderRadius: Spacing.three, - marginTop: Spacing.two, - }, - imageReact: { - width: 100, - height: 100, - alignSelf: 'center', - }, -}); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 8ec3e6f..7631b4e 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,98 +1 @@ -import * as Device from 'expo-device'; -import { Platform, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { AnimatedIcon } from '@/components/animated-icon'; -import { HintRow } from '@/components/hint-row'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; - -function getDevMenuHint() { - if (Platform.OS === 'web') { - return use browser devtools; - } - if (Device.isDevice) { - return ( - - shake device or press m in terminal - - ); - } - const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d'; - return ( - - press {shortcut} - - ); -} - -export default function HomeScreen() { - return ( - - - - - - Welcome to Expo - - - - - get started - - - - src/app/index.tsx} - /> - - npm run reset-project} - /> - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - flexDirection: 'row', - }, - safeArea: { - flex: 1, - paddingHorizontal: Spacing.four, - alignItems: 'center', - gap: Spacing.three, - paddingBottom: BottomTabInset + Spacing.three, - maxWidth: MaxContentWidth, - }, - heroSection: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - paddingHorizontal: Spacing.four, - gap: Spacing.four, - }, - title: { - textAlign: 'center', - }, - code: { - textTransform: 'uppercase', - }, - stepContainer: { - gap: Spacing.three, - alignSelf: 'stretch', - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.four, - borderRadius: Spacing.four, - }, -}); +export { SessionListScreen as default } from '@/features/sessions/list/screen'; diff --git a/apps/mobile/src/app/new.tsx b/apps/mobile/src/app/new.tsx new file mode 100644 index 0000000..373848a --- /dev/null +++ b/apps/mobile/src/app/new.tsx @@ -0,0 +1 @@ +export { CreateSessionScreen as default } from '@/features/sessions/create/screen'; diff --git a/apps/mobile/src/app/s/[id].tsx b/apps/mobile/src/app/s/[id].tsx new file mode 100644 index 0000000..9a196af --- /dev/null +++ b/apps/mobile/src/app/s/[id].tsx @@ -0,0 +1 @@ +export { SessionDetailScreen as default } from '@/features/sessions/detail/screen'; diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx new file mode 100644 index 0000000..9f4d93a --- /dev/null +++ b/apps/mobile/src/app/settings.tsx @@ -0,0 +1 @@ +export { SettingsScreen as default } from '@/features/profiles/screen'; diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx new file mode 100644 index 0000000..82f6e86 --- /dev/null +++ b/apps/mobile/src/app/sign-in.tsx @@ -0,0 +1 @@ +export { SignInScreen as default } from '@/features/auth/screen'; diff --git a/apps/mobile/src/components/animated-icon.module.css b/apps/mobile/src/components/animated-icon.module.css deleted file mode 100644 index f8156fe..0000000 --- a/apps/mobile/src/components/animated-icon.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.expoLogoBackground { - background-image: linear-gradient(180deg, #3c9ffe, #0274df); - border-radius: 40px; - width: 128px; - height: 128px; -} diff --git a/apps/mobile/src/components/animated-icon.tsx b/apps/mobile/src/components/animated-icon.tsx deleted file mode 100644 index 91a480f..0000000 --- a/apps/mobile/src/components/animated-icon.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Image } from 'expo-image'; -import { useState } from 'react'; -import { Dimensions, StyleSheet, View } from 'react-native'; -import Animated, { Easing, Keyframe } from 'react-native-reanimated'; -import { scheduleOnRN } from 'react-native-worklets'; - -const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; -const DURATION = 600; - -export function AnimatedSplashOverlay() { - const [visible, setVisible] = useState(true); - - if (!visible) return null; - - const splashKeyframe = new Keyframe({ - 0: { - transform: [{ scale: INITIAL_SCALE_FACTOR }], - opacity: 1, - }, - 20: { - opacity: 1, - }, - 70: { - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 0, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, - }); - - return ( - { - 'worklet'; - if (finished) { - scheduleOnRN(setVisible, false); - } - })} - style={styles.backgroundSolidColor} - /> - ); -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: INITIAL_SCALE_FACTOR }], - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - transform: [{ scale: 1.3 }], - opacity: 0, - }, - 40: { - transform: [{ scale: 1.3 }], - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 1, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '0deg' }], - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - zIndex: 100, - }, - image: { - position: 'absolute', - width: 76, - height: 71, - }, - background: { - borderRadius: 40, - experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`, - width: 128, - height: 128, - position: 'absolute', - }, - backgroundSolidColor: { - ...StyleSheet.absoluteFillObject, - backgroundColor: '#208AEF', - zIndex: 1000, - }, -}); diff --git a/apps/mobile/src/components/animated-icon.web.tsx b/apps/mobile/src/components/animated-icon.web.tsx deleted file mode 100644 index dfbb1fd..0000000 --- a/apps/mobile/src/components/animated-icon.web.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Image } from 'expo-image'; -import { StyleSheet, View } from 'react-native'; -import Animated, { Keyframe, Easing } from 'react-native-reanimated'; - -import classes from './animated-icon.module.css'; -const DURATION = 300; - -export function AnimatedSplashOverlay() { - return null; -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: 0 }], - }, - 60: { - transform: [{ scale: 1.2 }], - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(1.2), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - opacity: 0, - }, - 60: { - transform: [{ scale: 1.2 }], - opacity: 0, - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - opacity: 1, - easing: Easing.elastic(1.2), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }], - opacity: 0, - }, - [DURATION / 1000]: { - transform: [{ rotateZ: '0deg' }, { scale: 1 }], - opacity: 1, - easing: Easing.elastic(0.7), - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - -
- - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - width: '100%', - zIndex: 1000, - position: 'absolute', - top: 128 / 2 + 138, - }, - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - }, - image: { - position: 'absolute', - width: 76, - height: 71, - }, - background: { - width: 128, - height: 128, - position: 'absolute', - }, -}); diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx deleted file mode 100644 index 0e1bc23..0000000 --- a/apps/mobile/src/components/app-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import React from 'react'; -import { useColorScheme } from 'react-native'; - -import { Colors } from '@/constants/theme'; - -export default function AppTabs() { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - Home - - - - - Explore - - - - ); -} diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx deleted file mode 100644 index 6542e46..0000000 --- a/apps/mobile/src/components/app-tabs.web.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { - Tabs, - TabList, - TabTrigger, - TabSlot, - TabTriggerSlotProps, - TabListProps, -} from 'expo-router/ui'; -import { SymbolView } from 'expo-symbols'; -import React from 'react'; -import { Pressable, useColorScheme, View, StyleSheet } from 'react-native'; - -import { ExternalLink } from './external-link'; -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Colors, MaxContentWidth, Spacing } from '@/constants/theme'; - -export default function AppTabs() { - return ( - - - - - - Home - - - Explore - - - - - ); -} - -export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) { - return ( - pressed && styles.pressed}> - - - {children} - - - - ); -} - -export function CustomTabList(props: TabListProps) { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - - Expo Starter - - - {props.children} - - - - Docs - - - - - - ); -} - -const styles = StyleSheet.create({ - tabListContainer: { - position: 'absolute', - width: '100%', - padding: Spacing.three, - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', - }, - innerContainer: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.five, - borderRadius: Spacing.five, - flexDirection: 'row', - alignItems: 'center', - flexGrow: 1, - gap: Spacing.two, - maxWidth: MaxContentWidth, - }, - brandText: { - marginRight: 'auto', - }, - pressed: { - opacity: 0.7, - }, - tabButtonView: { - paddingVertical: Spacing.one, - paddingHorizontal: Spacing.three, - borderRadius: Spacing.three, - }, - externalPressable: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: Spacing.one, - marginLeft: Spacing.three, - }, -}); diff --git a/apps/mobile/src/components/external-link.tsx b/apps/mobile/src/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/apps/mobile/src/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/apps/mobile/src/components/hint-row.tsx b/apps/mobile/src/components/hint-row.tsx deleted file mode 100644 index a66062b..0000000 --- a/apps/mobile/src/components/hint-row.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { type ReactNode } from 'react'; -import { View, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -type HintRowProps = { - title?: string; - hint?: ReactNode; -}; - -export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) { - return ( - - {title} - - {hint} - - - ); -} - -const styles = StyleSheet.create({ - stepRow: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - codeSnippet: { - borderRadius: Spacing.two, - paddingVertical: Spacing.half, - paddingHorizontal: Spacing.two, - }, -}); diff --git a/apps/mobile/src/components/themed-text.tsx b/apps/mobile/src/components/themed-text.tsx deleted file mode 100644 index 799c8b1..0000000 --- a/apps/mobile/src/components/themed-text.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Platform, StyleSheet, Text, type TextProps } from 'react-native'; - -import { Fonts, ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedTextProps = TextProps & { - type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code'; - themeColor?: ThemeColor; -}; - -export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) { - const theme = useTheme(); - - return ( - - ); -} - -const styles = StyleSheet.create({ - small: { - fontSize: 14, - lineHeight: 20, - fontWeight: 500, - }, - smallBold: { - fontSize: 14, - lineHeight: 20, - fontWeight: 700, - }, - default: { - fontSize: 16, - lineHeight: 24, - fontWeight: 500, - }, - title: { - fontSize: 48, - fontWeight: 600, - lineHeight: 52, - }, - subtitle: { - fontSize: 32, - lineHeight: 44, - fontWeight: 600, - }, - link: { - lineHeight: 30, - fontSize: 14, - }, - linkPrimary: { - lineHeight: 30, - fontSize: 14, - color: '#3c87f7', - }, - code: { - fontFamily: Fonts.mono, - fontWeight: Platform.select({ android: 700 }) ?? 500, - fontSize: 12, - }, -}); diff --git a/apps/mobile/src/components/themed-view.tsx b/apps/mobile/src/components/themed-view.tsx deleted file mode 100644 index c710df9..0000000 --- a/apps/mobile/src/components/themed-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; - type?: ThemeColor; -}; - -export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) { - const theme = useTheme(); - - return ; -} diff --git a/apps/mobile/src/components/ui/collapsible.tsx b/apps/mobile/src/components/ui/collapsible.tsx deleted file mode 100644 index d0d745b..0000000 --- a/apps/mobile/src/components/ui/collapsible.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { SymbolView } from 'expo-symbols'; -import { PropsWithChildren, useState } from 'react'; -import { Pressable, StyleSheet } from 'react-native'; -import Animated, { FadeIn } from 'react-native-reanimated'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useTheme(); - - return ( - - [styles.heading, pressed && styles.pressedHeading]} - onPress={() => setIsOpen((value) => !value)}> - - - - - {title} - - {isOpen && ( - - - {children} - - - )} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - }, - pressedHeading: { - opacity: 0.7, - }, - button: { - width: Spacing.four, - height: Spacing.four, - borderRadius: 12, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - marginTop: Spacing.three, - borderRadius: Spacing.three, - marginLeft: Spacing.four, - padding: Spacing.four, - }, -}); diff --git a/apps/mobile/src/components/web-badge.tsx b/apps/mobile/src/components/web-badge.tsx deleted file mode 100644 index 23933d2..0000000 --- a/apps/mobile/src/components/web-badge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { version } from 'expo/package.json'; -import { Image } from 'expo-image'; -import React from 'react'; -import { useColorScheme, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -export function WebBadge() { - const scheme = useColorScheme(); - - return ( - - - v{version} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - padding: Spacing.five, - alignItems: 'center', - gap: Spacing.two, - }, - versionText: { - textAlign: 'center', - }, - badgeImage: { - width: 123, - aspectRatio: 123 / 24, - }, -}); diff --git a/apps/mobile/src/data/gateway.ts b/apps/mobile/src/data/gateway.ts new file mode 100644 index 0000000..bab4df4 --- /dev/null +++ b/apps/mobile/src/data/gateway.ts @@ -0,0 +1,45 @@ +/** + * The single data seam (PLAN-02 / spec approach A). Screens depend ONLY on this + * interface (via the queries hooks), never on a concrete impl. MockSessionGateway + * implements it now; the real HTTP/WS gateway implements the same interface later + * with zero screen changes. Typed entirely against the vendored protocol. + */ +import type { + CreateSessionRequest, + SandboxEvent, + Session, + SessionArtifact, + SessionState, +} from '@constructor/protocol'; + +export type { CreateSessionRequest, SandboxEvent, Session, SessionArtifact, SessionState }; + +export interface SubscribeSnapshot { + state: SessionState; + artifacts: SessionArtifact[]; + replay: { + events: SandboxEvent[]; + hasMore: boolean; + cursor: { timestamp: number; id: string } | null; + }; +} + +export interface StreamListeners { + snapshot(s: SubscribeSnapshot): void; + event(e: SandboxEvent): void; + closed?(reason?: string): void; +} + +export interface StreamHandle { + unsubscribe(): void; +} + +export interface SessionGateway { + listSessions(): Promise; + getSession(id: string): Promise; + createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>; + /** Mirrors the real DO: a `snapshot` (state + replay) then a live event stream. */ + subscribe(id: string, on: StreamListeners): StreamHandle; + sendFollowUp(id: string, content: string): Promise; + stop(id: string): Promise; +} diff --git a/apps/mobile/src/data/mock/emitter.ts b/apps/mobile/src/data/mock/emitter.ts new file mode 100644 index 0000000..f7840ca --- /dev/null +++ b/apps/mobile/src/data/mock/emitter.ts @@ -0,0 +1,41 @@ +/** Replays a scripted SandboxEvent[] through stream listeners with realistic + * inter-event timing. Returns a cancel fn (clears pending timers). */ +import type { SandboxEvent } from '@constructor/protocol'; + +import type { StreamListeners, SubscribeSnapshot } from '../gateway'; + +export function startScriptedStream( + snapshot: SubscribeSnapshot, + script: SandboxEvent[], + on: StreamListeners, +): () => void { + let cancelled = false; + const timers: ReturnType[] = []; + + // snapshot first (mirrors `subscribed` frame), then stream after a beat. + const snapTimer = setTimeout(() => { + if (cancelled) return; + on.snapshot(snapshot); + let delay = 350; + script.forEach((evt) => { + delay += evt.type === 'token' ? 90 : 260; + timers.push( + setTimeout(() => { + if (cancelled) return; + on.event(evt); + }, delay), + ); + }); + timers.push( + setTimeout(() => { + if (!cancelled) on.closed?.('completed'); + }, delay + 200), + ); + }, 200); + timers.push(snapTimer); + + return () => { + cancelled = true; + timers.forEach(clearTimeout); + }; +} diff --git a/apps/mobile/src/data/mock/fixtures.ts b/apps/mobile/src/data/mock/fixtures.ts new file mode 100644 index 0000000..74cf583 --- /dev/null +++ b/apps/mobile/src/data/mock/fixtures.ts @@ -0,0 +1,103 @@ +/** + * Mock fixtures shaped to the real vendored protocol. The scripted scenario + * mirrors the real DO contract: a `snapshot` (state + replay) then a live + * `SandboxEvent` stream, so screens behave as they will post-M0 (spec §5). + * Enum literals are `satisfies`-checked against @constructor/protocol. + */ +import type { SandboxEvent, Session, SessionState } from '@constructor/protocol'; + +const now = Date.now(); +const SBX = 'sbx_mock_1'; + +export const mockSessions: Session[] = [ + { + id: 's_active', + title: 'Add dark-mode toggle to settings', + repoOwner: 'refrakts', + repoName: 'constructor-mobile', + baseBranch: 'main', + branchName: 'open-inspect/s_active', + baseSha: 'abc1234', + currentSha: 'def5678', + opencodeSessionId: null, + status: 'active', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: now - 1000 * 60 * 12, + updatedAt: now - 1000 * 30, + }, + { + id: 's_done', + title: 'Fix flaky auth test', + repoOwner: 'refrakts', + repoName: 'constructor-mobile', + baseBranch: 'main', + branchName: 'open-inspect/s_done', + baseSha: 'aaa0001', + currentSha: 'bbb0002', + opencodeSessionId: null, + status: 'completed', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: now - 1000 * 60 * 60 * 5, + updatedAt: now - 1000 * 60 * 60 * 4, + }, +] satisfies Session[]; + +export function mockSessionState(id: string): SessionState { + const src = mockSessions.find((x) => x.id === id) ?? mockSessions[0]; + return { + id: src.id, + title: src.title, + repoOwner: src.repoOwner, + repoName: src.repoName, + baseBranch: src.baseBranch, + branchName: src.branchName, + status: src.status, + sandboxStatus: 'running', + messageCount: 1, + createdAt: src.createdAt, + model: 'anthropic/claude-sonnet-4-6', + isProcessing: src.status === 'active', + totalCost: 0, + } satisfies SessionState; +} + +/** Scenario A — happy path: prompt → tool → cumulative token stream → done. */ +export function scenarioHappy(): SandboxEvent[] { + const t = () => Date.now() / 1000; + const mid = 'm_1'; + const partials = [ + 'Looking at the settings screen…', + 'Looking at the settings screen… adding a `Toggle`', + 'Looking at the settings screen… adding a `Toggle` bound to the theme store.', + 'Looking at the settings screen… adding a `Toggle` bound to the theme store.\n\n```tsx\n\n```\n\nDone.', + ]; + const evts: SandboxEvent[] = [ + { type: 'user_message', content: 'Add a dark-mode toggle to the settings screen', messageId: mid, timestamp: t() }, + { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_call', tool: 'read_file', args: { path: 'src/app/settings.tsx' }, callId: 'c1', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_result', callId: 'c1', result: 'export default function Settings() { … }', messageId: mid, sandboxId: SBX, timestamp: t() }, + ]; + for (const p of partials) { + evts.push({ type: 'token', content: p, messageId: mid, sandboxId: SBX, timestamp: t() }); + } + evts.push({ type: 'step_finish', cost: 0.0123, tokens: 1840, messageId: mid, sandboxId: SBX, timestamp: t() }); + evts.push({ type: 'execution_complete', messageId: mid, success: true, sandboxId: SBX, timestamp: t() }); + return evts; +} + +/** Scenario B — error path. */ +export function scenarioError(): SandboxEvent[] { + const t = () => Date.now() / 1000; + const mid = 'm_err'; + return [ + { type: 'user_message', content: 'Refactor the auth module', messageId: mid, timestamp: t() }, + { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_call', tool: 'run_tests', args: {}, callId: 'c9', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'error', error: 'Test suite failed: 3 failing in auth.test.ts', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'execution_complete', messageId: mid, success: false, error: 'aborted', sandboxId: SBX, timestamp: t() }, + ]; +} diff --git a/apps/mobile/src/data/mock/mock-gateway.ts b/apps/mobile/src/data/mock/mock-gateway.ts new file mode 100644 index 0000000..982d60a --- /dev/null +++ b/apps/mobile/src/data/mock/mock-gateway.ts @@ -0,0 +1,70 @@ +/** In-memory SessionGateway. Same interface the real HTTP/WS gateway will + * implement later — screens never know which one is wired. */ +import type { CreateSessionRequest, Session, SessionState } from '@constructor/protocol'; + +import type { SessionGateway, StreamHandle, StreamListeners, SubscribeSnapshot } from '../gateway'; +import { startScriptedStream } from './emitter'; +import { mockSessionState, mockSessions, scenarioError, scenarioHappy } from './fixtures'; + +export class MockSessionGateway implements SessionGateway { + private sessions: Session[] = [...mockSessions]; + + async listSessions(): Promise { + await tick(); + return [...this.sessions]; + } + + async getSession(id: string): Promise { + await tick(); + return mockSessionState(id); + } + + async createSession(req: CreateSessionRequest): Promise<{ sessionId: string }> { + await tick(); + const id = `s_${Math.random().toString(36).slice(2, 8)}`; + const ts = Date.now(); + this.sessions = [ + { + id, + title: req.title ?? `${req.repoOwner}/${req.repoName}`, + repoOwner: req.repoOwner, + repoName: req.repoName, + baseBranch: req.branch ?? 'main', + branchName: `open-inspect/${id}`, + baseSha: null, + currentSha: null, + opencodeSessionId: null, + status: 'active', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: ts, + updatedAt: ts, + }, + ...this.sessions, + ]; + return { sessionId: id }; + } + + subscribe(id: string, on: StreamListeners): StreamHandle { + const state = mockSessionState(id); + const snapshot: SubscribeSnapshot = { + state, + artifacts: [], + replay: { events: [], hasMore: false, cursor: null }, + }; + const script = state.status === 'failed' ? scenarioError() : scenarioHappy(); + const cancel = startScriptedStream(snapshot, script, on); + return { unsubscribe: cancel }; + } + + async sendFollowUp(_id: string, _content: string): Promise { + await tick(); + } + + async stop(_id: string): Promise { + await tick(); + } +} + +const tick = () => new Promise((r) => setTimeout(r, 220)); diff --git a/apps/mobile/src/data/provider.tsx b/apps/mobile/src/data/provider.tsx new file mode 100644 index 0000000..70a709f --- /dev/null +++ b/apps/mobile/src/data/provider.tsx @@ -0,0 +1,31 @@ +/** Wires the gateway seam + TanStack Query. Swap `defaultGateway` for the real + * HTTP/WS impl later — nothing else changes. */ +import React, { createContext, useContext, useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import type { SessionGateway } from './gateway'; +import { MockSessionGateway } from './mock/mock-gateway'; + +const GatewayContext = createContext(null); + +export function useGateway(): SessionGateway { + const g = useContext(GatewayContext); + if (!g) throw new Error('useGateway must be used within '); + return g; +} + +export function AppProviders({ + children, + gateway, +}: { + children: React.ReactNode; + gateway?: SessionGateway; +}) { + const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), []); + const gw = useMemo(() => gateway ?? new MockSessionGateway(), [gateway]); + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/data/queries.ts b/apps/mobile/src/data/queries.ts new file mode 100644 index 0000000..480ee45 --- /dev/null +++ b/apps/mobile/src/data/queries.ts @@ -0,0 +1,71 @@ +/** The only data entry points screens use. Lists go through TanStack Query; + * the live stream uses the ported pure transforms over the gateway. */ +import { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { CreateSessionRequest, SandboxEvent, SessionState } from '@constructor/protocol'; +import { costDelta, foldEvent, type PendingRef } from '@/features/sessions/stream/transforms'; + +import { useGateway } from './provider'; + +export { useGateway } from './provider'; + +export function useSessions() { + const gw = useGateway(); + return useQuery({ queryKey: ['sessions'], queryFn: () => gw.listSessions() }); +} + +export function useSession(id: string) { + const gw = useGateway(); + return useQuery({ queryKey: ['session', id], queryFn: () => gw.getSession(id), enabled: !!id }); +} + +export function useCreateSession() { + const gw = useGateway(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateSessionRequest) => gw.createSession(req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }), + }); +} + +export type StreamStatus = 'connecting' | 'live' | 'closed'; + +export interface SessionStream { + status: StreamStatus; + state: SessionState | null; + events: SandboxEvent[]; + cost: number; +} + +export function useSessionStream(id: string): SessionStream { + const gw = useGateway(); + const [status, setStatus] = useState('connecting'); + const [state, setState] = useState(null); + const [events, setEvents] = useState([]); + const [cost, setCost] = useState(0); + const pending = useRef(null) as PendingRef; + + useEffect(() => { + if (!id) return; + pending.current = null; + setStatus('connecting'); + setEvents([]); + const handle = gw.subscribe(id, { + snapshot: (snap) => { + setState(snap.state); + setEvents(snap.replay.events); + setStatus('live'); + }, + event: (e) => { + setEvents((prev) => foldEvent(prev, e, pending)); + const d = costDelta(e); + if (d) setCost((c) => c + d); + }, + closed: () => setStatus('closed'), + }); + return () => handle.unsubscribe(); + }, [gw, id]); + + return { status, state, events, cost }; +} diff --git a/apps/mobile/src/features/auth/screen.tsx b/apps/mobile/src/features/auth/screen.tsx new file mode 100644 index 0000000..b77dbf7 --- /dev/null +++ b/apps/mobile/src/features/auth/screen.tsx @@ -0,0 +1,28 @@ +/** Phase-1 slice owner: auth. Visual shell only — real OAuth is M1 (gated on + * deployment + mobile GitHub OAuth App). Mock "signed-in" toggle. */ +import React from 'react'; +import { Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; + +import { Button, Screen, useThemeColors } from '@/ui'; + +export function SignInScreen() { + const router = useRouter(); + const c = useThemeColors(); + return ( + + + + Constructor + + + Control your background coding agents + +