#18522 · @DroganC · opened Mar 21, 2026 at 12:56 PM UTC · last updated Mar 21, 2026 at 12:56 PM UTC

feat: offline web static serve

appfeat
59
+1381212 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

7.0

Ease Of Review

8.0

Guidelines

6.0

Readiness

8.0

Size

4.0

Trust

5.0

Traction

0.0

Summary

This PR introduces a feature to allow the OpenCode web UI to be served offline from a local build, enabling use in air-gapped environments. It modifies the server to prioritize local dist files and updates UI asset paths to support offline functionality. A new OFFLINE_WEB.md document provides comprehensive setup and troubleshooting instructions.

Open in GitHub

Description

Issue for this PR

Closes #

Type of change

  • [ ] Bug fix
  • [X] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Provided a solution for building an open code web within an internal network; Opencode web serves the browser UI by proxying to https://app.opencode.ai when no API route matches. On machines without outbound access to that host, the UI never loads.

If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!

How did you verify your code works?

Changes:

  1. packages/opencode/src/server/server.ts — If packages/app/dist/index.html exists (or OPENCODE_APP_DIST points at a directory that contains it), serve files from that directory with the same SPA fallback as before (unknown paths → index.html). Otherwise keep the existing proxy behavior.
  2. Flag.OPENCODE_APP_DIST in packages/opencode/src/flag/flag.ts — Documents and centralizes the env var used for the absolute path override.
  3. One log line when local dist is used (serving web UI from local dist with root) so operators can confirm the proxy is not in use.
  4. packages/app — Changelog fetch uses /changelog.json (from public/ after build); notification and project avatar use same-origin favicon paths instead of https://opencode.ai/..., so those requests stay on the server.
  5. docs/OFFLINE_WEB.md (English) — How to build packages/app, which env vars matter, and troubleshooting. CONTRIBUTING.md — Short subsection linking to that doc.

Screenshots / recordings

If this is a UI change, please include a screenshot or recording.

Checklist

  • [X] I have tested my changes locally
  • [X] I have not included unrelated changes in this PR

If you do not follow this template your PR will be automatically rejected.

Linked Issues

None.

Comments

No comments.

Changed Files

.gitignore

+30
@@ -23,6 +23,9 @@ target
.scripts
.direnv/
# Local mirrors for air-gapped use (see docs/OFFLINE_WEB.md)
offline/
# Local dev files
opencode-dev
logs/

CONTRIBUTING.md

+40
@@ -121,6 +121,10 @@ bun run --cwd packages/app dev
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Air-gapped or offline `opencode web`
If the machine cannot reach `app.opencode.ai`, build the web app and point the server at the `dist` output. See [docs/OFFLINE_WEB.md](./docs/OFFLINE_WEB.md) for `OPENCODE_APP_DIST`, optional model list mirroring, and troubleshooting.
### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI.

docs/OFFLINE_WEB.md

+820
@@ -0,0 +1,82 @@
# Air-gapped or offline `opencode web`
Serve the web UI from a local **`vite build`** output so the server does not proxy to `https://app.opencode.ai`. If no build is present (no `index.html` under the resolved path), behavior is unchanged: the server falls back to that proxy (requires outbound access).
**Before opening a PR for related code changes:** the project expects an [issue first](https://github.com/anomalyco/opencode/blob/dev/CONTRIBUTING.md#issue-first-policy) (`Fixes #…` / `Closes #…` in the PR description).
---
## 1. Build the web app (while online)
From the repository root:
```bash
bun install
cd packages/app
bun run build
```
Confirm **`packages/app/dist/index.html`** exists.
---
## 2. Environment variables
| Variable | Purpose |
|----------|---------|
| `OPENCODE_APP_DIST` | Absolute path to the `dist` directory (must contain `index.html`). If unset, the server looks for `packages/app/dist` relative to the running server package. |
| `OPENCODE_DISABLE_MODELS_FETCH` | Set to `1` to disable periodic fetches to `https://models.dev`. |
| `OPENCODE_MODELS_PATH` | Optional path to a local `api.json`–compatible file (e

packages/app/public/changelog.json

+10
@@ -0,0 +1 @@
{"releases":[{"tag":"v1.2.27","name":"v1.2.27","date":"2026-03-16T02:34:10Z","url":"https://github.com/anomalyco/opencode/releases/tag/v1.2.27","highlights":[],"sections":[{"title":"Core","items":["Fixed VCS watcher if statement logic","Delete legacy permission module","Clean up pending entry when question is aborted","Remove SIGHUP exit handler","Effectify PermissionNext and fix InstanceState ALS bug","Inline branded ID schemas","Refactor QuestionService to use effects","Ensure that compaction message is tracked as agent initiated","Increase default chunk timeout from 2 minutes to 5 minutes","Fix lost sessions across worktrees and orphan branches (@michaeldwan)"]},{"title":"Desktop","items":["Remove open label from UI","Handle multiline web paste in prompt composer"]}]},{"tag":"v1.2.26","name":"v1.2.26","date":"2026-03-13T16:33:18Z","url":"https://github.com/anomalyco/opencode/releases/tag/v1.2.26","highlights":[],"sections":[{"title":"Core","items":["Scaffold effect-to-zod bridge for schema conversion","Serialize configuration for Bun installations","Support text attachments in app","Paginate session history for improved server performance","Sessions lost after git

packages/app/src/components/dialog-connect-provider.tsx

+11
@@ -383,7 +383,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},

packages/app/src/components/dialog-custom-provider.tsx

+11
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},

packages/app/src/context/highlights.tsx

+11
@@ -7,7 +7,7 @@ import { useSettings } from "@/context/settings"
import { persisted } from "@/utils/persist"
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
const CHANGELOG_URL = "/changelog.json"
type Store = {
version?: string

packages/app/src/entry.tsx

+11
@@ -67,7 +67,7 @@ const notify: Platform["notify"] = async (title, description, href) => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
icon: "/favicon-96x96-v3.png",
})
notification.onclick = () => {

packages/app/src/pages/layout/sidebar-items.tsx

+11
@@ -44,7 +44,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<Avatar
fallback={name()}
src={
props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
props.project.id === OPENCODE_PROJECT_ID ? "/favicon-v3.svg" : props.project.icon?.override
}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+11
@@ -265,7 +265,7 @@ function ApiMethod(props: ApiMethodProps) {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
body: {
type: "api",
key: value,
},

packages/opencode/src/flag/flag.ts

+20
@@ -69,6 +69,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
/** Absolute path to `packages/app` production build output (`vite build` → `dist/`). Used by `opencode web` to serve the UI without proxying to app.opencode.ai. */
export const OPENCODE_APP_DIST = process.env["OPENCODE_APP_DIST"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")

packages/opencode/src/server/server.ts

+406
@@ -43,12 +43,16 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { stat } from "node:fs/promises"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
const log = Log.create({ service: "server" })
let webUiLocalLogged = false
export const Default = lazy(() => createApp({}))
@@ -497,19 +501,49 @@ export namespace Server {
},
)
.all("/*", async (c) => {
const path = c.req.path
const webCsp =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const response = await proxy(`https://app.opencode.ai${path}`, {
const root = Flag.OPENCODE_APP_DIS