Compare commits

...
Author SHA1 Message Date
kitlangton 58dec7efcb test(tui): await dialog text selection 2026-08-29 13:06:57 +00:00
Kit Langton 171947787c test(tui): wait for diff base search focus (#46083)
Wait for the diff-base search input to receive focus before typing. Preserve existing assertions and timeouts while removing the render-versus-focus test race.
2026-08-29 08:37:32 -04:00
Luke Parker 106629aa11 feat(infra): deploy beta web app with SST (#46086) 2026-08-29 14:51:22 +10:00
Luke Parker 3ee2e482ce fix(app): preserve Windows panel top outlines (#46090) 2026-08-29 04:40:55 +00:00
Kit Langton 849824efd2 refactor(core): merge defaults for selected MCP servers (#46072) 2026-08-28 23:48:29 -04:00
11 changed files with 145 additions and 35 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -15,7 +16,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -28,6 +29,7 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+2 -9
View File
@@ -1,4 +1,5 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
},
})
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
createWebApp("app." + domain)
+18
View File
@@ -0,0 +1,18 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+22 -1
View File
@@ -71,4 +71,25 @@ Environment options:
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+2 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
? "1px"
: "8px",
}}
>
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
servers.set(name, server)
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, server)
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
})
})
+62
View File
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
@@ -192,7 +192,7 @@ test("ctrl+c clears a dialog text selection before closing the dialog", async ()
const column = frame[row]!.indexOf("Alpha") + 1
await select.app.mockMouse.click(column, row)
await select.app.mockMouse.click(column, row)
expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha")
await select.app.waitFor(() => select.app.renderer.getSelection()?.getSelectedText() === "Alpha")
select.app.mockInput.pressKey("c", { ctrl: true })
await select.app.waitFor(() => !select.app.renderer.getSelection())
@@ -1,11 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, expect, test } from "bun:test"
import { once } from "node:events"
import { readdir } from "node:fs/promises"
import path from "node:path"
import {
BoxRenderable,
CliRenderEvents,
DiffRenderable,
ImageRenderable,
InputRenderable,
MouseButton,
type Renderable,
ScrollBoxRenderable,
@@ -315,6 +318,9 @@ test.each(["branch", "committed", "working"] as const)(
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
expect(viewer.branchesRequests[0].searchParams.get("limit")).toBe("100")
// The picker can paint before its deferred input focus.
if (!viewer.app.renderer.currentFocusedEditor) await once(viewer.app.renderer, CliRenderEvents.FOCUSED_EDITOR)
expect(viewer.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
await viewer.app.mockInput.typeText("origin/release")
await Bun.sleep(160)
await viewer.app.waitFor(() => viewer.branchesRequests.at(-1)?.searchParams.get("search") === "origin/release")
+26 -18
View File
@@ -7,27 +7,35 @@ export default $config({
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "cloudflare",
providers: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
providers:
input.stage === "beta"
? {}
: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
}
},
async run() {
if ($app.stage === "beta") {
const { createWebApp } = await import("./infra/webapp.js")
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
}
const stage = await import("./infra/stage.js")
await import("./infra/app.js")
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined