Skip to content

Commit 524d3fd

Browse files
Zexiclaude
authored andcommitted
refactor: set web UI title from active server context, drop server-side rewrite
The previous approach rewrote <title> on the sidecar based on os.hostname() or the request Host header, which on macOS often returns generic names like "Mac.lan" instead of the SSH/displayName the user expects. The web app already tracks the active ServerConnection — drive document.title from server.name so it reflects displayName / configured connection name and updates reactively when the user switches servers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent affc29f commit 524d3fd

3 files changed

Lines changed: 19 additions & 42 deletions

File tree

packages/app/src/app.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
1414
import { Effect } from "effect"
1515
import {
1616
type Component,
17+
createEffect,
1718
createMemo,
1819
createResource,
1920
createSignal,
@@ -293,6 +294,15 @@ function ServerKey(props: ParentProps) {
293294
)
294295
}
295296

297+
function DocumentTitle() {
298+
const server = useServer()
299+
createEffect(() => {
300+
const name = server.name
301+
document.title = name ? `${name} - OpenCode` : "OpenCode"
302+
})
303+
return null
304+
}
305+
296306
export function AppInterface(props: {
297307
children?: JSX.Element
298308
defaultServer: ServerConnection.Key
@@ -306,6 +316,7 @@ export function AppInterface(props: {
306316
disableHealthCheck={props.disableHealthCheck}
307317
servers={props.servers}
308318
>
319+
<DocumentTitle />
309320
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
310321
<ServerKey>
311322
<QueryProvider>

packages/opencode/src/server/shared/ui.ts

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,10 @@ import { Flag } from "@opencode-ai/core/flag/flag"
33
import { Effect, Stream } from "effect"
44
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
55
import { createHash } from "node:crypto"
6-
import os from "node:os"
76
import path from "node:path"
87
import { fileURLToPath } from "node:url"
98
import { ProxyUtil } from "../proxy-util"
109

11-
const SERVER_HOSTNAME = (() => {
12-
try {
13-
return os.hostname().replace(/\.local$/i, "")
14-
} catch {
15-
return ""
16-
}
17-
})()
18-
19-
function serverDisplayName(request: HttpServerRequest.HttpServerRequest) {
20-
if (SERVER_HOSTNAME) return SERVER_HOSTNAME
21-
const host = request.headers["host"] ?? ""
22-
return host.split(":")[0] ?? ""
23-
}
24-
25-
function escapeHtml(value: string) {
26-
return value.replace(/[&<>"']/g, (c) =>
27-
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
28-
)
29-
}
30-
31-
function rewriteTitle(html: string, name: string) {
32-
if (!name) return html
33-
return html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, `<title>${escapeHtml(name)} - OpenCode</title>`)
34-
}
35-
3610
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
3711
? Promise.resolve(null)
3812
: // @ts-expect-error - generated file at build time
@@ -96,13 +70,11 @@ function notFound() {
9670
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
9771
}
9872

99-
function embeddedUIResponse(file: string, body: Uint8Array, name: string) {
73+
function embeddedUIResponse(file: string, body: Uint8Array) {
10074
const mime = AppFileSystem.mimeType(file)
10175
const headers = new Headers({ "content-type": mime })
10276
if (mime.startsWith("text/html")) {
103-
const rewritten = rewriteTitle(new TextDecoder().decode(body), name)
104-
headers.set("content-security-policy", cspForHtml(rewritten))
105-
return HttpServerResponse.raw(new TextEncoder().encode(rewritten), { headers })
77+
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
10678
}
10779
return HttpServerResponse.raw(body, { headers })
10880
}
@@ -111,15 +83,14 @@ export function serveEmbeddedUIEffect(
11183
requestPath: string,
11284
fs: AppFileSystem.Interface,
11385
embeddedWebUI: Record<string, string>,
114-
name: string,
11586
) {
11687
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
11788
if (!file) return Effect.succeed(notFound())
11889

11990
const resolved = embeddedUIFile(file)
12091

12192
return fs.readFile(resolved).pipe(
122-
Effect.map((body) => embeddedUIResponse(resolved, body, name)),
93+
Effect.map((body) => embeddedUIResponse(resolved, body)),
12394
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
12495
)
12596
}
@@ -128,14 +99,13 @@ function serveLocalDirEffect(
12899
requestPath: string,
129100
fs: AppFileSystem.Interface,
130101
dir: string,
131-
name: string,
132102
) {
133103
const filePath = path.join(dir, requestPath === "/" ? "index.html" : requestPath)
134104
return fs.readFile(filePath).pipe(
135-
Effect.map((body) => embeddedUIResponse(filePath, body, name)),
105+
Effect.map((body) => embeddedUIResponse(filePath, body)),
136106
Effect.catchReason("PlatformError", "NotFound", () =>
137107
fs.readFile(path.join(dir, "index.html")).pipe(
138-
Effect.map((body) => embeddedUIResponse(path.join(dir, "index.html"), body, name)),
108+
Effect.map((body) => embeddedUIResponse(path.join(dir, "index.html"), body)),
139109
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
140110
),
141111
),
@@ -149,13 +119,11 @@ export function serveUIEffect(
149119
return Effect.gen(function* () {
150120
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
151121
const requestPath = new URL(request.url, "http://localhost").pathname
152-
const name = serverDisplayName(request)
153122

154-
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(requestPath, services.fs, embeddedWebUI, name)
123+
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(requestPath, services.fs, embeddedWebUI)
155124

156125
// Dev mode: serve from local build directory if configured
157-
if (Flag.OPENCODE_DEV_UI_DIR)
158-
return yield* serveLocalDirEffect(requestPath, services.fs, Flag.OPENCODE_DEV_UI_DIR, name)
126+
if (Flag.OPENCODE_DEV_UI_DIR) return yield* serveLocalDirEffect(requestPath, services.fs, Flag.OPENCODE_DEV_UI_DIR)
159127

160128
const response = yield* services.client.execute(
161129
HttpClientRequest.make(request.method)(upstreamURL(requestPath), {
@@ -166,7 +134,7 @@ export function serveUIEffect(
166134
const headers = proxyResponseHeaders(response.headers)
167135

168136
if (response.headers["content-type"]?.includes("text/html")) {
169-
const body = rewriteTitle(yield* response.text, name)
137+
const body = yield* response.text
170138
headers.set("Content-Security-Policy", cspForHtml(body))
171139
return HttpServerResponse.text(body, { status: response.status, headers })
172140
}

packages/opencode/test/server/httpapi-ui.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,6 @@ describe("HttpApi UI fallback", () => {
317317
},
318318
},
319319
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
320-
"",
321320
).pipe(Effect.map(HttpServerResponse.toWeb))
322321

323322
expect(response.status).toBe(200)
@@ -347,7 +346,6 @@ describe("HttpApi UI fallback", () => {
347346
},
348347
},
349348
{ "index.html": "/$bunfs/root/index.html" },
350-
"",
351349
).pipe(Effect.map(HttpServerResponse.toWeb))
352350

353351
const csp = response.headers.get("content-security-policy") ?? ""

0 commit comments

Comments
 (0)