Compare commits
1 Commits
beta
...
example-tu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4362a719 |
27
.github/workflows/porter-app-5534-apn-relay.yml
vendored
@@ -1,27 +0,0 @@
|
||||
"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 }}
|
||||
25
.opencode/plugins/tui-config-once-toast.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
|
||||
let seen = false
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id: "local.config-once-toast",
|
||||
async tui(api) {
|
||||
if (seen) return
|
||||
|
||||
const cfg = api.state.config
|
||||
if (cfg.plugin !== undefined && !Array.isArray(cfg.plugin)) {
|
||||
throw new Error("Invalid config: plugin must be an array")
|
||||
}
|
||||
|
||||
const mdl = typeof cfg.model === "string" && cfg.model.trim() ? cfg.model : "default"
|
||||
seen = true
|
||||
api.ui.toast({
|
||||
title: "Config check",
|
||||
message: `This is a 1 time toast, validating ur config (model: ${mdl})`,
|
||||
variant: "info",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"plugin": [
|
||||
"./plugins/tui-config-once-toast.tsx",
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"rules": {
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
|
||||
}
|
||||
106
AGENTS.md
@@ -1,16 +1,46 @@
|
||||
# OpenCode Monorepo Agent Guide
|
||||
- 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.
|
||||
|
||||
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
|
||||
## Style Guide
|
||||
|
||||
## Scope And Precedence
|
||||
### General Principles
|
||||
|
||||
- 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
|
||||
|
||||
### 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
|
||||
@@ -51,48 +81,48 @@ else foo = 2
|
||||
|
||||
### Control Flow
|
||||
|
||||
- Prefer early returns over nested `else` blocks.
|
||||
- Keep functions focused; split only when it improves reuse or readability.
|
||||
Avoid `else` statements. Prefer early returns.
|
||||
|
||||
### Error Handling
|
||||
```ts
|
||||
// Good
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
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.
|
||||
// Bad
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
### Data / DB
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
- 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`.
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
### Testing Philosophy
|
||||
```ts
|
||||
// Good
|
||||
const table = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
})
|
||||
|
||||
- Prefer testing real behavior over mocks.
|
||||
- Add regression tests for bug fixes where practical.
|
||||
- Keep fixtures small and focused.
|
||||
// Bad
|
||||
const table = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
projectID: text("project_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
})
|
||||
```
|
||||
|
||||
## Agent Workflow Tips
|
||||
## Testing
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
## Known Operational Notes
|
||||
## Type Checking
|
||||
|
||||
- `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.
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
||||
})
|
||||
connected = true
|
||||
break
|
||||
} catch {}
|
||||
} catch (e) {}
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
|
||||
if (evt.properties.info.id !== session.id) continue
|
||||
session = evt.properties.info
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
|
||||
async function summarize(response: string) {
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (isScheduleEvent()) {
|
||||
return "Scheduled task changes"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SECRET } from "./secret"
|
||||
import { shortDomain } from "./stage"
|
||||
import { domain, shortDomain } from "./stage"
|
||||
|
||||
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PvIx2g1J5QIUIzkz2ABaAM4K/k/+xlBPDUExoOJNNuo=",
|
||||
"aarch64-linux": "sha256-YTAL+P13L5hgNJdDSiBED/UNa5zdTntnUUYDYL+Jdzo=",
|
||||
"aarch64-darwin": "sha256-y2VCJifYAp+H0lpDcJ0QfKNMG00Q/usFElaUIpdc8Vs=",
|
||||
"x86_64-darwin": "sha256-yz8edIlqLp06Y95ad8YjKz5azP7YATPle4TcDx6lM+U="
|
||||
"x86_64-linux": "sha256-3kpnjBg7AQanyDGTOFdYBFvo9O9Rfnu0Wmi8bY5LpEI=",
|
||||
"aarch64-linux": "sha256-8rQ+SNUiSpA2Ea3NrYNGopHQsnY7Y8qBsXCqL6GMt24=",
|
||||
"aarch64-darwin": "sha256-OASMkW5hnXucV6lSmxrQo73lGSEKN4MQPNGNV0i7jdo=",
|
||||
"x86_64-darwin": "sha256-CmHqXlm8wnLcwSSK0ghxAf+DVurEltMaxrUbWh9/ZGE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
@@ -86,7 +85,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"oxlint": "1.60.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,106 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,14 +0,0 @@
|
||||
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"]
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
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 })
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function hash(input: string) {
|
||||
return createHash("sha256").update(input).digest("hex")
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -1,35 +0,0 @@
|
||||
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),
|
||||
],
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
`)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
|
||||
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
||||
this._allRows = Array.from<string>({ length: rows })
|
||||
this._allRowSeparators = Array.from<string>({ length: rows })
|
||||
this._allRows = new Array<string>(rows)
|
||||
this._allRowSeparators = new Array<string>(rows)
|
||||
this._rowIndex = 0
|
||||
|
||||
this._currentRow = ""
|
||||
|
||||
@@ -8,20 +8,14 @@ import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsPair } from "./settings-pair"
|
||||
|
||||
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
defaultValue={props.defaultTab ?? "general"}
|
||||
class="h-full settings-dialog"
|
||||
>
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full pt-3">
|
||||
@@ -51,10 +45,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="pair">
|
||||
<Icon name="link" />
|
||||
{language.t("settings.pair.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,9 +67,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pair" class="no-scrollbar">
|
||||
<SettingsPair />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -149,7 +149,7 @@ const FileTreeNode = (
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === local.active,
|
||||
...local.classList,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||
}}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
@@ -16,7 +16,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
@@ -135,7 +134,6 @@ export function SessionHeader() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
@@ -153,10 +151,6 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const search = createMemo(() => platform.platform !== "desktop" || settings.general.showSearch())
|
||||
const tree = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||
const term = createMemo(() => platform.platform !== "desktop" || settings.general.showTerminal())
|
||||
const status = createMemo(() => platform.platform !== "desktop" || settings.general.showStatus())
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -273,37 +267,35 @@ export function SessionHeader() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={search()}>
|
||||
<Show when={centerMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={centerMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||
{keybind()}
|
||||
</Keybind>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
@@ -423,28 +415,24 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={term()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
@@ -463,32 +451,30 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Show when={tree()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,6 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
@@ -280,74 +279,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showNavigation.title")}
|
||||
description={language.t("settings.general.row.showNavigation.description")}
|
||||
>
|
||||
<div data-action="settings-show-navigation">
|
||||
<Switch
|
||||
checked={settings.general.showNavigation()}
|
||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
@@ -678,10 +609,6 @@ export const SettingsGeneral: Component = () => {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { type Component, createResource, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
|
||||
|
||||
export const SettingsPair: Component = () => {
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [data] = createResource(async () => {
|
||||
const f = platform.fetch ?? fetch
|
||||
const res = await f(`${globalSDK.url}/experimental/push/pair`)
|
||||
if (!res.ok) return { enabled: false as const }
|
||||
return (await res.json()) as PairResult
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6 py-4 px-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
|
||||
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
|
||||
</div>
|
||||
|
||||
<Show when={data.loading}>
|
||||
<SettingsList>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
|
||||
<Show when={data.error}>
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Icon name="warning" size="large" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
|
||||
<span class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("settings.pair.error.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
|
||||
<Show when={!data.loading && !data.error && data()}>
|
||||
{(result) => (
|
||||
<Show
|
||||
when={result().enabled && result()}
|
||||
fallback={
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Icon name="link" size="large" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
|
||||
<span class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("settings.pair.disabled.description")}
|
||||
</span>
|
||||
</div>
|
||||
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
|
||||
opencode serve --relay-url <url> --relay-secret <secret>
|
||||
</code>
|
||||
</div>
|
||||
</SettingsList>
|
||||
}
|
||||
>
|
||||
{(pair) => (
|
||||
<SettingsList>
|
||||
<div class="flex flex-col items-center py-8 gap-4">
|
||||
<img src={(pair() as PairResult & { enabled: true }).qr} alt="Pairing QR code" class="w-64 h-64" />
|
||||
<div class="flex flex-col gap-1 text-center max-w-sm">
|
||||
<span class="text-14-medium text-text-strong">
|
||||
{language.t("settings.pair.instructions.title")}
|
||||
</span>
|
||||
<span class="text-13-regular text-text-weak">
|
||||
{language.t("settings.pair.instructions.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -634,7 +634,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
classList={{
|
||||
...local.classList,
|
||||
...(local.classList ?? {}),
|
||||
"select-text": true,
|
||||
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -11,7 +11,6 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
@@ -41,7 +40,6 @@ export function Titlebar() {
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -80,7 +78,6 @@ export function Titlebar() {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => platform.platform !== "desktop" || settings.general.showNavigation())
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -255,7 +252,7 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
@@ -290,9 +287,6 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -243,8 +243,8 @@ export function createChildStoreManager(input: {
|
||||
const cached = metaCache.get(directory)
|
||||
if (!cached) return
|
||||
const previous = store.projectMeta ?? {}
|
||||
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
|
||||
const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands
|
||||
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
|
||||
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
|
||||
const next = {
|
||||
...previous,
|
||||
...patch,
|
||||
|
||||
@@ -63,7 +63,6 @@ export function createRefreshQueue(input: QueueInput) {
|
||||
}
|
||||
} finally {
|
||||
running = false
|
||||
// oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused
|
||||
if (input.paused()) return
|
||||
if (root || queued.size) schedule()
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return
|
||||
}
|
||||
|
||||
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next }))
|
||||
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
|
||||
prune(keep)
|
||||
},
|
||||
})
|
||||
@@ -399,7 +399,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
local?.icon?.color !== undefined
|
||||
|
||||
const base = {
|
||||
...metadata,
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: {
|
||||
url: metadata?.icon?.url,
|
||||
|
||||
@@ -23,11 +23,6 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
followup: "queue" | "steer"
|
||||
showFileTree: boolean
|
||||
showNavigation: boolean
|
||||
showSearch: boolean
|
||||
showStatus: boolean
|
||||
showTerminal: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
@@ -94,11 +89,6 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showFileTree: false,
|
||||
showNavigation: false,
|
||||
showSearch: false,
|
||||
showStatus: false,
|
||||
showTerminal: false,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
@@ -172,26 +162,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||
},
|
||||
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||
setShowFileTree(value: boolean) {
|
||||
setStore("general", "showFileTree", value)
|
||||
},
|
||||
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||
setShowNavigation(value: boolean) {
|
||||
setStore("general", "showNavigation", value)
|
||||
},
|
||||
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||
setShowSearch(value: boolean) {
|
||||
setStore("general", "showSearch", value)
|
||||
},
|
||||
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||
setShowStatus(value: boolean) {
|
||||
setStore("general", "showStatus", value)
|
||||
},
|
||||
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
|
||||
@@ -28,7 +28,6 @@ export const dict = {
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
"command.pair.show": "Pair mobile device",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
@@ -720,7 +719,6 @@ export const dict = {
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
|
||||
"settings.general.section.appearance": "Appearance",
|
||||
"settings.general.section.advanced": "Advanced",
|
||||
"settings.general.section.notifications": "System notifications",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Sound effects",
|
||||
@@ -743,16 +741,6 @@ export const dict = {
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||
"settings.general.row.showTerminal.title": "Terminal",
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -869,17 +857,6 @@ export const dict = {
|
||||
"settings.providers.tag.config": "Config",
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.pair.title": "Pair",
|
||||
"settings.pair.description": "Pair a mobile device for push notifications.",
|
||||
"settings.pair.loading": "Loading pairing info...",
|
||||
"settings.pair.error.title": "Could not load pairing info",
|
||||
"settings.pair.error.description": "Check that the server is reachable and try again.",
|
||||
"settings.pair.disabled.title": "Push relay is not enabled",
|
||||
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
|
||||
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
|
||||
"settings.pair.instructions.description":
|
||||
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
|
||||
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Model settings will be configurable here.",
|
||||
"settings.agents.title": "Agents",
|
||||
|
||||
@@ -704,7 +704,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const active = new Set(visibleSessionDirs())
|
||||
for (const directory of prefetchedByDir.keys()) {
|
||||
for (const directory of [...prefetchedByDir.keys()]) {
|
||||
if (active.has(directory)) continue
|
||||
prefetchedByDir.delete(directory)
|
||||
}
|
||||
@@ -1058,13 +1058,6 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+comma",
|
||||
onSelect: () => openSettings(),
|
||||
},
|
||||
{
|
||||
id: "pair.show",
|
||||
title: language.t("command.pair.show"),
|
||||
category: language.t("command.category.settings"),
|
||||
slash: "pair",
|
||||
onSelect: () => openSettings("pair"),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: language.t("command.session.previous"),
|
||||
@@ -1217,11 +1210,11 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings(defaultTab?: string) {
|
||||
function openSettings() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
onCleanup,
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
on,
|
||||
onMount,
|
||||
untrack,
|
||||
Suspense,
|
||||
startTransition,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -325,7 +323,6 @@ export default function Page() {
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const queryClient = useQueryClient()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
@@ -524,6 +521,26 @@ export default function Page() {
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore<{
|
||||
diff: {
|
||||
git: VcsFileDiff[]
|
||||
branch: VcsFileDiff[]
|
||||
}
|
||||
ready: {
|
||||
git: boolean
|
||||
branch: boolean
|
||||
}
|
||||
}>({
|
||||
diff: {
|
||||
git: [] as VcsFileDiff[],
|
||||
branch: [] as VcsFileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
@@ -557,6 +574,68 @@ 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, list(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()
|
||||
@@ -587,52 +666,21 @@ export default function Page() {
|
||||
list.push("turn")
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const vcsKey = createMemo(
|
||||
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const mode = vcsMode()
|
||||
const enabled = wantsReview() && sync.project?.vcs === "git"
|
||||
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 60 * 1000,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
})
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||
const reviewDiffs = () => {
|
||||
if (store.changes === "git" || store.changes === "branch")
|
||||
// avoids suspense
|
||||
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return list(vcs.diff.git)
|
||||
if (store.changes === "branch") return list(vcs.diff.branch)
|
||||
return turnDiffs()
|
||||
}
|
||||
const reviewCount = () => reviewDiffs().length
|
||||
const hasReview = () => reviewCount() > 0
|
||||
const reviewReady = () => {
|
||||
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
|
||||
})
|
||||
const reviewCount = createMemo(() => reviewDiffs().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
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -852,6 +900,27 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
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 =
|
||||
@@ -985,6 +1054,13 @@ 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
|
||||
@@ -993,12 +1069,22 @@ export default function Page() {
|
||||
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
|
||||
refreshVcs()
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -74,7 +73,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -89,7 +87,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
const pickedCount = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -101,8 +98,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -431,21 +426,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -454,38 +437,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -511,137 +469,99 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && pickedCount() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && pickedCount() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{pickedCount()} answer{pickedCount() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
|
||||
@@ -19,8 +19,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
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"
|
||||
@@ -41,8 +39,6 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
@@ -50,10 +46,9 @@ export function SessionSidePanel(props: {
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
@@ -346,100 +341,98 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={shown()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<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",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
|
||||
@@ -7,10 +7,8 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
@@ -41,10 +39,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
@@ -70,7 +66,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const closableTab = tabState.closableTab
|
||||
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||
@@ -462,16 +457,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
...(shown()
|
||||
? [
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { action, useSubmission } from "@solidjs/router"
|
||||
import dock from "../asset/lander/dock.png"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(githubData()?.stars)
|
||||
}).format(githubData()?.stars!)
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||
import { For, createEffect } from "solid-js"
|
||||
import { For, Show, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
import zenLogoLight from "../../asset/zen-ornate-light.svg"
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function handler(
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...providerInfo.payloadModifier,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerInfo.payloadMappings ?? {})
|
||||
.map(([k, v]) => [k, input.request.headers.get(v)])
|
||||
@@ -345,7 +345,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch {}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
|
||||
@@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
...(content.some((c) => c.type === "text")
|
||||
...(content.length > 0 && content.some((c) => c.type === "text")
|
||||
? {
|
||||
content: content
|
||||
.filter((c) => c.type === "text")
|
||||
@@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
.join(""),
|
||||
}
|
||||
: {}),
|
||||
...(content.some((c) => c.type === "tool_use")
|
||||
...(content.length > 0 && content.some((c) => c.type === "tool_use")
|
||||
? {
|
||||
tool_calls: content
|
||||
.filter((c) => c.type === "tool_use")
|
||||
|
||||
@@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
|
||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { Actor } from "../src/actor.js"
|
||||
|
||||
const plan = "200"
|
||||
const couponID = "JAIr0Pe1"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
@@ -24,9 +24,11 @@ export namespace Key {
|
||||
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(
|
||||
and(
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
isNull(KeyTable.timeDeleted),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
...[
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
isNull(KeyTable.timeDeleted),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
],
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${KeyTable.name} DESC`),
|
||||
@@ -82,9 +84,11 @@ export namespace Key {
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(KeyTable.id, input.id),
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
...[
|
||||
eq(KeyTable.id, input.id),
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {}
|
||||
|
||||
@@ -48,7 +48,7 @@ export namespace Log {
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use()
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return { tags: {} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
||||
try {
|
||||
if (path.startsWith("~")) {
|
||||
const suffix = path.slice(1)
|
||||
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
|
||||
const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"`
|
||||
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
|
||||
return output.toString().trim()
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function loadShellEnv(shell: string) {
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...shell,
|
||||
...(shell || {}),
|
||||
...env,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test, afterAll } from "bun:test"
|
||||
import { Share } from "../../src/core/share"
|
||||
import { Storage } from "../../src/core/storage"
|
||||
import { Identifier } from "@opencode-ai/shared/util/identifier"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
43
packages/mobile-voice/.gitignore
vendored
@@ -1,43 +0,0 @@
|
||||
# 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
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Control",
|
||||
"slug": "control",
|
||||
"version": "1.0.2",
|
||||
"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": "#1a1a1a",
|
||||
"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.2",
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 755 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 608 B |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 215 B |
|
Before Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 468 B |
|
Before Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 343 B |