Compare commits

..

47 Commits

Author SHA1 Message Date
Ryan Vogel
cd3a58a4c2 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice
# Conflicts:
#	bun.lock
2026-04-03 13:26:01 +00:00
Ryan Vogel
a5614f988f fix: unblock mobile EAS installs with Bun
Refresh the workspace lockfile and pin EAS to Bun 1.3.11 so frozen installs match local resolution and development builds stop failing in CI.
2026-04-03 13:18:13 +00:00
Ryan Vogel
0f58efe030 fix: advertise MagicDNS hosts for Tailscale pairing
Prefer .ts.net names and allow Tailscale CGNAT HTTP on iOS so mobile pairing avoids ATS-blocked raw tailnet IPs.
2026-04-03 13:02:58 +00:00
Ryan Vogel
4abb464345 feat: refine mobile voice reader interactions
Make Reader open and close feel like one continuous control instead of a view swap. Render assistant markdown consistently in both the compact card and Reader so formatted responses stay readable in either mode.
2026-04-02 20:38:49 +00:00
Ryan Vogel
d1d3d420bf fix: suppress subagent APN completion and error events 2026-04-02 20:11:12 +00:00
Ryan Vogel
1d68cd288c fix: resolve dev merge conflicts in opencode deps 2026-04-02 19:41:33 +00:00
Ryan Vogel
0b8f8bc196 fix: reduce noisy push relay notifications
Only send completion pushes from session.status idle and suppress aborted/overflow errors. Avoid emitting redundant idle state on no-op cancel so users don't get duplicate notifications.
2026-04-02 15:48:02 +00:00
Ryan Vogel
4d30ad1e7c feat: enhance mobile voice build and UI layout
- Updated AGENTS.md with new build commands for development and production.
- Added a new script in package.json for starting Expo with a specific hostname.
- Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers.
- Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness.
- Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.
2026-04-02 13:01:17 +00:00
Ryan Vogel
c90640e0e1 feat: expose push pairing QR endpoints
Return both JSON metadata and a PNG QR so clients can consume mobile pairing without rebuilding the payload themselves.
2026-04-02 13:00:08 +00:00
Ryan Vogel
36b51cad33 Merge branch 'dev' into opencode-remote-voice 2026-03-31 13:59:13 -04:00
Ryan Vogel
776e61d1ec update to build proc 2026-03-31 13:58:57 -04:00
Ryan Vogel
28aebb2772 update mobile voice iOS tracking
Stop tracking generated iOS native project files so EAS builds use app config prebuild output and avoid mixed native/CNG state.
2026-03-31 10:21:02 -04:00
Ryan Vogel
6494f48136 update 2026-03-30 17:05:49 -04:00
Ryan Vogel
15fae6cb60 update mobile pairing flow and audio session handling
Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
2026-03-30 16:53:35 -04:00
Ryan Vogel
aacf1d20d3 update app hanlding 2026-03-30 13:07:30 -04:00
Ryan Vogel
bcf7817127 update mobile dictation controls
Add mobile permission approval flow, simplify dictation settings into toggles, and remove oversized Whisper models while syncing the iOS project with the current runtime configuration.
2026-03-30 13:01:14 -04:00
Ryan Vogel
abf79ae24c refactor mobile screen orchestration
Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
2026-03-30 08:57:35 -04:00
Ryan Vogel
922633ea9d refactor mobile derived ui state
Rewrite a focused cluster of nested ternaries in the mobile screen into straight-line derived logic so the render state is easier to read without changing behavior.
2026-03-30 08:31:46 -04:00
Ryan Vogel
49b40e3c90 refactor mobile fire-and-forget calls
Mark intentional async work in the mobile screen with the void operator so lint can distinguish real promise bugs from deliberate fire-and-forget behavior.
2026-03-30 08:30:28 -04:00
Ryan Vogel
df3276fc87 refactor mobile web color hydration
Replace the hydration state effect with useSyncExternalStore so the web color-scheme hook keeps its static-render fallback without triggering the set-state-in-effect lint warning.
2026-03-30 08:28:25 -04:00
Ryan Vogel
f8f986536b refactor mobile session payload parsing
Move server session response parsing into a typed helper so the mobile screen no longer relies on inline any-based mapping in the refresh path.
2026-03-30 08:27:16 -04:00
Ryan Vogel
785635caef refactor mobile onboarding config
Replace the onboarding step ternary chain with a typed step config so the screen is easier to read and lint can highlight the remaining hotspots more clearly.
2026-03-30 08:18:51 -04:00
Ryan Vogel
ec27518eca update mobile voice quality guardrails
Document package-specific React Native best practices and add lint warnings so state, effect, and complexity issues surface earlier during mobile-voice work.
2026-03-30 08:15:29 -04:00
Ryan Vogel
8ee4ada38e update for onboarding 2026-03-30 07:45:21 -04:00
Ryan Vogel
ab7b1d78bf Update settings 2026-03-30 07:33:30 -04:00
Ryan Vogel
2f44d1900e feat: support deep-link QR pairing in mobile
Generate mobilevoice deep links in serve QR output and let mobile parse both raw payloads and pair query links, while keeping advertised-host ordering and removing QR name overrides.
2026-03-29 19:38:19 -04:00
Ryan Vogel
cb535eef9d feat: support advertised QR hosts for mobile pairing
Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently.
2026-03-29 18:32:21 -04:00
Ryan Vogel
d3ec6f75f4 feat: route push notifications by server and session
Include serverID in relay event payloads and prefer server+session matching in mobile notification handling so taps reliably open the correct context and stale state is refreshed.
2026-03-29 17:52:07 -04:00
Ryan Vogel
9a8b2ae0b1 update apn server 2026-03-29 16:26:16 -04:00
Ryan Vogel
eadb0e25da update to the apn and server management 2026-03-29 16:17:57 -04:00
Ryan Vogel
ddd30ef304 update 2026-03-28 21:38:21 -04:00
Ryan Vogel
2abf1100ee update for whisper 2026-03-28 21:12:24 -04:00
Ryan Vogel
bd2e34f3bd update 2026-03-28 19:03:13 -04:00
Ryan Vogel
a45c3a0049 feat: harden mobile server flow and enrich push alerts
Persist scanned servers across reloads, smooth server/session UI states, and make recording feel immediate. Add session-aware push notification title/body metadata from the OpenCode server.
2026-03-28 18:10:35 -04:00
Ryan Vogel
52d1ee70a0 feat: use new mobile app icon and QR-only server add flow
Replace Expo icon/adaptive icon assets with the provided image and simplify the server dropdown so adding a server is done by scanning the setup QR code only.
2026-03-28 17:30:13 -04:00
Ryan Vogel
0a9fcab56f chore: update dependencies and enhance mobile-voice functionality
- Updated package dependencies in bun.lock and package.json for mobile-voice and opencode.
- Added expo-camera and improved camera permission handling in mobile-voice.
- Introduced QR code generation for relay setup in opencode serve command.
- Enhanced server management and logging in DictationScreen component.
2026-03-28 17:05:35 -04:00
Ryan Vogel
62fae6d182 fix: auto-recover APNs env mismatch in relay
Retry sends on BadEnvironmentKeyInToken with the opposite APNs environment, persist the corrected env, and add request/send logs for register/unregister/event delivery debugging.
2026-03-28 16:58:36 -04:00
Ryan Vogel
3a5be7ad33 update index.ts 2026-03-28 14:31:16 -04:00
Ryan Vogel
f1e88d35ba update for the db.ts 2026-03-28 14:28:44 -04:00
Ryan Vogel
b737e87d9a update env again 2026-03-28 14:16:57 -04:00
Ryan Vogel
bd6e81f30b update for env checks 2026-03-28 14:11:02 -04:00
Ryan Vogel
f080147363 update for app and bun 2026-03-28 14:03:57 -04:00
Ryan Vogel
0051b605ae feat: improve mobile model download UX and relay defaults
Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
2026-03-28 14:03:57 -04:00
Ryan Vogel
56e0e5ce65 Update packages json for the porter stuff 2026-03-28 14:03:57 -04:00
porter-deployment-app[bot]
d065d5a8ec Add Porter workflow files for APN relay project (#19547)
Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2026-03-28 13:59:37 -04:00
Ryan Vogel
cf79208055 mobile-voice commit 2026-03-28 13:30:21 -04:00
Ryan Vogel
f276a8db42 feat: add APN relay MVP and experimental push bridge 2026-03-28 13:28:24 -04:00
271 changed files with 86024 additions and 6359 deletions

View File

@@ -0,0 +1,27 @@
"on":
push:
branches:
- opencode-remote-voice
name: Deploy to apn-relay
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: porter apply
env:
PORTER_APP_NAME: apn-relay
PORTER_CLUSTER: "5534"
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
PORTER_HOST: https://dashboard.porter.run
PORTER_PROJECT: "18525"
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}

View File

@@ -15,7 +15,6 @@ concurrency:
permissions:
contents: read
checks: write
jobs:
unit:
@@ -46,40 +45,14 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test:ci
run: bun turbo test
env:
# Bun 1.3.11 intermittently crashes on Windows during test teardown
# inside the native @parcel/watcher binding. Unit CI does not rely on
# the live watcher backend there, so disable it for that platform.
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:

252
AGENTS.md
View File

@@ -1,128 +1,162 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
# OpenCode Monorepo Agent Guide
## Style Guide
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
### General Principles
## Scope And Precedence
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- Start with this file for repo-wide defaults.
- Then check package-local `AGENTS.md` files for stricter rules.
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
- Package-specific guides override this file when they conflict.
## Repo Facts
- Package manager: `bun` (`bun@1.3.11`).
- Monorepo tool: `turbo`.
- Default branch: `dev`.
- Root test script intentionally fails; do not run tests from root.
## Cursor / Copilot Rules
- No `.cursor/rules/` directory found.
- No `.cursorrules` file found.
- No `.github/copilot-instructions.md` file found.
- If these files are added later, treat them as mandatory project policy.
## High-Value Commands
Run commands from the correct package directory unless noted.
### Root
- Install deps: `bun install`
- Run all typechecks via turbo: `bun run typecheck`
- OpenCode dev CLI entry: `bun run dev`
- OpenCode serve (common): `bun run dev serve --hostname 0.0.0.0 --port 4096`
### `packages/opencode`
- Dev CLI: `bun run dev`
- Typecheck: `bun run typecheck`
- Tests (all): `bun test --timeout 30000`
- Tests (single file): `bun test test/path/to/file.test.ts --timeout 30000`
- Tests (single test name): `bun test test/path/to/file.test.ts -t "name fragment" --timeout 30000`
- Build: `bun run build`
- Drizzle helper: `bun run db`
### `packages/app`
- Dev server: `bun dev`
- Build: `bun run build`
- Typecheck: `bun run typecheck`
- Unit tests (all): `bun run test:unit`
- Unit tests (single file): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts`
- Unit tests (single test name): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts -t "name fragment"`
- E2E tests: `bun run test:e2e`
### `packages/mobile-voice`
- Start Expo: `bun run start`
- Start Expo dev client: `bunx expo start --dev-client --clear --host lan`
- iOS native run: `bun run ios`
- Android native run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
### `packages/apn-relay`
- Start relay: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Lint / Test Expectations
- Always run the narrowest checks that prove your change.
- For backend changes: run package typecheck + relevant tests.
- For mobile changes: run `expo lint` and at least one `expo` compile-style command if possible.
- Never claim tests passed unless you ran them in this workspace.
## Single-Test Guidance
- Prefer running one file first, then broaden scope.
- For Bun tests, pass the file path directly.
- For name filtering, use `-t "..."`.
- Keep original timeouts when scripts define them.
## Code Style Guidelines
These conventions are already used heavily in this repo and should be preserved.
### Formatting
- Use Prettier defaults configured in root: `semi: false`, `printWidth: 120`.
- Keep imports grouped and stable; avoid noisy reorder-only edits.
- Avoid unrelated formatting churn in touched files.
### Imports
- Prefer explicit imports over dynamic imports unless runtime gating is required.
- Prefer existing alias patterns (for example `@/...`) where already configured.
- Do not introduce new dependency layers when a local util already exists.
### Types
- Avoid `any`.
- Prefer inference for local variables.
- Add explicit annotations for exported APIs and complex boundaries.
- Prefer `zod` schemas for request/response validation and parsing.
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
// Bad
let foo
if (condition) foo = 1
else foo = 2
```
- Follow existing repo preference for short, clear names.
- Use single-word names when readable; use multi-word only for clarity.
- Keep naming consistent with nearby code.
### Control Flow
Avoid `else` statements. Prefer early returns.
- Prefer early returns over nested `else` blocks.
- Keep functions focused; split only when it improves reuse or readability.
```ts
// Good
function foo() {
if (condition) return 1
return 2
}
### Error Handling
// Bad
function foo() {
if (condition) return 1
else return 2
}
```
- Fail with actionable messages.
- Avoid swallowing errors silently.
- Log enough context to debug production issues (IDs, env, status), but never secrets.
- In UI code, degrade gracefully for missing capabilities.
### Schema Definitions (Drizzle)
### Data / DB
Use snake_case for field names so column names don't need to be redefined as strings.
- For Drizzle schema, use snake_case fields and columns.
- Keep migration and schema changes minimal and explicit.
- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
### Testing Philosophy
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
```
- Prefer testing real behavior over mocks.
- Add regression tests for bug fixes where practical.
- Keep fixtures small and focused.
## Testing
## Agent Workflow Tips
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
- Read existing code paths before introducing new abstractions.
- Match local patterns first; do not impose a new style per file.
- If a package has its own `AGENTS.md`, review it before editing.
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
## Type Checking
## Known Operational Notes
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
## Regeneration / Special Scripts
- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
## Quick Checklist Before Finishing
- Ran relevant package checks.
- Updated docs/config when behavior changed.
- Avoided committing unrelated files.
- Kept edits minimal and aligned with local conventions.

1741
bun.lock

File diff suppressed because it is too large Load Diff

0
eas.json Normal file
View File

View File

@@ -109,12 +109,6 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -130,7 +124,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
},
})
@@ -236,7 +229,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=",
"aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=",
"aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=",
"x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg="
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
}
}

View File

@@ -12,7 +12,6 @@
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -48,7 +47,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"ai": "6.0.149",
"ai": "6.0.138",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -104,7 +103,6 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
@@ -118,6 +116,8 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
}
}

View File

@@ -0,0 +1,11 @@
PORT=8787
DATABASE_HOST=
DATABASE_USERNAME=
DATABASE_PASSWORD=
DATABASE_NAME=main
APNS_TEAM_ID=
APNS_KEY_ID=
APNS_PRIVATE_KEY=
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

View File

@@ -0,0 +1,106 @@
# apn-relay Agent Guide
This file defines package-specific guidance for agents working in `packages/apn-relay`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file provides stricter package-level conventions for relay service work.
- If future local guides are added, closest guide wins.
## Project Overview
- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
- Core routes:
- `GET /health`
- `GET /`
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Commands
Run all commands from `packages/apn-relay`.
- Install deps: `bun install`
- Start relay locally: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Test Expectations
- There is no dedicated package test script currently.
- Required validation for behavior changes:
- `bun run typecheck`
- `bun run db:check` when DB/env changes are involved
- manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
## Single-Test Guidance
- No single-test command exists for this package today.
- For focused checks, run endpoint-level manual tests against a local dev server.
## Code Style Guidelines
### Formatting / Structure
- Keep handlers compact and explicit.
- Prefer small local helpers for repeated route logic.
- Avoid broad refactors when a targeted fix is enough.
### Types / Validation
- Validate request bodies with `zod` at route boundaries.
- Keep payload and DB row shapes explicit and close to usage.
- Avoid `any`; narrow unknown input immediately after parsing.
### Naming
- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
- For DB columns, keep snake_case alignment with schema.
### Error Handling
- Return clear JSON errors for invalid input.
- Keep handler failures observable via `app.onError` and structured logs.
- Do not leak secrets in responses or logs.
### Logging
- Log delivery lifecycle at key checkpoints:
- registration/unregistration attempts
- event fanout start/end
- APNs send failures and retries
- Mask sensitive values; prefer token suffixes and metadata.
### APNs Environment Rules
- Keep APNs env explicit per registration (`sandbox` / `production`).
- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
- Avoid infinite retry loops; one retry max per delivery attempt.
## Database Conventions
- Schema is in `src/schema.sql.ts`.
- Keep table/column names snake_case.
- Maintain index naming consistency with existing schema.
- For upserts, update only fields required by current behavior.
## API Behavior Expectations
- `register`/`unregister` must be idempotent.
- `event` should return success envelope even when no devices are registered.
- Delivery logs should capture per-attempt result and error payload.
## Operational Notes
- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
- Avoid coupling route behavior to deployment platform specifics.
## Before Finishing
- Run `bun run typecheck`.
- If DB/env behavior changed, run `bun run db:check`.
- Manually exercise affected endpoints.
- Confirm logs are useful and secret-safe.

View File

@@ -0,0 +1,14 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY package.json ./
COPY tsconfig.json ./
COPY drizzle.config.ts ./
RUN bun install --production
COPY src ./src
EXPOSE 8787
CMD ["bun", "run", "src/index.ts"]

View File

@@ -0,0 +1,46 @@
# APN Relay
Minimal APNs relay for OpenCode mobile background notifications.
## What it does
- Registers iOS device tokens for a shared secret.
- Receives OpenCode event posts (`complete`, `permission`, `error`).
- Sends APNs notifications to mapped devices.
- Stores delivery rows in PlanetScale.
## Routes
- `GET /health`
- `GET /` (simple dashboard)
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Environment
Use `.env.example` as a starting point.
- `DATABASE_HOST`
- `DATABASE_USERNAME`
- `DATABASE_PASSWORD`
- `APNS_TEAM_ID`
- `APNS_KEY_ID`
- `APNS_PRIVATE_KEY`
- `APNS_DEFAULT_BUNDLE_ID`
## Run locally
```bash
bun install
bun run src/index.ts
```
## Docker
Build from this directory:
```bash
docker build -t apn-relay .
docker run --rm -p 8787:8787 --env-file .env apn-relay
```

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migration",
strict: true,
schema: ["./src/**/*.sql.ts"],
dialect: "mysql",
dbCredentials: {
host: process.env.DATABASE_HOST ?? "",
user: process.env.DATABASE_USERNAME ?? "",
password: process.env.DATABASE_PASSWORD ?? "",
database: process.env.DATABASE_NAME ?? "main",
ssl: {
rejectUnauthorized: false,
},
},
})

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/apn-relay",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"db:check": "bun run --env-file .env src/check.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"hono": "4.10.7",
"jose": "6.0.11",
"zod": "4.1.8"
},
"devDependencies": {
"@tsconfig/bun": "1.0.9",
"@types/bun": "1.3.11",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"typescript": "5.8.2"
}
}

View File

@@ -0,0 +1,185 @@
import { connect } from "node:http2"
import { SignJWT, importPKCS8 } from "jose"
import { env } from "./env"
export type PushEnv = "sandbox" | "production"
type PushInput = {
token: string
bundle: string
env: PushEnv
title: string
body: string
data: Record<string, unknown>
}
type PushResult = {
ok: boolean
code: number
error?: string
}
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
function host(input: PushEnv) {
if (input === "sandbox") return "api.sandbox.push.apple.com"
return "api.push.apple.com"
}
function key() {
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
return env.APNS_PRIVATE_KEY
}
async function sign() {
if (!pk) pk = await importPKCS8(key(), "ES256")
const now = Math.floor(Date.now() / 1000)
if (jwt && now < exp) return jwt
jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
.setIssuer(env.APNS_TEAM_ID)
.setIssuedAt(now)
.sign(pk)
exp = now + 50 * 60
return jwt
}
function post(input: {
host: string
token: string
auth: string
bundle: string
payload: string
}): Promise<{ code: number; body: string }> {
return new Promise((resolve, reject) => {
const cli = connect(`https://${input.host}`)
let done = false
let code = 0
let body = ""
const stop = (fn: () => void) => {
if (done) return
done = true
fn()
}
cli.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
const req = cli.request({
":method": "POST",
":path": `/3/device/${input.token}`,
authorization: `bearer ${input.auth}`,
"apns-topic": input.bundle,
"apns-push-type": "alert",
"apns-priority": "10",
"content-type": "application/json",
})
req.setEncoding("utf8")
req.on("response", (headers) => {
code = Number(headers[":status"] ?? 0)
})
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
stop(() => resolve({ code, body }))
cli.close()
})
req.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
req.end(input.payload)
})
}
export async function send(input: PushInput): Promise<PushResult> {
const apnsHost = host(input.env)
const suffix = tokenSuffix(input.token)
console.log("[ APN RELAY ] push:start", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
})
const auth = await sign().catch((err) => {
return `error:${String(err)}`
})
if (auth.startsWith("error:")) {
console.log("[ APN RELAY ] push:auth-failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
error: auth,
})
return {
ok: false,
code: 0,
error: auth,
}
}
const payload = JSON.stringify({
aps: {
alert: {
title: input.title,
body: input.body,
},
sound: "alert.wav",
},
...input.data,
})
const out = await post({
host: apnsHost,
token: input.token,
auth,
bundle: input.bundle,
payload,
}).catch((err) => ({
code: 0,
body: String(err),
}))
if (out.code === 200) {
console.log("[ APN RELAY ] push:sent", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
})
return {
ok: true,
code: 200,
}
}
console.log("[ APN RELAY ] push:failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
error: out.body,
})
return {
ok: false,
code: out.code,
error: out.body,
}
}

View File

@@ -0,0 +1,28 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
import { env } from "./env"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
async function run() {
console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
await db.execute(sql`SELECT 1`)
console.log("[apn-relay] DB connection OK")
await setup()
console.log("[apn-relay] Setup migration OK")
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
console.log("[apn-relay] DB check passed")
}
run().catch((err) => {
console.error("[apn-relay] DB check failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,11 @@
import { Client } from "@planetscale/database"
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { env } from "./env"
const client = new Client({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
})
export const db = drizzle({ client })

View File

@@ -0,0 +1,47 @@
import { z } from "zod"
const bad = new Set(["undefined", "null"])
const txt = z
.string()
.transform((input) => input.trim())
.refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
const schema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
DATABASE_HOST: txt,
DATABASE_USERNAME: txt,
DATABASE_PASSWORD: txt,
APNS_TEAM_ID: txt,
APNS_KEY_ID: txt,
APNS_PRIVATE_KEY: txt,
APNS_DEFAULT_BUNDLE_ID: txt,
})
const req = [
"DATABASE_HOST",
"DATABASE_USERNAME",
"DATABASE_PASSWORD",
"APNS_TEAM_ID",
"APNS_KEY_ID",
"APNS_PRIVATE_KEY",
"APNS_DEFAULT_BUNDLE_ID",
] as const
const out = schema.safeParse(process.env)
if (!out.success) {
const miss = req.filter((key) => !process.env[key]?.trim())
const bad = out.error.issues
.map((item) => item.path[0])
.filter((key): key is string => typeof key === "string")
.filter((key) => !miss.includes(key as (typeof req)[number]))
console.error("[apn-relay] Invalid startup configuration")
if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
console.error("[apn-relay] Check .env.example and restart")
throw new Error("Startup configuration invalid")
}
export const env = out.data

View File

@@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}

View File

@@ -0,0 +1,448 @@
import { randomUUID } from "node:crypto"
import { and, desc, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { z } from "zod"
import { send } from "./apns"
import { db } from "./db"
import { env } from "./env"
import { hash } from "./hash"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
function bad(input?: string) {
if (!input) return false
return input.includes("BadEnvironmentKeyInToken")
}
function flip(input: "sandbox" | "production") {
if (input === "sandbox") return "production"
return "sandbox"
}
function tail(input: string) {
return input.slice(-8)
}
function esc(input: unknown) {
return String(input ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function fmt(input: number) {
return new Date(input).toISOString()
}
const reg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
bundleId: z.string().min(1).optional(),
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
})
const unreg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
})
const evt = z.object({
secret: z.string().min(1),
serverID: z.string().min(1).optional(),
eventType: z.enum(["complete", "permission", "error"]),
sessionID: z.string().min(1),
title: z.string().min(1).optional(),
body: z.string().min(1).optional(),
})
function title(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "Session complete"
if (input === "permission") return "Action needed"
return "Session error"
}
function body(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "OpenCode finished your session."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
const app = new Hono()
app.onError((err, c) => {
return c.json(
{
ok: false,
error: err.message,
},
500,
)
})
app.notFound((c) => {
return c.json(
{
ok: false,
error: "Not found",
},
404,
)
})
app.get("/health", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
return c.json({
ok: true,
devices: Number(a?.value ?? 0),
deliveries: Number(b?.value ?? 0),
})
})
app.get("/", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
const byBundle = await db
.select({
bundle: device_registration.bundle_id,
env: device_registration.apns_env,
value: sql<number>`count(*)`,
})
.from(device_registration)
.groupBy(device_registration.bundle_id, device_registration.apns_env)
.orderBy(desc(sql<number>`count(*)`))
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APN Relay</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
h1 { margin: 0 0 12px 0; }
h2 { margin: 22px 0 10px 0; font-size: 16px; }
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
.muted { color: #6b7280; font-size: 12px; }
.small { font-size: 11px; color: #6b7280; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>APN Relay</h1>
<p class="muted">MVP dashboard</p>
<div class="stats">
<div class="card">
<div class="muted">Registered devices</div>
<div>${Number(a?.value ?? 0)}</div>
</div>
<div class="card">
<div class="muted">Delivery log rows</div>
<div>${Number(b?.value ?? 0)}</div>
</div>
</div>
<h2>Registered devices</h2>
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
<table>
<thead>
<tr>
<th>updated</th>
<th>created</th>
<th>token suffix</th>
<th>env</th>
<th>bundle</th>
<th>secret hash</th>
</tr>
</thead>
<tbody>
${
devices.length
? devices
.map(
(row) => `<tr>
<td>${esc(fmt(row.updated_at))}</td>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(tail(row.device_token))}</td>
<td>${esc(row.apns_env)}</td>
<td>${esc(row.bundle_id)}</td>
<td>${esc(`${row.secret_hash.slice(0, 12)}`)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
}
</tbody>
</table>
<h2>Bundle breakdown</h2>
<table>
<thead>
<tr>
<th>bundle</th>
<th>env</th>
<th>count</th>
</tr>
</thead>
<tbody>
${
byBundle.length
? byBundle
.map(
(row) => `<tr>
<td>${esc(row.bundle)}</td>
<td>${esc(row.env)}</td>
<td>${esc(Number(row.value ?? 0))}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
}
</tbody>
</table>
<h2>Recent deliveries</h2>
<table>
<thead>
<tr>
<th>time</th>
<th>event</th>
<th>session</th>
<th>status</th>
<th>error</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `<tr>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(row.event_type)}</td>
<td>${esc(row.session_id)}</td>
<td>${esc(row.status)}</td>
<td>${esc(row.error ?? "")}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</body>
</html>`
return c.html(html)
})
app.post("/v1/device/register", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = reg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const now = Date.now()
const key = hash(check.data.secret)
const row = {
id: randomUUID(),
secret_hash: key,
device_token: check.data.deviceToken,
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
apns_env: check.data.apnsEnv,
created_at: now,
updated_at: now,
}
console.log("[relay] register", {
token: tail(row.device_token),
env: row.apns_env,
bundle: row.bundle_id,
secretHash: `${key.slice(0, 12)}...`,
})
await db
.insert(device_registration)
.values(row)
.onDuplicateKeyUpdate({
set: {
bundle_id: row.bundle_id,
apns_env: row.apns_env,
updated_at: now,
},
})
return c.json({ ok: true })
})
app.post("/v1/device/unregister", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = unreg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
console.log("[relay] unregister", {
token: tail(check.data.deviceToken),
secretHash: `${key.slice(0, 12)}...`,
})
await db
.delete(device_registration)
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
return c.json({ ok: true })
})
app.post("/v1/event", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = evt.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
console.log("[relay] event", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
devices: list.length,
})
if (!list.length) {
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
console.log("[relay] event:no-matching-devices", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
totalDevices: Number(total?.value ?? 0),
})
return c.json({
ok: true,
sent: 0,
failed: 0,
})
}
const out = await Promise.all(
list.map(async (row) => {
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
const payload = {
token: row.device_token,
bundle: row.bundle_id,
title: check.data.title ?? title(check.data.eventType),
body: check.data.body ?? body(check.data.eventType),
data: {
serverID: check.data.serverID,
eventType: check.data.eventType,
sessionID: check.data.sessionID,
},
}
const first = await send({ ...payload, env })
if (first.ok || !bad(first.error)) {
if (!first.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env,
error: first.error,
})
}
return first
}
const alt = flip(env)
console.log("[relay] send:retry-env", {
token: tail(row.device_token),
from: env,
to: alt,
})
const second = await send({ ...payload, env: alt })
if (!second.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env: alt,
error: second.error,
})
return second
}
await db
.update(device_registration)
.set({ apns_env: alt, updated_at: Date.now() })
.where(
and(
eq(device_registration.secret_hash, row.secret_hash),
eq(device_registration.device_token, row.device_token),
),
)
console.log("[relay] send:env-updated", {
token: tail(row.device_token),
env: alt,
})
return second
}),
)
const now = Date.now()
await db.insert(delivery_log).values(
out.map((item) => ({
id: randomUUID(),
secret_hash: key,
event_type: check.data.eventType,
session_id: check.data.sessionID,
status: item.ok ? "sent" : "failed",
error: item.error,
created_at: now,
})),
)
const sent = out.filter((item) => item.ok).length
console.log("[relay] event:done", {
type: check.data.eventType,
session: check.data.sessionID,
sent,
failed: out.length - sent,
})
return c.json({
ok: true,
sent,
failed: out.length - sent,
})
})
await setup()
if (import.meta.main) {
Bun.serve({
port: env.PORT,
fetch: app.fetch,
})
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
}
export { app }

View File

@@ -0,0 +1,35 @@
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
export const device_registration = mysqlTable(
"device_registration",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
device_token: varchar("device_token", { length: 255 }).notNull(),
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
},
(table) => [
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
index("device_registration_secret_hash_idx").on(table.secret_hash),
],
)
export const delivery_log = mysqlTable(
"delivery_log",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
event_type: varchar("event_type", { length: 32 }).notNull(),
session_id: varchar("session_id", { length: 255 }).notNull(),
status: varchar("status", { length: 16 }).notNull(),
error: varchar("error", { length: 1024 }),
created_at: bigint("created_at", { mode: "number" }).notNull(),
},
(table) => [
index("delivery_log_secret_hash_idx").on(table.secret_hash),
index("delivery_log_created_at_idx").on(table.created_at),
],
)

View File

@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
export async function setup() {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS device_registration (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
device_token varchar(255) NOT NULL,
bundle_id varchar(255) NOT NULL,
apns_env varchar(16) NOT NULL DEFAULT 'production',
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
KEY device_registration_secret_hash_idx (secret_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS delivery_log (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
event_type varchar(32) NOT NULL,
session_id varchar(255) NOT NULL,
status varchar(16) NOT NULL,
error varchar(1024) NULL,
created_at bigint NOT NULL,
PRIMARY KEY (id),
KEY delivery_log_secret_hash_idx (secret_hash),
KEY delivery_log_created_at_idx (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@@ -1,88 +0,0 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
type Probe = {
agent?: string
model?: { providerID: string; modelID: string; name?: string }
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function state(page: Page) {
const value = await probe(page)
if (!value) throw new Error("Failed to resolve model selection probe")
return value
}
async function ready(page: Page) {
const prompt = page.locator(promptSelector)
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("focus")
return prompt
}
async function body(prompt: Locator) {
return prompt.evaluate((el) => (el as HTMLElement).innerText)
}
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
test.skip(!next, "only one agent available")
if (!next) return
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
next,
)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" agent")
await expect.poll(() => body(prompt)).toContain("focus agent")
})
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
test.skip(!next, "only one model available")
if (!next) return
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" model")
await expect.poll(() => body(prompt)).toContain("focus model")
})

View File

@@ -1,6 +1,6 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { closeDialog, openSettings, withSession } from "../actions"
import { withSession } from "../actions"
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
const isBash = (part: unknown): part is ToolPart => {
@@ -19,15 +19,12 @@ test("shell mode runs a command in the project directory", async ({ page, projec
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
const dialog = await openSettings(page)
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
const input = toggle.locator('[data-slot="switch-input"]').first()
await expect(toggle).toBeVisible()
if ((await input.getAttribute("aria-checked")) !== "true") {
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "true")
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
if ((await button.getAttribute("aria-pressed")) !== "true") {
await button.click()
await expect(button).toHaveAttribute("aria-pressed", "true")
}
await closeDialog(page, dialog)
await project.shell(cmd)
await expect

View File

@@ -5,7 +5,7 @@ import {
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -65,14 +65,12 @@ async function clearPermissionDock(page: any, label: RegExp) {
}
async function setAutoAccept(page: any, enabled: boolean) {
const dialog = await openSettings(page)
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
const input = toggle.locator('[data-slot="switch-input"]').first()
await expect(toggle).toBeVisible()
const checked = (await input.getAttribute("aria-checked")) === "true"
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
await closeDialog(page, dialog)
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function expectQuestionBlocked(page: any) {
@@ -279,7 +277,6 @@ test("default dock shows prompt input", async ({ page, project }) => {
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
@@ -293,6 +290,10 @@ test("default dock shows prompt input", async ({ page, project }) => {
test("auto-accept toggle works before first submit", async ({ page, project }) => {
await project.open()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.17",
"version": "1.3.13",
"description": "",
"type": "module",
"exports": {
@@ -15,7 +15,6 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",

View File

@@ -87,7 +87,7 @@ const runnerEnv = {
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
@@ -100,7 +100,7 @@ const cleanup = async () => {
const jobs = [
inst?.Instance.disposeAll(),
typeof server?.stop === "function" ? server.stop() : undefined,
server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
@@ -158,7 +158,7 @@ try {
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)

View File

@@ -86,7 +86,6 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
@@ -94,31 +93,25 @@ export function ModelSelectorPopover(props: {
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
open: boolean
dismiss: Dismiss | null
dismiss: "escape" | "outside" | null
}>({
open: false,
dismiss: null,
})
const dialog = useDialog()
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => {
close("manage")
setStore("open", false)
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
const handleConnectProvider = () => {
close("provider")
setStore("open", false)
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
@@ -143,19 +136,21 @@ export function ModelSelectorPopover(props: {
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
close("escape")
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => close("outside")}
onFocusOutside={() => close("outside")}
onPointerDownOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onCloseAutoFocus={(event) => {
const dismiss = store.dismiss
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
if (store.dismiss === "outside") event.preventDefault()
setStore("dismiss", null)
}}
>
@@ -163,7 +158,7 @@ export function ModelSelectorPopover(props: {
<ModelList
provider={props.provider}
model={props.model}
onSelect={() => close("select")}
onSelect={() => setStore("open", false)}
class="p-1"
action={
<div class="flex items-center gap-1">

View File

@@ -243,6 +243,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -280,31 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => {
const items = prompt.context.items()
@@ -510,15 +502,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getCursorPosition(editorRef)
}
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
@@ -1079,6 +1062,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
})
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({
info,
@@ -1322,7 +1316,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
return
}
editorRef?.focus()
@@ -1400,17 +1398,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!working() && blank())}
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
@@ -1473,10 +1471,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={(value) => {
local.agent.set(value)
restoreFocus()
}}
onSelect={local.agent.set}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
@@ -1540,7 +1535,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
@@ -1569,10 +1563,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
@@ -1582,6 +1573,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</div>
</Show>
<TooltipKeybind
placement="top"
gutter={8}
title={acceptLabel()}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind>
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt"
type PromptImageAttachmentsProps = {
@@ -23,36 +22,34 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}>
{(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class={imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
<div class="relative group">
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class={imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</Tooltip>
</div>
)}
</For>
</div>

View File

@@ -146,7 +146,7 @@ beforeAll(async () => {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => {
optimistic.push(value)
optimisticSeeded.push(
@@ -310,7 +310,8 @@ describe("prompt submit worktree selection", () => {
expect(optimistic[0]).toMatchObject({
message: {
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
model: { providerID: "provider", modelID: "model" },
variant: "high",
},
})
})

View File

@@ -121,7 +121,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
model: input.draft.model,
variant: input.draft.variant,
}
const add = () =>

View File

@@ -8,9 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import {
monoDefault,
@@ -21,7 +19,6 @@ import {
sansInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
@@ -67,9 +64,7 @@ const playDemoSound = (id: string | undefined) => {
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const params = useParams()
const settings = useSettings()
onMount(() => {
@@ -81,31 +76,6 @@ export const SettingsGeneral: Component = () => {
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const check = () => {
if (!platform.checkUpdate) return
@@ -169,6 +139,11 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
@@ -231,15 +206,6 @@ export const SettingsGeneral: Component = () => {
/>
</SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -275,6 +241,24 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList>
</div>
)

View File

@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),

View File

@@ -494,10 +494,8 @@ describe("applyDirectoryEvent", () => {
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -513,8 +511,8 @@ describe("applyDirectoryEvent", () => {
},
})
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test" })
})
test("routes disposal and lsp events to side-effect handlers", () => {

View File

@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch?: string }
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
const next = { ...input.store.vcs, branch: props.branch }
const next = { branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break

View File

@@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
import { useSDK } from "./sdk"
import { useSync } from "./sync"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
export type ModelKey = { providerID: string; modelID: string }
type State = {
agent?: string
@@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
@@ -383,7 +383,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model.variant ?? null,
variant: msg.variant ?? null,
})
},
},

View File

@@ -136,11 +136,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return {
ready,
get current() {
@@ -155,12 +150,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
setStore("general", "followup", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,

View File

@@ -416,7 +416,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
model: input.model,
variant: input.variant,
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })

View File

@@ -535,8 +535,6 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",

View File

@@ -16,7 +16,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@@ -102,46 +101,42 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void
}) => {
const title = () => sessionTitle(props.session.title)
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</A>
)
}
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
const SessionHoverPreview = (props: {
mobile?: boolean
@@ -324,7 +319,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
value={props.session.title}
gutter={10}
class="min-w-0 w-full"
>

View File

@@ -1,4 +1,4 @@
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -68,9 +68,6 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -430,16 +427,15 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: canReview,
hasReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -462,12 +458,6 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -521,22 +511,11 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
deferRender: false,
})
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
@@ -570,68 +549,6 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -647,42 +564,7 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -748,7 +630,13 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const sessionEmptyKey = createMemo(() => {
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -902,46 +790,13 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "git")
setStore("changes", "session")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => params.dir,
@@ -1064,40 +919,6 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -1144,23 +965,21 @@ export default function Page() {
loadFile: file.load,
})
const changesTitle = () => {
if (!canReview()) {
return null
}
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
const changesTitle = () => {
if (!hasReview()) {
return null
}
return (
<Select
options={changesOptions()}
options={changesOptionsList}
current={store.changes}
label={label}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -1169,34 +988,20 @@ export default function Page() {
)
}
const empty = (text: string) => (
const emptyTurn = () => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div>
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") return emptyTurn()
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
if (hasReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (sessionEmptyKey() === "session.review.noVcs") {
if (reviewEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -1216,7 +1021,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
@@ -1323,7 +1128,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
if (!reviewReady()) return
if (!diffsReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1364,7 +1169,10 @@ export default function Page() {
const id = params.id
if (!id) return
if (!wantsReview()) return
const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
@@ -1373,7 +1181,13 @@ export default function Page() {
createEffect(
on(
() => [sessionKey(), wantsReview()] as const,
() =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -2053,12 +1867,6 @@ export default function Page() {
</div>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}

View File

@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language"
@@ -210,25 +210,76 @@ export function SessionTodoDock(props: {
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}}
>
<TodoList todos={props.todos} />
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
</DockTray>
)
}
function TodoList(props: { todos: Todo[] }) {
function TodoList(props: { todos: Todo[]; open: boolean }) {
const [store, setStore] = createStore({
stuck: false,
scrolling: false,
})
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (store.scrolling) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStore("stuck", scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0)
setStore("scrolling", true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore("scrolling", false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<Index each={props.todos}>

View File

@@ -29,7 +29,6 @@ import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
@@ -44,6 +43,7 @@ type MessageComment = {
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
type UserActions = {
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -291,7 +291,6 @@ export function MessageTimeline(props: {
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -400,7 +399,7 @@ export function MessageTimeline(props: {
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
@@ -418,7 +417,7 @@ export function MessageTimeline(props: {
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) {
if (!next || next === (titleValue() ?? "")) {
setTitle("editing", false)
return
}
@@ -533,9 +532,7 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -577,18 +574,10 @@ export function MessageTimeline(props: {
}}
>
<button
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
onClick={props.onResumeScroll}
>
<div
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
style={{
"box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
}}
>
<Icon name="arrow-down-to-line" size="small" />
</div>
<Icon name="arrow-down-to-line" />
</button>
</div>
<ScrollView
@@ -684,7 +673,7 @@ export function MessageTimeline(props: {
</div>
</Show>
</div>
<Show when={titleLabel() || title.editing}>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
@@ -692,7 +681,7 @@ export function MessageTimeline(props: {
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleLabel()}
{titleValue()}
</h1>
}
>

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
({
id: "msg",
sessionID: "session",
@@ -10,6 +10,7 @@ const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
time: { created: 1 },
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: input?.variant,
}) as UserMessage
describe("syncSessionModel", () => {
@@ -25,12 +26,10 @@ describe("syncSessionModel", () => {
reset() {},
},
},
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
message({ variant: "high" }),
)
expect(calls).toEqual([
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
])
expect(calls).toEqual([message({ variant: "high" })])
})
})

View File

@@ -8,7 +8,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,6 +18,7 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -26,12 +26,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -39,11 +33,12 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { sessionKey, tabs, view } = useSessionLayout()
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -58,7 +53,24 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -69,7 +81,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of props.diffs()) {
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -123,7 +135,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: props.canReview,
hasReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -228,12 +240,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -292,7 +304,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview()}>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -366,10 +378,8 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -377,9 +387,9 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Match when={hasReview()}>
<Show
when={props.diffsReady()}
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -398,7 +408,11 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
<Match when={true}>{empty(props.empty())}</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

View File

@@ -52,7 +52,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(id)
}
const hasReview = () => !!params.id
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)

View File

@@ -1,7 +0,0 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.17",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,4 +1,3 @@
import type { Stripe } from "stripe"
import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
@@ -112,17 +111,27 @@ export async function POST(input: APIEvent) {
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
const paymentMethodID = body.data.object.default_payment_method as string
if (!workspaceID) throw new Error("Workspace ID not found")
if (!userID) throw new Error("User ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
if (!paymentMethodID) throw new Error("Payment method ID not found")
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
// get payment method for the payment intent
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
@@ -191,18 +200,26 @@ export async function POST(input: APIEvent) {
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
const productID = body.data.object.lines?.data[0].pricing?.price_details?.product as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
// get coupon id from subscription
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["discounts", "payments"],
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
expand: ["discounts"],
})
const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string
const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
const productID = subscriptionData.items.data[0].price.product as string
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")

View File

@@ -287,8 +287,6 @@ export function LiteSection() {
<ul data-slot="promo-models">
<li>Kimi K2.5</li>
<li>GLM-5</li>
<li>Mimo-V2-Pro</li>
<li>Mimo-V2-Omni</li>
<li>MiniMax M2.5</li>
<li>MiniMax M2.7</li>
</ul>

View File

@@ -90,8 +90,7 @@ export async function handler(
const body = await input.request.json()
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
const rawIp = input.request.headers.get("x-real-ip") ?? ""
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
const ip = input.request.headers.get("x-real-ip") ?? ""
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""

View File

@@ -17,8 +17,9 @@ export function createRateLimiter(
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
const dailyLimit = rateLimit ?? limits.dailyRequests
const isDefaultModel = !rateLimit
const headerExists = request.headers.has(limits.checkHeader)
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headerExists && !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.17",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -254,7 +254,7 @@ export namespace Billing {
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
...(billing.customerID
? {
customer: billing.customerID,

View File

@@ -11,11 +11,6 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonthCoupon = fn(z.string(), (email) => {
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
return invitees.includes(email)
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
})
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -9,6 +9,8 @@ export namespace Subscription {
free: z.object({
promoTokens: z.number().int(),
dailyRequests: z.number().int(),
checkHeader: z.string(),
fallbackValue: z.number().int(),
}),
lite: z.object({
rollingLimit: z.number().int(),

View File

@@ -142,12 +142,7 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.17",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -142,12 +142,7 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.17",
"version": "1.3.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -142,12 +142,7 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.17",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.17",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.17",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -142,12 +142,7 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.17"
version = "1.3.13"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.17",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -142,12 +142,7 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
"price": string
"priceInr": number

View File

@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}

43
packages/mobile-voice/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -0,0 +1,183 @@
# mobile-voice Agent Guide
This file defines package-specific guidance for agents working in `packages/mobile-voice`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file overrides root guidance for this package when rules conflict.
- If additional local guides are added later, treat the closest guide as highest priority.
## Project Overview
- Expo + React Native app for voice dictation and OpenCode session monitoring.
- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
- Development builds are required for native module changes.
## Commands
Run all commands from `packages/mobile-voice`.
- Install deps: `bun install`
- Start Metro: `bun run start`
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
- iOS run: `bun run ios`
- Android run: `bun run android`
- Lint: `bun run lint`
- Typecheck: `bun run typecheck`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear`
## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
## Single-Test Guidance
- This package currently has no dedicated unit test script.
- Use targeted validation commands instead:
- `bun run lint`
- `bun run typecheck`
- `bunx expo export --platform ios --clear`
- manual runtime test in dev client
## Architecture Priorities
- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
## Code Style And Patterns
### Formatting / Structure
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
- Prefer feature-adjacent hooks/components over growing a single screen file.
### React State / Effects
- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
- Use `useMemo` only when computation is expensive or stable identity actually matters.
- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
### Types
- Avoid `any`; prefer local type aliases for component state and network payloads.
- Keep exported/shared boundaries typed explicitly.
- Parse persisted and network payloads as `unknown` first, then validate before use.
- Use discriminated unions for UI modes/status where practical.
### Naming
- Prefer short, readable names consistent with nearby code.
- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
### Error Handling / Logging
- Fail gracefully in UI (alerts, disabled actions, fallback text).
- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
- Never log secrets or full APNs tokens.
- Keep hot-path logging behind `__DEV__` when possible.
### Network / Relay Integration
- Normalize and validate URLs before storing server configs.
- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
- Keep relay registration idempotent.
- Guard duplicate scan/add flows to avoid repeated server entries.
### Notifications / APNs
- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
- On registration changes, ensure old token unregister flow remains intact.
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
### Performance / RN
- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
- During recording and monitoring flows, keep JS-thread work light.
- Prefer Reanimated/native-thread-friendly animations for motion.
- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
## Lint / Quality Bar
- Keep hooks lint warnings clean before finishing.
- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
## Native-Module Safety
- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
- Rebuild the dev client after native module additions or changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
## Expo Native Config (EAS)
- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
- Put App Store compliance and permission metadata in app config using these fields:
- `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
- `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
- `expo.ios.entitlements` for iOS entitlements.
- `expo.ios.privacyManifests` for Apple privacy manifest declarations.
- Keep `app.json` entries explicit and review-friendly:
- Permission descriptions should be complete, product-specific sentences.
- Compliance keys should be set intentionally rather than relying on implicit defaults.
- Preserve existing JSON style in this package (concise arrays and stable key grouping).
- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
Example shape:
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "...",
"NSMicrophoneUsageDescription": "..."
},
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"privacyManifests": {
"NSPrivacyAccessedAPITypes": []
}
}
}
}
```
## Common Pitfalls
- Black screen + "No script URL provided" often means a stale dev client binary.
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
## Before Finishing
- Run `bun run lint`.
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
- Confirm no accidental config side effects were introduced.
- Summarize what was verified on-device vs only in tooling.
- Dev build (internal/dev client):
- bunx eas build --profile development --platform ios
- Production build + auto-submit:
- bunx eas build --profile production --platform ios --auto-submit

View File

@@ -0,0 +1,39 @@
# Mobile Voice
Expo app for voice dictation and OpenCode session monitoring.
## Current monitoring behavior
- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
- The app registers its native APNs device token with relay route `POST /v1/device/register`.
## App requirements
- Use a development build or production build (not Expo Go).
- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
- Notification permission must be granted.
## Server entry fields in app
When adding a server, provide:
- OpenCode URL
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:
- send prompts to OpenCode
- register/unregister APNs token with relay
- receive background push updates for monitored sessions
## Local dev
```bash
npx expo start
```
Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.

View File

@@ -0,0 +1,101 @@
{
"expo": {
"name": "Control",
"slug": "control",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
"NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
"NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
"NSBonjourServices": ["_http._tcp."],
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"100.64.0.0/10": {
"NSExceptionAllowsInsecureHTTPLoads": true
},
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.CHANGE_WIFI_MULTICAST_STATE"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
},
"owner": "anomaly-co",
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Some files were not shown because too many files have changed in this diff Show More