feat: add daily product intelligence and versioned site assets (#64)

Adds privacy-safe aggregate product intelligence, reviewed/versioned website assets, and a dispatch-only rollout until the dedicated Sentry token is verified. Independent review blockers were fixed in 8bc47e4; app checks, website production build, Android CI, and iOS CI are green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Den
2026-07-15 11:59:40 -07:00
committed by GitHub
parent cbe00c3222
commit 5c14ce0a5d
46 changed files with 4539 additions and 1 deletions

View File

@@ -0,0 +1,126 @@
name: Daily Product Intelligence
on:
workflow_dispatch:
permissions:
actions: read
contents: read
issues: write
concurrency:
group: product-intelligence-${{ github.ref }}
cancel-in-progress: false
jobs:
report:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Collect aggregate product signals
id: collect
continue-on-error: true
env:
GITHUB_TOKEN: ${{ github.token }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
node scripts/product-intelligence.mjs \
--report "$RUNNER_TEMP/product-intelligence.md" \
--json "$RUNNER_TEMP/product-intelligence.json"
- name: Create material-signal issue
if: always()
uses: actions/github-script@v8
env:
REPORT_PATH: ${{ runner.temp }}/product-intelligence.json
with:
script: |
const fs = require("fs")
if (!fs.existsSync(process.env.REPORT_PATH)) {
core.setFailed("Product intelligence collector produced no report.")
return
}
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, "utf8"))
const allowedSignals = new Set([
"new-sentry-issue",
"repeated-workflow-failure",
])
const validDate = typeof report.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(report.date)
const validSignals = Array.isArray(report.signals) &&
report.signals.length > 0 &&
report.signals.every((signal) => allowedSignals.has(signal))
if (!report.material) {
core.info("No material signal. No GitHub issue created.")
return
}
if (!validDate || !validSignals) {
core.setFailed("Collector report failed public issue allowlist validation.")
return
}
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
})
const title = "Product intelligence: material signal"
const marker = `<!-- product-intelligence:${report.date}:${[...report.signals].sort().join(",")} -->`
const body = [
marker,
"",
"## Material aggregate signal",
"",
`The daily product-intelligence run detected: ${[...report.signals].sort().join(", ")}.`,
"",
"This issue intentionally contains no raw diagnostic, review, request, or user-generated content. Review the sanitized Actions artifact and reproduce the behavior in the affected user channel before implementing a fix.",
].join("\n")
const existing = issues.find((issue) => issue.title === title)
if (existing) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
title,
body,
})
core.info(`Updated #${existing.number}.`)
return
}
const { data: issue } = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ["P1"],
})
core.info(`Created #${issue.number}.`)
- name: Upload sanitized report
if: always()
uses: actions/upload-artifact@v7
with:
name: product-intelligence-${{ github.run_id }}
path: |
${{ runner.temp }}/product-intelligence.md
${{ runner.temp }}/product-intelligence.json
if-no-files-found: error
- name: Fail when a required source is unavailable
if: steps.collect.outcome == 'failure'
run: |
echo "::error::A required product-intelligence source was unavailable. See the report summary for the exact source and error."
exit 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

View File

@@ -0,0 +1,80 @@
{
"schemaVersion": 1,
"source": {
"platform": "Google Play",
"listing": "https://play.google.com/store/apps/details?id=cc.agentlabs.opencode&hl=en_US",
"packageName": "cc.agentlabs.opencode",
"retrievedAt": "2026-07-14",
"ownership": "OpenCode Mobile listing assets"
},
"assetPolicy": {
"purpose": "Canonical reference assets for the OpenCode Mobile website and future store-listing updates.",
"doNotModify": "Do not crop, add marketing copy, or otherwise alter these files in place. Create a separately named derivative for a channel-specific composition.",
"websiteSource": "Copy selected originals into website/public/screenshots/ during the website deployment change."
},
"screenshots": [
{
"file": "01.png",
"sourceUrl": "https://play-lh.googleusercontent.com/0TndhVWCZHGC0UpZNEWBJfhAsRI1NPeg5aevMqdTTEmCQ1zE-HPwMVt0eGxxW2ljPAhGynebJ346MKP2JYAf9YU",
"sha256": "9c2c1648458d52e9fdc6f9b45777d0fbf20f07fa3369e7aa747b708c4c7a5102",
"width": 1280,
"height": 2560,
"feature": "Model and settings selection",
"websitePlacement": "gallery-07"
},
{
"file": "02.png",
"sourceUrl": "https://play-lh.googleusercontent.com/l9Q1182qqZIJ6fSoXUMmlctaDMAt6RJbgZYVUA4akx2Fd7UKQ5R8MH0jbWGFI0_RVf-vBQMiTTixrTANWJKWpw",
"sha256": "7928699bd4ea3c64d22bce60d05854d09699bf74b23f8390e62ed900fdf0d1e8",
"width": 1280,
"height": 2560,
"feature": "Live tool activity",
"websitePlacement": "gallery-04"
},
{
"file": "03.png",
"sourceUrl": "https://play-lh.googleusercontent.com/m1EkbhW1W17NNt1wyMOl7pGpdRgzwbULsxkrZ-KVuVlppTnYfN6dmXWmIoy2a8obp3uY6yg3LCuDN5_obHopTiQ",
"sha256": "8987a963d708563f5ff2c0f1c127d852bcbd3759cd9eef2b462cee4d0d8e8658",
"width": 1280,
"height": 2560,
"feature": "Streaming task conversation",
"websitePlacement": "hero-02"
},
{
"file": "04.png",
"sourceUrl": "https://play-lh.googleusercontent.com/n3CtQbxCaV9V715VC9Dk5LYqP-ufQr2TA9Jsm-iKowTgN562_SWX663a_FpS1iGhAIFJ1QCSRAz5jtEinuosig",
"sha256": "d7af61652e9a1521457e87c54c1702a1318e811201a46c40af9b59b85e09e2a2",
"width": 1280,
"height": 2560,
"feature": "Connection setup",
"websitePlacement": "hero-01"
},
{
"file": "05.png",
"sourceUrl": "https://play-lh.googleusercontent.com/nkYKYJzFD3Q3hVHpsUUhbd6ICirWLUYMcuxTK3Tg_QiCA3YPI_8PZiepI98BY9gddaWzF3xBZEDHmEo15dfyBQ",
"sha256": "6acbbd87f4f48e5cf53deddd562c2099f8bab892bbd8f0bb00aac24e6f63ed85",
"width": 1280,
"height": 2560,
"feature": "Session list",
"websitePlacement": "gallery-02"
},
{
"file": "06.png",
"sourceUrl": "https://play-lh.googleusercontent.com/npsXfCkwCWEqJ9o56ZCENBTTrvefgnA54fGQLc6L1USc6wp9oFsfC_m_orBZ_hsZsE7pUuCDl51QSvkLy7w7m8c",
"sha256": "4d68454a602be5546085ceea7f261aadb332973721bde3123d01304dc95d1651",
"width": 1280,
"height": 2560,
"feature": "Agent writes code",
"websitePlacement": "gallery-05"
},
{
"file": "07.png",
"sourceUrl": "https://play-lh.googleusercontent.com/zXUoTydFFH3U-Hc1hemnXFaxluRrWdDuRweatkuc5zRQQH1f806lN7ZY_go23OMxs5E5-7rD5JOZHlI-M9ACcQ",
"sha256": "02be91c4ab35e5ab30f7e310ea5bcf98e0485b14b7d3bbe7f161a38e1642c427",
"width": 1280,
"height": 2560,
"feature": "Completed coding task",
"websitePlacement": "gallery-06"
}
]
}

View File

@@ -119,3 +119,76 @@ git push origin main --tags
- Multi-user / shared sessions. - Multi-user / shared sessions.
- Voice input beyond what the OS dictation keyboard provides. - Voice input beyond what the OS dictation keyboard provides.
- iPad / tablet-optimised layout (phone-first only; tablet works but is not designed for). - iPad / tablet-optimised layout (phone-first only; tablet works but is not designed for).
## 8. Continuous Product Intelligence
### Problem
At 1,000 downloads, acquisition, reliability, feedback, and visual-conversion
signals are fragmented across Sentry, GitHub, Google Play, and Vercel. The app
has opted-in diagnostics but no daily operating loop that turns aggregate,
privacy-safe evidence into a small, prioritized queue. The public Vercel site
also deploys from an untracked Next.js working directory, so its screenshots
cannot be traced to a reviewed source commit.
### Scope
- A daily GitHub Action, triggered only by `schedule` and maintainer
`workflow_dispatch`, that publishes an aggregate-only product-intelligence
report to the Actions summary and artifact.
- Rollout starts with maintainer dispatch only. Enable the daily schedule after
the dedicated read-only Sentry token succeeds against production.
- A material-signal issue upsert: create or update a GitHub issue only when a
defined threshold is crossed, never one issue per uneventful day.
- A metric contract covering acquisition, activation, reliability, retention,
feedback, and visual conversion, with explicit data sources and gaps.
- Sentry diagnostic payloads that match the consent copy: no server address,
port, URL, credential, code, prompt, session title, or directory leaves the
device.
- Versioned Play screenshots, checksums, provenance, and intended website
placement in this repository.
- A versioned Next.js source for the Vercel site, replacing its untracked
working copy before future visual changes.
### Non-Goals
- Automatic code changes, merges, releases, deployments, or Play Console edits
from a daily report.
- Sending raw Sentry issue text, review body text, reviewer names, device
details, or user-generated content to a GitHub issue or Actions artifact.
- Treating a download count as active-user, activation, or retention evidence.
- Adding product-usage analytics without a separate explicit consent,
disclosure, and Data Safety review.
### Metric Contract
| Decision | Metric | Current source | Gap / next step |
| --- | --- | --- | --- |
| Is acquisition growing? | Play listing visitors, installs, uninstall rate, release APK downloads, repo views, stars | Play Console; GitHub Releases and Traffic APIs | Automate GitHub now; add read-only Play reporting only after access is verified. |
| Do new users reach value? | Connection success, time to first connection, sessions loaded, first prompt, first response | Sentry records opted-in failures only | Design explicit opt-in aggregate activation measurement before implementation. |
| Is app reliable? | Crash-free sessions, unresolved/new/regressed Sentry issues, error volume by release, connection classification, CUA pass rate | Sentry Release Health / Issues; GitHub Actions | Daily report initially aggregates Sentry Issues only; add Release Health after its aggregate query is verified. |
| Do users return? | 7-day / 30-day returning active installations | None | Do not infer this. Evaluate a privacy-reviewed aggregate telemetry design. |
| What should be fixed next? | Low-rating review themes, GitHub issue themes, issue age, CI failures | Android Publisher reviews script; GitHub Issues; Actions | Repair review ingestion and dedupe before enabling it. |
| Does site convert? | Unique visitors, install/beta CTA clicks, screenshot engagement, listing conversion | Vercel Analytics; Play Console | Define aggregate event names and exports; do not fingerprint visitors. |
### Acceptance Criteria
- [ ] A manual or scheduled daily run creates or updates exactly one report for
its UTC day in its Actions summary/artifact, exposes source freshness, and
distinguishes unavailable data from zero.
- [ ] The first report populates only GitHub Traffic, Releases, Issues, and
Actions plus Sentry Issues aggregates. Release Health, activation, retention,
Play, review-theme, and site-conversion rows appear as `deferred` with their
reason, never zero or blank.
- [ ] Scheduled runs create a GitHub issue only for a documented material
threshold; otherwise they update no public issue and require no daily manual
review.
- [ ] No Sentry payload of any kind - event, breadcrumb, tag, context,
transaction/span name, or `serverName` - contains a server host, port, URL,
credential, prompt, code, session title, or directory.
- [ ] Seven full-resolution Play screenshots are versioned with source URL,
retrieval date, SHA-256, dimensions, and website placement.
- [ ] `https://opencode.agentlabs.cc` serves the approved screenshots from
versioned source after its Vercel deployment.
- [ ] Material signals become implementation issues only after a maintainer
reproduces them in the affected user channel.

View File

@@ -182,3 +182,74 @@ A test for this would feed a basic-auth URL into a fake event and assert the scr
- **No fallback UI for SSE disconnect.** Today the user sees the existing chat with a (small) banner; a more deliberate "Reconnecting…" affordance would help. - **No fallback UI for SSE disconnect.** Today the user sees the existing chat with a (small) banner; a more deliberate "Reconnecting…" affordance would help.
- **Silent `.catch(() => null)` in stores.** Intentional today (these are non-critical fetches), but should be revisited once we have proper severity tiers for breadcrumbs. - **Silent `.catch(() => null)` in stores.** Intentional today (these are non-critical fetches), but should be revisited once we have proper severity tiers for breadcrumbs.
- **PRD analytics.** No usage analytics; only crash telemetry. A future opt-in product-analytics provider could close that loop without compromising the privacy posture. - **PRD analytics.** No usage analytics; only crash telemetry. A future opt-in product-analytics provider could close that loop without compromising the privacy posture.
## 10. Continuous Product Intelligence
```text
Maintainer manual dispatch
|
v
scripts/product-intelligence.mjs
| GitHub REST: releases, traffic, issues, Actions
| Sentry REST: aggregate unresolved/new issue health
| (future) Android Publisher: aggregate review and Play signals
|
v
sanitized Markdown report + JSON evidence
|
v
Actions summary + artifact per UTC day
|
v
material threshold -> deduplicated GitHub issue -> reviewed PR
```
### Components
| Component | Location | Purpose |
| --- | --- | --- |
| Product-intelligence workflow | `.github/workflows/product-intelligence.yml` | Starts with maintainer dispatch only. Enable its cron after the dedicated Sentry token passes a production run. Uses `actions: read`, `contents: read`, and `issues: write`. |
| Collector | `scripts/product-intelligence.mjs` | Validates sources, collects aggregate signals, renders a sanitized report, and fails visibly when a source is unavailable. |
| Material issue upsert | Workflow `actions/github-script` | Locates a stable signal fingerprint and creates or updates an issue only after a material threshold. |
| Sentry privacy implementation | `src/lib/sentry.ts`, `src/lib/scrub.ts` | Separate P1 prerequisite: remove user-controlled server data from events, breadcrumbs, tags, contexts, and span names before an event leaves device. |
| Screenshot source of truth | `distribution/reference-screenshots/play-v1/manifest.json` | Records Play asset provenance, checksum, dimensions, and page placement. |
| Vercel website | `website/` | Versioned Next.js source deployed to existing `opencode-mobile-site` project. |
### Data and Privacy Boundaries
- The initial collector contains numeric aggregates only. It must not publish
Sentry issue titles, exception text, culprits, request URL, device metadata,
raw review content, author names, or user-generated text.
- The material-issue upsert independently reconstructs its body from an
allowlisted UTC date and allowlisted signal names. It rejects malformed
report data and never uses collector-provided Markdown, URLs, or Sentry
response fields.
- A missing credential or failed request is reported as unavailable and exits
non-zero; it must never become a success-shaped zero.
- The first report supports only GitHub and Sentry aggregates. Every other
metric-contract row is rendered as `deferred` with its source/access reason.
- The first delivery introduces no new analytics SDK or user identifier.
- Sentry remains explicitly opt-in. The consent UI, Settings UI, privacy
policy, and runtime scrubber must agree on what leaves the device.
- Future activation and retention metrics require a separate product decision
that updates consent language and Play Data Safety before code ships.
- The `SENTRY_PRODUCT_INTELLIGENCE_TOKEN` repository secret is a read-only,
single-project Sentry token with only `project:read`, `event:read`, and
`org:read` scopes; it is distinct from release-upload credentials. Any
future Android Publisher credential is read-only and limited to
reporting/reviews.
### Rollout
1. Provision the dedicated read-only Sentry repository secret, then manually
run the daily workflow against production credentials and verify a
sanitized Actions summary and artifact for its UTC date.
2. Enable the cron, then observe one scheduled run and verify it creates a new UTC-day artifact but
no public issue unless a documented material threshold is crossed.
Back-to-back manual and scheduled runs must update at most one open
material-signal issue.
3. Merge the Sentry privacy boundary before exposing Sentry detail links.
4. Import and manifest Play screenshots, deploy versioned web source, and
verify every screenshot loads at the public domain.
5. Enable Android Publisher review/Play data collection only after
least-privilege credentials and real-data validation are complete.

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
const DAY = 24 * 60 * 60 * 1000
const WEEK = 7 * DAY
const GITHUB_API = "https://api.github.com"
const SENTRY_API = "https://sentry.io/api/0"
function args(values) {
const result = {}
for (let index = 0; index < values.length; index += 2) {
const key = values[index]
const value = values[index + 1]
if (!key?.startsWith("--") || !value) throw new Error(`Expected --name value, received ${key ?? "<none>"}`)
result[key.slice(2)] = value
}
return result
}
function unavailable(reason) {
return { status: "unavailable", reason }
}
function available(data) {
return { status: "available", data }
}
async function request(source, url, headers) {
try {
const response = await fetch(url, { headers })
if (!response.ok) return unavailable(`${source} returned HTTP ${response.status}`)
return available(await response.json())
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return unavailable(`${source} request failed: ${message}`)
}
}
function githubHeaders(token) {
return {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"User-Agent": "opencode-mobile-product-intelligence",
"X-GitHub-Api-Version": "2022-11-28",
}
}
function sum(values, key) {
return values.reduce((total, value) => total + Number(value[key] ?? 0), 0)
}
function recent(values, key, since) {
return values.filter((value) => Date.parse(value[key] ?? "") >= since)
}
async function collectGithub(token, repo, now) {
if (!token) return unavailable("GITHUB_TOKEN is not set")
const headers = githubHeaders(token)
const base = `${GITHUB_API}/repos/${repo}`
const issueQuery = encodeURIComponent(`repo:${repo} is:issue is:open`)
const [repository, releases, issues, workflows, views, clones] = await Promise.all([
request("GitHub repository", base, headers),
request("GitHub releases", `${base}/releases?per_page=100`, headers),
request("GitHub issue count", `${GITHUB_API}/search/issues?q=${issueQuery}`, headers),
request("GitHub workflow runs", `${base}/actions/runs?per_page=100`, headers),
request("GitHub traffic views", `${base}/traffic/views`, headers),
request("GitHub traffic clones", `${base}/traffic/clones`, headers),
])
const required = [repository, releases, issues, workflows]
const failed = required.find((source) => source.status !== "available")
if (failed) return unavailable(failed.reason)
const releaseDownloads = releases.data.reduce(
(total, release) => total + sum(release.assets ?? [], "download_count"),
0,
)
const issueCount = Number(issues.data.total_count ?? 0)
const runs = workflows.data.workflow_runs ?? []
const failedWorkflowRuns7d = recent(runs, "created_at", now - WEEK).filter((run) => {
const path = String(run.path ?? "")
return run.conclusion === "failure" && path !== ".github/workflows/product-intelligence.yml"
}).length
return available({
stars: Number(repository.data.stargazers_count ?? 0),
forks: Number(repository.data.forks_count ?? 0),
openIssues: issueCount,
releaseDownloads,
failedWorkflowRuns7d,
traffic: {
views:
views.status === "available"
? {
count: Number(views.data.count ?? 0),
uniques: Number(views.data.uniques ?? 0),
}
: unavailable(views.reason),
clones:
clones.status === "available"
? {
count: Number(clones.data.count ?? 0),
uniques: Number(clones.data.uniques ?? 0),
}
: unavailable(clones.reason),
},
})
}
async function collectSentry(token, organization, project, now) {
if (!token || !organization || !project) {
return unavailable("SENTRY_AUTH_TOKEN, SENTRY_ORG, or SENTRY_PROJECT is not set")
}
const url = new URL(`${SENTRY_API}/projects/${organization}/${project}/issues/`)
url.searchParams.set("query", "is:unresolved")
url.searchParams.set("statsPeriod", "14d")
url.searchParams.set("sort", "date")
url.searchParams.set("limit", "100")
const response = await request("Sentry issues", url, {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": "opencode-mobile-product-intelligence",
})
if (response.status !== "available") return response
const issues = response.data
const newIssues24h = recent(issues, "firstSeen", now - DAY).length
const newIssues = recent(issues, "firstSeen", now - WEEK)
return available({
unresolvedIssues: issues.length,
newIssues24h,
newIssues7d: newIssues.length,
eventCount: sum(issues, "count"),
})
}
function sourceLine(name, source) {
if (source.status === "available") return `| ${name} | available | current run |`
return `| ${name} | unavailable | ${source.reason} |`
}
function metric(value) {
return Number.isFinite(value) ? String(value) : "unavailable"
}
function trafficMetric(value) {
if (value?.status === "unavailable") return `unavailable (${value.reason})`
return `${metric(value.count)} total / ${metric(value.uniques)} unique`
}
function render(report) {
const github = report.github.status === "available" ? report.github.data : null
const sentry = report.sentry.status === "available" ? report.sentry.data : null
const state = report.material ? "material signal detected" : "no material signal detected"
const lines = [
`# Daily Product Intelligence - ${report.date}`,
"",
`**Status:** ${state}`,
"",
"## Source freshness",
"",
"| Source | Status | Detail |",
"| --- | --- | --- |",
sourceLine("GitHub", report.github),
sourceLine("Sentry", report.sentry),
"",
"## Current aggregate signals",
"",
"| Metric | Value |",
"| --- | --- |",
`| GitHub stars | ${github ? metric(github.stars) : "unavailable"} |`,
`| GitHub forks | ${github ? metric(github.forks) : "unavailable"} |`,
`| Open GitHub issues | ${github ? metric(github.openIssues) : "unavailable"} |`,
`| GitHub release asset downloads | ${github ? metric(github.releaseDownloads) : "unavailable"} |`,
`| GitHub repository views (14-day window) | ${github ? trafficMetric(github.traffic.views) : "unavailable"} |`,
`| GitHub repository clones (14-day window) | ${github ? trafficMetric(github.traffic.clones) : "unavailable"} |`,
`| Failed GitHub workflow runs (7 days) | ${github ? metric(github.failedWorkflowRuns7d) : "unavailable"} |`,
`| Sentry unresolved issues returned (14-day query, max 100) | ${sentry ? metric(sentry.unresolvedIssues) : "unavailable"} |`,
`| Sentry newly seen issues returned (24 hours, max 100) | ${sentry ? metric(sentry.newIssues24h) : "unavailable"} |`,
`| Sentry newly seen issues returned (7 days, max 100) | ${sentry ? metric(sentry.newIssues7d) : "unavailable"} |`,
`| Sentry events across returned unresolved issues | ${sentry ? metric(sentry.eventCount) : "unavailable"} |`,
"",
"## Deferred metrics",
"",
"| Metric | Status | Reason |",
"| --- | --- | --- |",
"| Sentry release health | deferred | Requires a verified aggregate release-health query contract. |",
"| Play acquisition and uninstall metrics | deferred | Requires a verified least-privilege reporting source. |",
"| Play review themes | deferred | Review ingestion needs privacy-safe dedupe and redaction. |",
"| Activation funnel | deferred | Requires explicit product-usage consent and disclosure review. |",
"| Retention | deferred | No consent-safe active-install measurement is implemented. |",
"| Website conversion | deferred | Vercel Analytics export contract is not implemented. |",
"",
"## Triage rule",
"",
"A single deduplicated implementation issue is created only when at least one Sentry issue is newly seen in 24 hours or two or more non-monitor GitHub workflow runs failed in seven days. This report intentionally contains no raw diagnostic, review, request, or user-generated content.",
]
return `${lines.join("\n")}\n`
}
async function write(path, contents) {
if (!path) return
await mkdir(dirname(path), { recursive: true })
await writeFile(path, contents)
}
async function main() {
const options = args(process.argv.slice(2))
const now = Date.now()
const date = new Date(now).toISOString().slice(0, 10)
const repo = process.env.GITHUB_REPOSITORY ?? "dzianisv/opencode-mobile"
const [github, sentry] = await Promise.all([
collectGithub(process.env.GITHUB_TOKEN, repo, now),
collectSentry(process.env.SENTRY_AUTH_TOKEN, process.env.SENTRY_ORG, process.env.SENTRY_PROJECT, now),
])
const githubData = github.status === "available" ? github.data : null
const sentryData = sentry.status === "available" ? sentry.data : null
const signals = []
if (sentryData?.newIssues24h >= 1) signals.push("new-sentry-issue")
if (githubData?.failedWorkflowRuns7d >= 2) signals.push("repeated-workflow-failure")
const report = {
date,
generatedAt: new Date(now).toISOString(),
repo,
github,
sentry,
material: signals.length > 0,
signals,
}
const markdown = render(report)
await write(options.report, markdown)
await write(options.json, `${JSON.stringify(report, null, 2)}\n`)
if (process.env.GITHUB_STEP_SUMMARY) await write(process.env.GITHUB_STEP_SUMMARY, markdown)
console.log(`Product intelligence report written for ${date}.`)
if (github.status !== "available" || sentry.status !== "available") {
process.exitCode = 1
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error)
console.error(`Product intelligence failed: ${message}`)
process.exitCode = 1
})

View File

@@ -7,5 +7,5 @@
} }
}, },
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"] "exclude": ["**/*.test.ts", "**/*.test.tsx", "website"]
} }

3
website/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.next
.vercel

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
export const runtime = 'edge'
export async function POST(request: NextRequest) {
try {
const { email } = await request.json()
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
}
const normalized = email.trim().toLowerCase()
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
}
// Log for Vercel Function Logs (always available)
console.log(JSON.stringify({
event: 'beta_signup',
email: normalized,
timestamp: new Date().toISOString(),
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
}))
// If a webhook URL is configured, forward the signup there
const webhookUrl = process.env.BETA_SIGNUP_WEBHOOK_URL
if (webhookUrl) {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalized, source: 'opencode-mobile-beta' }),
})
} catch (e) {
console.error('[beta-signup] Webhook failed:', e)
}
}
return NextResponse.json({ message: 'Signed up successfully' })
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Join the Beta',
description:
'Sign up as a beta tester for OpenCode Mobile on Google Play. Help us test, find bugs, and shape the product before public launch.',
alternates: { canonical: 'https://opencode.agentlabs.cc/beta' },
openGraph: {
title: 'Join the OpenCode Mobile Beta',
description:
'Get early access to OpenCode Mobile on Google Play. We need 20 testers to unlock the public release.',
url: 'https://opencode.agentlabs.cc/beta',
},
}
export default function BetaLayout({ children }: { children: React.ReactNode }) {
return children
}

184
website/app/beta/page.tsx Normal file
View File

@@ -0,0 +1,184 @@
'use client'
import { useState, FormEvent } from 'react'
export default function BetaPage() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('')
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setStatus('loading')
try {
const res = await fetch('/api/beta-signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (res.ok) {
setStatus('success')
setMessage('You\'re in! We\'ll add you to the closed beta testers list.')
setEmail('')
} else {
const data = await res.json()
setStatus('error')
setMessage(data.error || 'Something went wrong. Please try again.')
}
} catch {
setStatus('error')
setMessage('Network error. Please try again.')
}
}
return (
<>
{/* Hero */}
<section className="pt-24 pb-16 md:pt-36 md:pb-24">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<p className="text-xs font-mono uppercase tracking-wider mb-6" style={{ color: 'var(--muted)' }}>
Closed beta &middot; Limited spots
</p>
<h1 className="text-4xl sm:text-5xl font-bold leading-[1.1] tracking-tight mb-6" style={{ color: 'var(--fg)' }}>
Get early access
</h1>
<p className="text-lg leading-relaxed mb-10" style={{ color: 'var(--muted)' }}>
Sign up to join the OpenCode Mobile closed beta on Google Play.
We need <span style={{ color: 'var(--fg)' }}>20 testers</span> to unlock the public release.
</p>
{/* Signup Form */}
{status === 'success' ? (
<div className="card">
<div className="flex items-center gap-3 mb-3">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--green)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<p className="font-semibold" style={{ color: 'var(--fg)' }}>You&apos;re on the list</p>
</div>
<p className="text-sm" style={{ color: 'var(--muted)' }}>
{message}
</p>
<p className="mt-3 text-sm" style={{ color: 'var(--muted)' }}>
We&apos;ll send you a Google Play beta invite within 24 hours.
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3">
<input
type="email"
required
placeholder="your@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1 px-4 py-3 rounded-lg text-sm outline-none transition-colors"
style={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
color: 'var(--fg)',
}}
disabled={status === 'loading'}
/>
<button
type="submit"
className="btn-primary whitespace-nowrap text-sm"
disabled={status === 'loading'}
>
{status === 'loading' ? 'Signing up...' : 'Join beta'}
</button>
</form>
)}
{status === 'error' && (
<p className="mt-3 text-sm" style={{ color: '#EF4444' }}>
{message}
</p>
)}
<p className="mt-4 text-xs" style={{ color: 'var(--muted)' }}>
Use the Gmail linked to your Google Play account. No spam, no commitment.
</p>
</div>
</section>
{/* What you get */}
<section className="py-20 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl font-bold mb-8" style={{ color: 'var(--fg)' }}>
What beta testers get
</h2>
<div className="space-y-6">
{[
{
title: 'Early access',
desc: 'Install from Google Play before anyone else. Get updates as we ship them.',
},
{
title: 'Direct feedback channel',
desc: 'Report bugs and request features directly to the dev team.',
},
{
title: 'Shape the product',
desc: 'Your input decides what we build next. Beta feedback has outsized impact.',
},
].map((item) => (
<div key={item.title} className="flex gap-4">
<span className="text-sm font-mono shrink-0 mt-0.5" style={{ color: 'var(--muted)' }}>[*]</span>
<div>
<h3 className="font-semibold mb-1" style={{ color: 'var(--fg)' }}>{item.title}</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--muted)' }}>{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-20 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl font-bold mb-8" style={{ color: 'var(--fg)' }}>
FAQ
</h2>
<div className="space-y-0">
{[
{
q: 'What is OpenCode Mobile?',
a: 'A free, open-source mobile client for the opencode AI coding agent. Connect to your own server and code from anywhere.',
},
{
q: 'Why do you need my email?',
a: 'Google Play requires us to add tester email addresses explicitly. We use the same Gmail you use on your Android device.',
},
{
q: 'Is it free?',
a: 'Yes. Free and open source, MIT license. No in-app purchases, no ads.',
},
{
q: 'When does the public release happen?',
a: 'Google requires 14 days of closed testing with at least 20 testers. The sooner we hit 20, the sooner we launch.',
},
{
q: 'What Android version?',
a: 'Android 8.0 (API 26) or newer.',
},
].map((faq) => (
<div key={faq.q} className="py-5 border-b" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--fg)' }}>
{faq.q}
</h3>
<p className="text-sm" style={{ color: 'var(--muted)' }}>
{faq.a}
</p>
</div>
))}
</div>
</div>
</section>
</>
)
}

106
website/app/docs/page.tsx Normal file
View File

@@ -0,0 +1,106 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Documentation',
description:
'OpenCode Mobile documentation. Quick start guide, API reference, and self-hosting instructions.',
alternates: { canonical: 'https://opencode.agentlabs.cc/docs' },
openGraph: {
title: 'Documentation | OpenCode Mobile',
description: 'OpenCode Mobile docs — coming soon. See README on GitHub for now.',
url: 'https://opencode.agentlabs.cc/docs',
},
}
const quickLinks = [
{
title: 'README',
description: 'Installation, building from source, and quick start guide.',
href: 'https://github.com/dzianisv/opencode-mobile#readme',
external: true,
},
{
title: 'Releases',
description: 'Download APKs and view changelog for every release.',
href: 'https://github.com/dzianisv/opencode-mobile/releases',
external: true,
},
{
title: 'Bug Reports',
description: 'Found a bug? Open an issue on GitHub.',
href: 'https://github.com/dzianisv/opencode-mobile/issues/new',
external: true,
},
{
title: 'Contributing',
description: 'Want to contribute? See CONTRIBUTING.md in the repo.',
href: 'https://github.com/dzianisv/opencode-mobile/blob/main/CONTRIBUTING.md',
external: true,
},
]
export default function DocsPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-16">
<div className="w-20 h-20 rounded-2xl flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: 'rgba(59,130,246,0.15)', color: 'var(--accent)' }}>
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--fg)' }}>
Documentation
</h1>
<p className="text-lg max-w-xl mx-auto" style={{ color: 'var(--muted)' }}>
A full documentation site is in progress. For now, everything you need is in the
GitHub repository.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5 mb-16">
{quickLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noopener noreferrer' : undefined}
className="card hover:border-blue-500/60 transition-colors"
style={{ textDecoration: 'none' }}
>
<h3 className="font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--fg)' }}>
{link.title}
{link.external && (
<svg className="w-3.5 h-3.5 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
)}
</h3>
<p className="text-sm" style={{ color: 'var(--muted)' }}>{link.description}</p>
</a>
))}
</div>
<div className="rounded-xl p-6 text-center" style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.25)' }}>
<h2 className="font-semibold mb-2" style={{ color: 'var(--fg)' }}>
Full docs site coming soon
</h2>
<p className="text-sm mb-4" style={{ color: 'var(--muted)' }}>
We&apos;re building a proper docs site with guides for connecting, self-hosting, contributing,
and the opencode Cloud. Want to help write docs?
</p>
<a
href="https://github.com/dzianisv/opencode-mobile/discussions"
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm"
>
Join the Discussion
</a>
</div>
</div>
)
}

129
website/app/globals.css Normal file
View File

@@ -0,0 +1,129 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--bg: #000000;
--fg: #F1ECEC;
--accent: #F1ECEC;
--accent-hover: #ffffff;
--muted: #8E8B8B;
--card: #0A0A0A;
--border: #1F1F1F;
--green: #03B000;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
color: var(--fg);
font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold transition-all;
background-color: var(--fg);
color: var(--bg);
}
.btn-primary:hover {
background-color: #ffffff;
transform: translateY(-1px);
}
.btn-secondary {
@apply inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold transition-all;
border: 1px solid var(--border);
color: var(--fg);
}
.btn-secondary:hover {
background-color: var(--card);
border-color: #333;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
@apply rounded-xl p-6;
}
.card:hover {
border-color: #333;
}
.prose-site h2 {
@apply text-2xl font-bold mt-10 mb-4;
color: var(--fg);
border-left: 4px solid var(--fg);
padding-left: 12px;
}
.prose-site h3 {
@apply text-xl font-semibold mt-8 mb-3;
color: var(--fg);
}
.prose-site p {
@apply mb-4 leading-relaxed;
color: var(--muted);
}
.prose-site ul {
@apply mb-4 ml-6 space-y-2;
color: var(--muted);
}
.prose-site ul li {
list-style-type: disc;
}
.prose-site a {
color: var(--fg);
text-decoration: underline;
text-underline-offset: 3px;
}
.prose-site a:hover {
opacity: 0.8;
}
.prose-site table {
@apply w-full text-sm mb-6;
border-collapse: collapse;
}
.prose-site th {
@apply px-4 py-2 text-left font-semibold;
background-color: var(--card);
border: 1px solid var(--border);
color: var(--fg);
}
.prose-site td {
@apply px-4 py-2 align-top;
border: 1px solid var(--border);
color: var(--muted);
}
.prose-site tr:nth-child(even) td {
background-color: rgba(10, 10, 10, 0.5);
}
/* Install command box */
.install-box {
@apply flex items-center gap-3 px-5 py-4 rounded-lg font-mono text-sm;
background-color: var(--card);
border: 1px solid var(--border);
color: var(--fg);
}
}

98
website/app/layout.tsx Normal file
View File

@@ -0,0 +1,98 @@
import type { Metadata } from 'next'
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import './globals.css'
import Nav from '@/components/Nav'
import Footer from '@/components/Footer'
export const metadata: Metadata = {
metadataBase: new URL('https://opencode.agentlabs.cc'),
title: {
default: 'OpenCode Mobile — Your AI Coding Agent, On the Go',
template: '%s | OpenCode Mobile',
},
description:
'OpenCode Mobile is a free, open-source Android & iOS client for the opencode AI coding agent. Connect to your own server, stream diffs, and code from anywhere.',
keywords: [
'opencode mobile',
'AI coding agent',
'mobile coding',
'self-hosted AI',
'opencode client',
'open source',
'Android AI coding',
],
authors: [{ name: 'Vibe Technologies' }],
creator: 'Vibe Technologies',
publisher: 'Vibe Technologies',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://opencode.agentlabs.cc',
siteName: 'OpenCode Mobile',
title: 'OpenCode Mobile — Your AI Coding Agent, On the Go',
description:
'Free, open-source mobile client for opencode. Multi-connection, biometric unlock, streaming diffs — code from anywhere.',
images: [
{
url: '/og.png',
width: 1200,
height: 630,
alt: 'OpenCode Mobile',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'OpenCode Mobile — Your AI Coding Agent, On the Go',
description:
'Free, open-source mobile client for opencode. Multi-connection, biometric unlock, streaming diffs.',
images: ['/og.png'],
creator: '@vibebrowserapp',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
alternates: {
canonical: 'https://opencode.agentlabs.cc',
},
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
],
shortcut: '/favicon.svg',
},
}
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
themeColor: '#000000',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="min-h-screen flex flex-col" style={{ fontFamily: 'var(--font-geist-sans), system-ui, sans-serif' }}>
<Nav />
<main className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
)
}

215
website/app/page.tsx Normal file
View File

@@ -0,0 +1,215 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'OpenCode Mobile — The open source AI coding agent, on the go',
description:
'Free, open-source Android & iOS client for the opencode AI coding agent. Connect to your own server, stream diffs, and code from anywhere.',
alternates: { canonical: 'https://opencode.agentlabs.cc' },
}
const features = [
{
bullet: '[*]',
title: 'Multi-server',
description: 'Switch between local, cloud, and team servers. Credentials stay on device.',
},
{
bullet: '[*]',
title: 'Streaming diffs',
description: 'Watch AI changes in real time with syntax-highlighted diffs.',
},
{
bullet: '[*]',
title: 'Biometric unlock',
description: 'Fingerprint or Face ID. No password typing.',
},
{
bullet: '[*]',
title: 'Any model',
description: 'Claude, GPT, Gemini, local models — whatever your server runs.',
},
{
bullet: '[*]',
title: 'Session management',
description: 'Create, resume, and switch between coding sessions.',
},
{
bullet: '[*]',
title: 'MIT licensed',
description: 'Fully open source. Audit, fork, self-build. Diagnostics are opt-in.',
},
]
const screenshots = [
{ file: '04', alt: 'Add an OpenCode server connection' },
{ file: '03', alt: 'Start a streaming coding task' },
{ file: '02', alt: 'Watch live tool activity' },
{ file: '05', alt: 'Browse coding sessions' },
{ file: '06', alt: 'Review an agent writing code' },
{ file: '07', alt: 'See a completed coding task' },
{ file: '01', alt: 'Choose a model and task settings' },
]
export default function HomePage() {
return (
<>
{/* Hero */}
<section className="pt-24 pb-20 md:pt-36 md:pb-28">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold leading-[1.1] tracking-tight mb-6" style={{ color: 'var(--fg)' }}>
The open source AI coding agent,<br />
<span style={{ color: 'var(--muted)' }}>on the go</span>
</h1>
<p className="text-lg sm:text-xl leading-relaxed mb-10 max-w-2xl" style={{ color: 'var(--muted)' }}>
Free mobile client for{' '}
<a href="https://opencode.ai" target="_blank" rel="noopener noreferrer"
className="underline underline-offset-3 decoration-1" style={{ color: 'var(--fg)' }}>
opencode
</a>
. Connect to your own server, stream diffs in real time, and review AI changes from anywhere.
</p>
{/* Install / Download */}
<div className="flex flex-wrap gap-3 mb-4">
<a
href="https://play.google.com/store/apps/details?id=cc.agentlabs.opencode"
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm"
>
Download for Android
</a>
<a
href="https://github.com/dzianisv/opencode-mobile/releases"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-sm"
>
GitHub Releases
</a>
<a
href="https://dzianisv.github.io/opencode-mobile/fdroid/repo"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-sm"
>
F-Droid
</a>
</div>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
Android 8.0+ &nbsp;&middot;&nbsp; iOS coming soon
</p>
</div>
</section>
{/* Approved Google Play screenshots, ordered from connection to completed work. */}
<section className="pb-24">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl font-bold mb-6 text-center" style={{ color: 'var(--fg)' }}>
From connection to completed code
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 sm:gap-6">
{screenshots.map((screenshot) => (
<div key={screenshot.file} className="relative rounded-2xl overflow-hidden"
style={{ border: '1px solid var(--border)' }}>
<img
src={`/screenshots/v0.4.7/${screenshot.file}.png`}
alt={screenshot.alt}
width={1280}
height={2560}
className="w-full h-auto object-contain"
style={{ display: 'block' }}
/>
</div>
))}
</div>
</div>
</section>
{/* Showcase video */}
<section className="pb-24">
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl font-bold mb-6 text-center" style={{ color: 'var(--fg)' }}>
See it in action
</h2>
<div className="relative rounded-2xl overflow-hidden" style={{ border: '1px solid var(--border)' }}>
<video
src="/cua-showcase.mp4"
controls
muted
playsInline
poster="/screenshots/v0.4.7/03.png"
style={{ width: '100%', display: 'block' }}
>
Your browser does not support the video tag.
</video>
</div>
</div>
</section>
{/* Features — opencode.ai style bullet list */}
<section className="py-20 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl sm:text-3xl font-bold mb-12" style={{ color: 'var(--fg)' }}>
What you get
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-16 gap-y-8">
{features.map((f) => (
<div key={f.title} className="flex gap-4">
<span className="text-sm font-mono shrink-0 mt-0.5" style={{ color: 'var(--muted)' }}>{f.bullet}</span>
<div>
<h3 className="font-semibold mb-1" style={{ color: 'var(--fg)' }}>{f.title}</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--muted)' }}>{f.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Open Source */}
<section className="py-20 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
<div>
<h2 className="text-2xl sm:text-3xl font-bold mb-3" style={{ color: 'var(--fg)' }}>
100% open source
</h2>
<p className="max-w-lg text-sm leading-relaxed" style={{ color: 'var(--muted)' }}>
MIT-licensed. Audit the code, self-build, contribute features, or fork it.
No telemetry without consent. No hidden servers. Your data stays yours.
</p>
</div>
<a
href="https://github.com/dzianisv/opencode-mobile"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-sm shrink-0"
>
View on GitHub
</a>
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6 text-center">
<h2 className="text-2xl sm:text-3xl font-bold mb-4" style={{ color: 'var(--fg)' }}>
Join the beta
</h2>
<p className="text-sm mb-8 max-w-lg mx-auto" style={{ color: 'var(--muted)' }}>
We need 20 testers to unlock the public Google Play release.
Sign up and get early access today.
</p>
<Link href="/beta" className="btn-primary text-sm">
Join the closed beta
</Link>
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,61 @@
import type { Metadata } from 'next'
import { readFileSync } from 'fs'
import path from 'path'
export const metadata: Metadata = {
title: 'Privacy Policy',
description:
'OpenCode Mobile privacy policy. We do not collect your code, prompts, or AI responses. Crash diagnostics via Sentry are opt-in only.',
alternates: { canonical: 'https://opencode.agentlabs.cc/privacy' },
openGraph: {
title: 'Privacy Policy | OpenCode Mobile',
description: 'OpenCode Mobile privacy policy — minimal data collection, opt-in crash reporting.',
url: 'https://opencode.agentlabs.cc/privacy',
},
}
export default function PrivacyPage() {
// Read the canonical privacy policy HTML generated by the Android distribution workflow.
// This ensures the policy shown on the site is always in sync with app store submissions.
const policyPath = path.join(
process.cwd(),
'../../../opencode-mobile/distribution/privacy-policy.html',
)
let policyHtml = ''
try {
const raw = readFileSync(policyPath, 'utf8')
// Extract just the <body> content so we can render it inside our layout
const bodyMatch = raw.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
policyHtml = bodyMatch ? bodyMatch[1] : raw
} catch {
policyHtml = `<p style="color:#94A3B8">Privacy policy document not found. Please contact
<a href="mailto:support@vibebrowser.app" style="color:#3B82F6">support@vibebrowser.app</a>.</p>`
}
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16">
{/* Override the inline HTML colors from the standalone file with dark theme */}
<style>{`
.privacy-content body { color: inherit; background: transparent; }
.privacy-content header { border-bottom-color: #3B82F6; }
.privacy-content header h1 { color: #F1F5F9; }
.privacy-content .meta { color: #94A3B8; }
.privacy-content h2 { color: #F1F5F9; border-left-color: #3B82F6; }
.privacy-content p, .privacy-content li { color: #CBD5E1; }
.privacy-content a { color: #3B82F6; }
.privacy-content .highlight-box { background: rgba(59,130,246,0.1); border-color: rgba(59,130,246,0.4); }
.privacy-content .highlight-box strong { color: #93C5FD; }
.privacy-content th { background: #1E293B; border-color: #334155; color: #F1F5F9; }
.privacy-content td { border-color: #334155; color: #CBD5E1; }
.privacy-content tr:nth-child(even) td { background: rgba(30,41,59,0.5); }
.privacy-content footer { border-top-color: #334155; color: #64748B; }
.privacy-content code { color: #94A3B8; font-size: 0.85em; }
`}</style>
<div
className="privacy-content"
dangerouslySetInnerHTML={{ __html: policyHtml }}
/>
</div>
)
}

11
website/app/robots.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://opencode.agentlabs.cc/sitemap.xml',
}
}

39
website/app/sitemap.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const base = 'https://opencode.agentlabs.cc'
const now = new Date()
return [
{
url: base,
lastModified: now,
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${base}/privacy`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${base}/terms`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${base}/support`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${base}/docs`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.8,
},
]
}

View File

@@ -0,0 +1,121 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Support',
description:
'Get help with OpenCode Mobile. Contact us, browse GitHub Issues, and read the FAQ.',
alternates: { canonical: 'https://opencode.agentlabs.cc/support' },
openGraph: {
title: 'Support | OpenCode Mobile',
description: 'Contact, FAQ, and GitHub Issues for OpenCode Mobile.',
url: 'https://opencode.agentlabs.cc/support',
},
}
const faqs = [
{
q: 'How do I connect the app to my opencode server?',
a: `Open the app, tap the "+" button on the connections screen, and enter your server URL (e.g., https://your-server.example.com or http://192.168.1.x:3000 for local). The app connects over HTTP/HTTPS to the standard opencode REST/WebSocket API. For remote access, we recommend running your server behind Tailscale or a reverse proxy with TLS.`,
},
{
q: 'Is my code sent to Vibe Technologies?',
a: `No. All AI traffic — your prompts, code, diffs, and AI responses — travels directly between the app and your own opencode server. VIBE TECHNOLOGIES, LLC never sees this data. The only optional data we receive is anonymous crash diagnostics via Sentry, and only if you opt in at first launch.`,
},
{
q: 'Can I self-host the backend?',
a: `Yes — that's the primary use case. OpenCode Mobile is a client for the open-source opencode server (github.com/sst/opencode). You run the server wherever you like: your laptop, a VPS, a home server, or a cloud VM. The app simply connects to whatever URL you configure.`,
},
{
q: 'Is the app really free? What\'s the catch?',
a: `The app is free and MIT-licensed with no catch. We pay for development out of Vibe Technologies revenue. In the future we plan to offer "opencode Cloud" — a managed, always-on backend for teams who don't want to run their own server. That will be a paid product. The mobile app itself will remain free and open source.`,
},
{
q: 'The app crashes or I see an error. What should I do?',
a: `Please open a GitHub Issue at github.com/dzianisv/opencode-mobile/issues with: your device model, Android/iOS version, app version (Settings → About), and a description of what happened. If you opted in to crash reporting, we may already have the stack trace — your issue report helps us match it to the right bug.`,
},
]
export default function SupportPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16">
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--fg)' }}>
Support
</h1>
<p className="text-lg mb-12" style={{ color: 'var(--muted)' }}>
We&apos;re a small team, but we try to respond within a few business days.
</p>
{/* Contact cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5 mb-16">
<a
href="mailto:support@vibebrowser.app"
className="card flex items-start gap-4 hover:border-blue-500/60 transition-colors"
style={{ textDecoration: 'none' }}
>
<div className="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: 'rgba(59,130,246,0.15)', color: 'var(--accent)' }}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="font-semibold mb-1" style={{ color: 'var(--fg)' }}>Email Support</p>
<p className="text-sm" style={{ color: 'var(--muted)' }}>support@vibebrowser.app</p>
</div>
</a>
<a
href="https://github.com/dzianisv/opencode-mobile/issues"
target="_blank"
rel="noopener noreferrer"
className="card flex items-start gap-4 hover:border-blue-500/60 transition-colors"
style={{ textDecoration: 'none' }}
>
<div className="shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: 'rgba(59,130,246,0.15)', color: 'var(--accent)' }}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" clipRule="evenodd"
d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</div>
<div>
<p className="font-semibold mb-1" style={{ color: 'var(--fg)' }}>GitHub Issues</p>
<p className="text-sm" style={{ color: 'var(--muted)' }}>Bug reports & feature requests</p>
</div>
</a>
</div>
{/* FAQ */}
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--fg)', borderLeft: '4px solid var(--accent)', paddingLeft: 12 }}>
Frequently Asked Questions
</h2>
<div className="space-y-5">
{faqs.map((faq, i) => (
<div key={i} className="card">
<h3 className="font-semibold mb-3" style={{ color: 'var(--fg)' }}>
{faq.q}
</h3>
<p className="text-sm leading-relaxed" style={{ color: '#CBD5E1' }}>
{faq.a}
</p>
</div>
))}
</div>
<div className="mt-12 p-5 rounded-xl text-sm" style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.25)', color: 'var(--muted)' }}>
Still stuck? Open a{' '}
<a href="https://github.com/dzianisv/opencode-mobile/issues/new" target="_blank"
rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>
GitHub Issue
</a>{' '}
or email{' '}
<a href="mailto:support@vibebrowser.app" style={{ color: 'var(--accent)' }}>
support@vibebrowser.app
</a>.
Include your device, OS version, and app version for fastest resolution.
</div>
</div>
)
}

199
website/app/terms/page.tsx Normal file
View File

@@ -0,0 +1,199 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Terms of Service',
description:
'OpenCode Mobile terms of service. MIT-licensed open-source software provided as-is, no warranty. Governing law: Washington State.',
alternates: { canonical: 'https://opencode.agentlabs.cc/terms' },
openGraph: {
title: 'Terms of Service | OpenCode Mobile',
description: 'OpenCode Mobile terms of service — MIT-licensed, provided as-is.',
url: 'https://opencode.agentlabs.cc/terms',
},
}
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-16">
<header className="border-b pb-6 mb-10" style={{ borderColor: '#3B82F6' }}>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--fg)' }}>
OpenCode Mobile — Terms of Service
</h1>
<p className="text-sm" style={{ color: 'var(--muted)' }}>
Effective date: 2026-05-24&nbsp;&nbsp;|&nbsp;&nbsp;
Operator: VIBE TECHNOLOGIES, LLC&nbsp;&nbsp;|&nbsp;&nbsp;
App: OpenCode Mobile (<code className="text-xs px-1 rounded" style={{ background: 'var(--card)' }}>ai.opencode.mobile</code>)
</p>
</header>
<div className="prose-site">
<div className="rounded-xl p-5 mb-8" style={{ background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.3)' }}>
<p className="!mb-0">
<strong style={{ color: '#93C5FD' }}>Summary:</strong> OpenCode Mobile is free,
open-source (MIT) software. It is provided as-is. You run it against your own opencode
server. We owe you no support on the free tier, though we genuinely try to help.
Governing law is Washington State, USA.
</p>
</div>
<h2>1. Acceptance of Terms</h2>
<p>
By downloading, installing, or using OpenCode Mobile (the &ldquo;App&rdquo;), you agree to
these Terms of Service (&ldquo;Terms&rdquo;). If you do not agree, do not use the App.
</p>
<p>
These Terms apply to all versions of the App, including builds distributed via Google Play,
F-Droid, Apple App Store, or directly from the GitHub repository.
</p>
<h2>2. Open-Source License</h2>
<p>
The App is released under the{' '}
<a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer">MIT License</a>.
Source code is available at{' '}
<a href="https://github.com/dzianisv/opencode-mobile" target="_blank" rel="noopener noreferrer">
github.com/dzianisv/opencode-mobile
</a>.
</p>
<p>
The MIT License grants you broad rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the software, subject to the license&apos;s attribution
requirement. These Terms supplement, but do not replace, the MIT License.
</p>
<h2>3. Description of Service</h2>
<p>
OpenCode Mobile is a mobile client application that connects to a self-hosted or
third-party-hosted opencode server. The App does not itself provide AI services; all AI
computation occurs on the server you configure. VIBE TECHNOLOGIES, LLC is not a party to
the communication between the App and your server.
</p>
<h2>4. Your Responsibilities</h2>
<ul>
<li>
<strong>Your server:</strong> You are solely responsible for the opencode server you
connect to, including its security, uptime, costs, and compliance with applicable laws
and the terms of any AI provider you use on that server.
</li>
<li>
<strong>Your content:</strong> You are solely responsible for any code, prompts, or other
content you submit through the App to your server.
</li>
<li>
<strong>Lawful use:</strong> You agree to use the App only for lawful purposes and in
accordance with applicable laws and regulations.
</li>
<li>
<strong>Age:</strong> You must be at least 18 years old to use the App, as it is a
developer tool not designed for minors.
</li>
<li>
<strong>Account security:</strong> You are responsible for safeguarding any credentials,
tokens, or API keys you configure in the App.
</li>
</ul>
<h2>5. No Warranty</h2>
<p>
THE APP IS PROVIDED &ldquo;AS IS&rdquo; AND &ldquo;AS AVAILABLE,&rdquo; WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR
A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. VIBE TECHNOLOGIES, LLC DOES NOT WARRANT THAT
THE APP WILL BE ERROR-FREE, UNINTERRUPTED, SECURE, OR MEET YOUR REQUIREMENTS.
</p>
<p>
This disclaimer is consistent with the MIT License under which the App&apos;s source code is
released.
</p>
<h2>6. Limitation of Liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL VIBE TECHNOLOGIES,
LLC, ITS OFFICERS, DIRECTORS, EMPLOYEES, OR AGENTS BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES, INCLUDING LOSS OF PROFITS, DATA,
BUSINESS, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE APP, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
</p>
<p>
IN JURISDICTIONS THAT DO NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, OUR
LIABILITY IS LIMITED TO THE GREATEST EXTENT PERMITTED BY LAW.
</p>
<h2>7. No Support Obligation (Free Tier)</h2>
<p>
VIBE TECHNOLOGIES, LLC provides OpenCode Mobile free of charge and has no obligation to
provide technical support, maintenance, updates, or bug fixes. We genuinely try to respond
to issues on{' '}
<a href="https://github.com/dzianisv/opencode-mobile/issues" target="_blank" rel="noopener noreferrer">
GitHub Issues
</a>{' '}
and via{' '}
<a href="mailto:support@vibebrowser.app">support@vibebrowser.app</a>, but we make no
guarantees about response times or resolution.
</p>
<p>
Future paid &ldquo;opencode Cloud&rdquo; plans may include defined support terms; those plans will
have separate agreements.
</p>
<h2>8. Updates and Changes to the App</h2>
<p>
We may release updates, patches, or new versions of the App at any time or discontinue the
App without notice. We are not obligated to maintain backwards compatibility or provide
migration paths.
</p>
<h2>9. Third-Party Services</h2>
<p>
The App may integrate with or link to third-party services (e.g., your opencode server,
AI providers, Sentry for crash reporting). These third parties have their own terms and
privacy policies. VIBE TECHNOLOGIES, LLC is not responsible for third-party services.
</p>
<h2>10. Intellectual Property</h2>
<p>
The App&apos;s source code is MIT-licensed (see Section 2). The &ldquo;OpenCode Mobile&rdquo; name, the
Vibe Technologies name, and associated logos are trademarks of VIBE TECHNOLOGIES, LLC and
may not be used without permission except as permitted by applicable law.
</p>
<h2>11. Governing Law and Dispute Resolution</h2>
<p>
These Terms are governed by and construed in accordance with the laws of the State of
Washington, USA, without regard to its conflict-of-law provisions. Any dispute arising out
of or relating to these Terms or the App shall be subject to the exclusive jurisdiction of
the state and federal courts located in King County, Washington.
</p>
<p>
If you are a consumer in a jurisdiction with mandatory consumer protection laws, nothing in
these Terms limits rights you are entitled to by law.
</p>
<h2>12. Changes to These Terms</h2>
<p>
We may update these Terms from time to time. The most current version will always be
available at{' '}
<a href="https://opencode.agentlabs.cc/terms">opencode.agentlabs.cc/terms</a>.
Continued use of the App after changes constitutes your acceptance of the new Terms.
We will update the effective date at the top when changes are made.
</p>
<h2>13. Severability</h2>
<p>
If any provision of these Terms is found to be unenforceable, the remaining provisions
will continue in full force and effect.
</p>
<h2>14. Contact</h2>
<p>
VIBE TECHNOLOGIES, LLC<br />
519 S Henderson St<br />
Seattle, WA 98108-4522<br />
USA<br />
Email:{' '}
<a href="mailto:support@vibebrowser.app">support@vibebrowser.app</a>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import Link from 'next/link'
export default function Footer() {
return (
<footer className="border-t mt-20" style={{ borderColor: 'var(--border)' }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6">
{/* Links */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm" style={{ color: 'var(--muted)' }}>
<a
href="https://github.com/dzianisv/opencode-mobile"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80 transition-colors"
>
GitHub
</a>
<a
href="https://opencode.ai"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80 transition-colors"
>
Docs
</a>
<Link href="/privacy" className="hover:opacity-80 transition-colors">
Privacy
</Link>
<Link href="/terms" className="hover:opacity-80 transition-colors">
Terms
</Link>
</div>
{/* Legal */}
<p className="text-xs" style={{ color: 'var(--muted)' }}>
&copy; 2026 VIBE TECHNOLOGIES, LLC
</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
export default function Nav() {
const [open, setOpen] = useState(false)
return (
<nav
className="sticky top-0 z-50 border-b"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)', borderColor: 'var(--border)', backdropFilter: 'blur(12px)' }}
>
<div className="max-w-5xl mx-auto px-4 sm:px-6 flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-2 font-bold text-lg tracking-tight" style={{ color: 'var(--fg)' }}>
opencode<span className="font-normal" style={{ color: 'var(--muted)' }}>/mobile</span>
</Link>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-6">
{[
{ href: 'https://opencode.ai', label: 'Docs', external: true },
{ href: 'https://github.com/dzianisv/opencode-mobile', label: 'GitHub', external: true },
{ href: '/beta', label: 'Beta' },
].map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--muted)' }}
{...('external' in link ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{link.label}
</Link>
))}
<a
href="https://play.google.com/store/apps/details?id=cc.agentlabs.opencode"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium px-4 py-2 rounded-lg transition-all"
style={{ backgroundColor: 'var(--fg)', color: 'var(--bg)' }}
>
Download
</a>
</div>
{/* Mobile hamburger */}
<button
className="md:hidden p-2 rounded-lg"
style={{ color: 'var(--muted)' }}
onClick={() => setOpen(!open)}
aria-label="Toggle menu"
>
{open ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
{/* Mobile menu */}
{open && (
<div className="md:hidden border-t px-4 py-4 space-y-3" style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg)' }}>
{[
{ href: 'https://opencode.ai', label: 'Docs', external: true },
{ href: 'https://github.com/dzianisv/opencode-mobile', label: 'GitHub', external: true },
{ href: '/beta', label: 'Join Beta' },
{ href: 'https://play.google.com/store/apps/details?id=cc.agentlabs.opencode', label: 'Download', external: true },
].map((link) => (
<Link
key={link.href}
href={link.href}
className="block text-sm py-2 transition-colors hover:opacity-80"
style={{ color: 'var(--muted)' }}
onClick={() => setOpen(false)}
{...('external' in link ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{link.label}
</Link>
))}
</div>
)}
</nav>
)
}

6
website/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

12
website/next.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { NextConfig } from 'next'
const root = process.cwd()
const nextConfig: NextConfig = {
// Static export disabled — using Vercel SSR so robots.txt and sitemap work normally
turbopack: {
root,
},
}
export default nextConfig

2358
website/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
website/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "opencode-mobile-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/verify-screenshots.mjs",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@vercel/otel": "^2.1.3",
"geist": "^1.3.1",
"next": "^16.2.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config

Binary file not shown.

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0F172A"/>
<text x="16" y="22" font-family="monospace" font-size="16" font-weight="bold" fill="#3B82F6" text-anchor="middle">&lt;/&gt;</text>
</svg>

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

View File

@@ -0,0 +1,80 @@
{
"schemaVersion": 1,
"source": {
"platform": "Google Play",
"listing": "https://play.google.com/store/apps/details?id=cc.agentlabs.opencode&hl=en_US",
"packageName": "cc.agentlabs.opencode",
"retrievedAt": "2026-07-14",
"ownership": "OpenCode Mobile listing assets"
},
"assetPolicy": {
"purpose": "Canonical reference assets for the OpenCode Mobile website and future store-listing updates.",
"doNotModify": "Do not crop, add marketing copy, or otherwise alter these files in place. Create a separately named derivative for a channel-specific composition.",
"websiteSource": "Copy selected originals into website/public/screenshots/ during the website deployment change."
},
"screenshots": [
{
"file": "01.png",
"sourceUrl": "https://play-lh.googleusercontent.com/0TndhVWCZHGC0UpZNEWBJfhAsRI1NPeg5aevMqdTTEmCQ1zE-HPwMVt0eGxxW2ljPAhGynebJ346MKP2JYAf9YU",
"sha256": "9c2c1648458d52e9fdc6f9b45777d0fbf20f07fa3369e7aa747b708c4c7a5102",
"width": 1280,
"height": 2560,
"feature": "Model and settings selection",
"websitePlacement": "gallery-07"
},
{
"file": "02.png",
"sourceUrl": "https://play-lh.googleusercontent.com/l9Q1182qqZIJ6fSoXUMmlctaDMAt6RJbgZYVUA4akx2Fd7UKQ5R8MH0jbWGFI0_RVf-vBQMiTTixrTANWJKWpw",
"sha256": "7928699bd4ea3c64d22bce60d05854d09699bf74b23f8390e62ed900fdf0d1e8",
"width": 1280,
"height": 2560,
"feature": "Live tool activity",
"websitePlacement": "gallery-04"
},
{
"file": "03.png",
"sourceUrl": "https://play-lh.googleusercontent.com/m1EkbhW1W17NNt1wyMOl7pGpdRgzwbULsxkrZ-KVuVlppTnYfN6dmXWmIoy2a8obp3uY6yg3LCuDN5_obHopTiQ",
"sha256": "8987a963d708563f5ff2c0f1c127d852bcbd3759cd9eef2b462cee4d0d8e8658",
"width": 1280,
"height": 2560,
"feature": "Streaming task conversation",
"websitePlacement": "hero-02"
},
{
"file": "04.png",
"sourceUrl": "https://play-lh.googleusercontent.com/n3CtQbxCaV9V715VC9Dk5LYqP-ufQr2TA9Jsm-iKowTgN562_SWX663a_FpS1iGhAIFJ1QCSRAz5jtEinuosig",
"sha256": "d7af61652e9a1521457e87c54c1702a1318e811201a46c40af9b59b85e09e2a2",
"width": 1280,
"height": 2560,
"feature": "Connection setup",
"websitePlacement": "hero-01"
},
{
"file": "05.png",
"sourceUrl": "https://play-lh.googleusercontent.com/nkYKYJzFD3Q3hVHpsUUhbd6ICirWLUYMcuxTK3Tg_QiCA3YPI_8PZiepI98BY9gddaWzF3xBZEDHmEo15dfyBQ",
"sha256": "6acbbd87f4f48e5cf53deddd562c2099f8bab892bbd8f0bb00aac24e6f63ed85",
"width": 1280,
"height": 2560,
"feature": "Session list",
"websitePlacement": "gallery-02"
},
{
"file": "06.png",
"sourceUrl": "https://play-lh.googleusercontent.com/npsXfCkwCWEqJ9o56ZCENBTTrvefgnA54fGQLc6L1USc6wp9oFsfC_m_orBZ_hsZsE7pUuCDl51QSvkLy7w7m8c",
"sha256": "4d68454a602be5546085ceea7f261aadb332973721bde3123d01304dc95d1651",
"width": 1280,
"height": 2560,
"feature": "Agent writes code",
"websitePlacement": "gallery-05"
},
{
"file": "07.png",
"sourceUrl": "https://play-lh.googleusercontent.com/zXUoTydFFH3U-Hc1hemnXFaxluRrWdDuRweatkuc5zRQQH1f806lN7ZY_go23OMxs5E5-7rD5JOZHlI-M9ACcQ",
"sha256": "02be91c4ab35e5ab30f7e310ea5bcf98e0485b14b7d3bbe7f161a38e1642c427",
"width": 1280,
"height": 2560,
"feature": "Completed coding task",
"websitePlacement": "gallery-06"
}
]
}

View File

@@ -0,0 +1,18 @@
import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
const root = resolve(import.meta.dirname, "..")
const directory = resolve(root, "public/screenshots/v0.4.7")
const manifest = JSON.parse(await readFile(resolve(directory, "manifest.json"), "utf8"))
for (const screenshot of manifest.screenshots) {
const file = resolve(directory, screenshot.file)
const contents = await readFile(file)
const hash = createHash("sha256").update(contents).digest("hex")
if (hash !== screenshot.sha256) {
throw new Error(`${screenshot.file} does not match its approved Play checksum`)
}
}
console.log(`Verified ${manifest.screenshots.length} approved screenshots.`)

View File

@@ -0,0 +1,24 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
slate: {
950: '#0F172A',
},
},
fontFamily: {
sans: ['var(--font-geist-sans)', 'system-ui', 'sans-serif'],
mono: ['var(--font-geist-mono)', 'monospace'],
},
},
},
plugins: [],
}
export default config

41
website/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}