From 81acbdb136df049b5e82d1bd3d7675f9c91be5ed Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Mon, 4 May 2026 00:18:31 +0000 Subject: [PATCH 1/6] Add support for inline images. --- packages/app/src/app.tsx | 42 +++++++++++++++---- packages/opencode/src/file/index.ts | 21 +++++++++- .../src/server/routes/instance/file.ts | 27 +++++++++++- .../routes/instance/httpapi/groups/file.ts | 11 +++++ .../routes/instance/httpapi/handlers/file.ts | 9 ++++ packages/ui/src/context/marked.tsx | 18 +++++++- 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3189d80257df..44d1b25696b4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { @@ -42,6 +42,8 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" +import { authTokenFromCredentials } from "@/utils/server" +import { decode64 } from "@/utils/base64" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" @@ -128,12 +130,38 @@ function SessionProviders(props: ParentProps) { } function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + const server = useServer() + const params = useParams() + + const resolveImage = (src: string) => { + if (/^https?:\/\//.test(src) || src.startsWith("data:") || src.startsWith("/")) return src + const current = server.current + if (!current) return src + + const url = new URL(current.http.url) + url.pathname = "/file/raw" + url.searchParams.set("path", src) + + const dir = params.dir ? decode64(params.dir) : undefined + if (dir) { + url.searchParams.set("directory", dir) + } + + if (current.http.password) { + url.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: current.http.username, password: current.http.password }), + ) + } + return url.toString() + } + return ( - {/*}>*/} - {props.appChildren} - {props.children} - {/**/} + + {props.appChildren} + {props.children} + ) } @@ -157,9 +185,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - - {props.children} - + {props.children} diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a69..4220ea881651 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -322,6 +322,7 @@ export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly read: (file: string) => Effect.Effect + readonly readRaw: (file: string) => Effect.Effect<{ content: Uint8Array; mimeType: string }> readonly list: (dir?: string) => Effect.Effect readonly search: (input: { query: string @@ -571,6 +572,24 @@ export const layer = Layer.effect( return { type: "text" as const, content } }) + const readRaw: Interface["readRaw"] = Effect.fn("File.readRaw")(function* (file: string) { + using _ = log.time("readRaw", { file }) + const ctx = yield* InstanceState.context + const full = path.join(ctx.directory, file) + + if (!containsPath(full, ctx)) { + throw new Error("Access denied: path escapes project directory") + } + + const exists = yield* appFs.existsSafe(full) + if (!exists) throw new Error(`File not found: ${file}`) + + const content = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) + const mimeType = AppFileSystem.mimeType(full) + + return { content, mimeType } + }) + const list = Effect.fn("File.list")(function* (dir?: string) { const ctx = yield* InstanceState.context const exclude = [".git", ".DS_Store"] @@ -645,7 +664,7 @@ export const layer = Layer.effect( }) log.info("init") - return Service.of({ init, status, read, list, search }) + return Service.of({ init, status, read, readRaw, list, search }) }), ) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index d0e9ee618607..9913b9597848 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -6,7 +6,7 @@ import { Ripgrep } from "@/file/ripgrep" import { LSP } from "@/lsp/lsp" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" export const FileRoutes = lazy(() => new Hono() @@ -164,6 +164,31 @@ export const FileRoutes = lazy(() => return yield* svc.read(c.req.valid("query").path) }), ) + .get( + "/file/raw", + describeRoute({ + summary: "Read raw file", + description: "Read the raw content of a specified file.", + operationId: "file.raw", + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const { content, mimeType } = await runRequest( + "FileRoutes.raw", + c, + Effect.gen(function* () { + const svc = yield* File.Service + return yield* svc.readRaw(c.req.valid("query").path) + }), + ) + return c.body(content, 200, { "content-type": mimeType }) + }, + ) .get( "/file/status", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383e3..eca3f7a7e9a4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -35,6 +35,7 @@ export const FilePaths = { findSymbol: "/find/symbol", list: "/file", content: "/file/content", + raw: "/file/raw", status: "/file/status", } as const @@ -92,6 +93,16 @@ export const FileApi = HttpApi.make("file") description: "Read the content of a specified file.", }), ), + HttpApiEndpoint.get("raw", FilePaths.raw, { + query: FileQuery, + success: Schema.Uint8Array, + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.raw", + summary: "Read raw file", + description: "Read the raw content of a specified file.", + }), + ), HttpApiEndpoint.get("status", FilePaths.status, { success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 98ee5968e0cd..08539b69f6dd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -2,6 +2,7 @@ import * as InstanceState from "@/effect/instance-state" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" import { Effect } from "effect" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" @@ -39,6 +40,13 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return yield* svc.read(ctx.query.path) }) + const raw = Effect.fn("FileHttpApi.raw")(function* (ctx: { query: { path: string } }) { + const { content, mimeType } = yield* svc.readRaw(ctx.query.path) + return HttpServerResponse.raw(content, { + headers: { "content-type": mimeType }, + }) + }) + const status = Effect.fn("FileHttpApi.status")(function* () { return yield* svc.status() }) @@ -49,6 +57,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("findSymbol", findSymbol) .handle("list", list) .handle("content", content) + .handle("raw", raw) .handle("status", status) }), ) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 46f4993babde..1804eefcee29 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -465,9 +465,16 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise +function resolveImagesInHtml(html: string, resolve: (src: string) => string): string { + const imgRegex = /]*)\bsrc=(['"])(.*?)\2([^>]*)>/gi + return html.replace(imgRegex, (_, before, quote, src, after) => { + return `` + }) +} + export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", - init: (props: { nativeParser?: NativeMarkdownParser }) => { + init: (props: { nativeParser?: NativeMarkdownParser; resolveImage?: (src: string) => string }) => { const jsParser = marked.use( { renderer: { @@ -475,6 +482,12 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( const titleAttr = title ? ` title="${title}"` : "" return `${text}` }, + image({ href, title, text }) { + const src = props.resolveImage ? props.resolveImage(href) : href + const titleAttr = title ? ` title="${title}"` : "" + const altAttr = text ? ` alt="${text}"` : "" + return `` + }, }, }, markedKatex({ @@ -508,7 +521,8 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( return { async parse(markdown: string): Promise { const html = await nativeParser(markdown) - const withMath = renderMathExpressions(html) + const withImages = props.resolveImage ? resolveImagesInHtml(html, props.resolveImage) : html + const withMath = renderMathExpressions(withImages) return highlightCodeBlocks(withMath) }, } From d1344fa62654ffb4b6449566b699107f2db007bf Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Mon, 4 May 2026 04:17:25 +0000 Subject: [PATCH 2/6] Resolve provider hierarchy crash --- packages/app/src/app.tsx | 125 +++++++++++++++++++++++++++---------- packages/app/src/entry.tsx | 12 ++-- 2 files changed, 97 insertions(+), 40 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 44d1b25696b4..aa2660f7bf5f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { type Component, + createEffect, createMemo, createResource, createSignal, @@ -166,7 +167,54 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { +function AppMarkedProvider(props: ParentProps) { + const server = useServer() + const [currentDir, setCurrentDir] = createSignal("") + + createEffect(() => { + const path = window.location.pathname + const match = path.match(/^\/([^/]+)/) + if (match && match[1] !== "session") { + setCurrentDir(decode64(match[1]) ?? "") + } else { + setCurrentDir("") + } + }) + + const resolveImage = (src: string) => { + if (/^https?:\/\//.test(src) || src.startsWith("data:") || src.startsWith("/")) return src + const current = server.current + if (!current) return src + + const url = new URL(current.http.url) + url.pathname = "/file/raw" + url.searchParams.set("path", src) + + const dir = currentDir() + if (dir) { + url.searchParams.set("directory", dir) + } + + if (current.http.password) { + url.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: current.http.username, password: current.http.password }), + ) + } + return url.toString() + } + + return {props.children} +} + +export function AppBaseProviders( + props: ParentProps<{ + locale?: Locale + defaultServer?: ServerConnection.Key + servers?: Array + disableHealthCheck?: boolean + }>, +) { return ( @@ -183,11 +231,30 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return }} > - - - {props.children} - - + + + {props.children} + + + } + > + + + + + {props.children} + + + + + @@ -320,37 +387,27 @@ function ServerKey(props: ParentProps) { export function AppInterface(props: { children?: JSX.Element - defaultServer: ServerConnection.Key - servers?: Array router?: Component disableHealthCheck?: boolean }) { return ( - - - - - - - {routerProps.children}} - > - - - - - - - - - - - - + + + + + {routerProps.children}} + > + + + + + + + + + + ) } diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 5115f0348ad4..001ac66d28c3 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -167,12 +167,12 @@ if (root instanceof HTMLElement) { render( () => ( - - + + ), From 1c04ebb70a20939970d6cc76858c6aec07c5eb22 Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Tue, 5 May 2026 06:02:31 +0000 Subject: [PATCH 3/6] Disk cache for static content. --- packages/opencode/src/server/routes/ui.ts | 11 ++++- packages/opencode/src/server/shared/ui.ts | 15 +++++-- .../opencode/test/server/httpapi-ui.test.ts | 43 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index ce06b2b35ee1..8efd0560d4f5 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -17,7 +17,16 @@ export async function serveUI(request: Request) { if (await fs.exists(match)) { const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", DEFAULT_CSP) + } + + if (path.includes("/assets/") && /-[a-zA-Z0-9]{8,}\./.test(path)) { + headers.set("cache-control", "public, max-age=31536000, immutable") + } else { + headers.set("cache-control", "no-cache") + } + return new Response(new Uint8Array(await fs.readFile(match)), { headers }) } diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c1558a1a4ea3..92e3e9a5f161 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -49,10 +49,19 @@ function notFound() { return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } -function embeddedUIResponse(file: string, body: Uint8Array) { +function embeddedUIResponse(requestPath: string, file: string, body: Uint8Array) { const mime = AppFileSystem.mimeType(file) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + if (mime.startsWith("text/html")) { + headers.set("content-security-policy", DEFAULT_CSP) + } + + if (requestPath.includes("/assets/") && /-[a-zA-Z0-9]{8,}\./.test(requestPath)) { + headers.set("cache-control", "public, max-age=31536000, immutable") + } else { + headers.set("cache-control", "no-cache") + } + return HttpServerResponse.raw(body, { headers }) } @@ -65,7 +74,7 @@ export function serveEmbeddedUIEffect( if (!file) return Effect.succeed(notFound()) return fs.readFile(file).pipe( - Effect.map((body) => embeddedUIResponse(file, body)), + Effect.map((body) => embeddedUIResponse(requestPath, file, body)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), ) } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index f364491ace93..6e61615001e8 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -211,9 +211,52 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) expect(readPath).toBe("/$bunfs/root/assets/app.js") expect(response.headers.get("content-type")).toContain("text/javascript") + expect(response.headers.get("cache-control")).toBe("no-cache") expect(await response.text()).toBe("console.log('embedded')") }) + test("sets long cache headers for hashed assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/assets/index-ByzcVvdi.js", + { + ...fs, + readFile: () => Effect.succeed(new TextEncoder().encode("console.log('hashed')")), + }, + { "assets/index-ByzcVvdi.js": "/$bunfs/root/assets/index-ByzcVvdi.js" }, + ) + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable") + }) + + test("sets no-cache for non-hashed assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/index.html", + { + ...fs, + readFile: () => Effect.succeed(new TextEncoder().encode("")), + }, + { "index.html": "/$bunfs/root/index.html" }, + ) + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe("no-cache") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true From b602f5ce64a2b500bbe698d9051dddc526f82912 Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Tue, 5 May 2026 06:15:03 +0000 Subject: [PATCH 4/6] Increase space for bun build. --- packages/opencode/script/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 2f2edb4ff5ac..3c85a20b83bb 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -58,7 +58,7 @@ const createEmbeddedWebUIBundle = async () => { console.log(`Building Web UI to embed in the binary`) const appDir = path.join(import.meta.dirname, "../../app") const dist = path.join(appDir, "dist") - await $`bun run --cwd ${appDir} build` + await $`NODE_OPTIONS=--max-old-space-size=4096 bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) .filter((file) => !file.endsWith(".map")) From 0b637504a72ea7be3519426f4944b3be02d2b210 Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Tue, 5 May 2026 06:16:04 +0000 Subject: [PATCH 5/6] perf(app): cache provider list and prevent redundant requests --- packages/app/src/context/global-sync/bootstrap.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index e85516bf14bf..8ea200aa1a9a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -199,8 +199,9 @@ function warmSessions(input: { export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) => queryOptions({ - queryKey: [directory, "providers"], + queryKey: ["providers"], queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken, + staleTime: 1000 * 60 * 5, // 5 minutes }) export const loadAgentsQuery = ( From 873e9faea9464cfedb74be634a7e7f61dc86d526 Mon Sep 17 00:00:00 2001 From: Cory Walker Date: Tue, 5 May 2026 06:31:48 +0000 Subject: [PATCH 6/6] Small fix for load time. --- packages/app/src/context/global-sync/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 8ea200aa1a9a..310dac1acd59 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -199,7 +199,7 @@ function warmSessions(input: { export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) => queryOptions({ - queryKey: ["providers"], + queryKey: [directory, "providers"], queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken, staleTime: 1000 * 60 * 5, // 5 minutes })