diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 385ece27aad..c13528790aa 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -99,6 +99,8 @@ export type TalkConfigResponse = TalkConfig & { export type GatewayControlUiConfig = { /** If false, the Gateway will not serve the Control UI (default /). */ enabled?: boolean; + /** Custom browser tab title for the Control UI (default: "OpenClaw Control"). */ + title?: string; /** Optional base path prefix for the Control UI (e.g. "/openclaw"). */ basePath?: string; /** Optional filesystem root for Control UI assets (defaults to dist/control-ui). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f8ad6bfcbc9..9ea1adb85c2 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -667,6 +667,7 @@ export const OpenClawSchema = z controlUi: z .object({ enabled: z.boolean().optional(), + title: z.string().optional(), basePath: z.string().optional(), root: z.string().optional(), allowedOrigins: z.array(z.string()).optional(), diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index b53eca81db5..6b1ca0b19f1 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -6,4 +6,5 @@ export type ControlUiBootstrapConfig = { assistantAvatar: string; assistantAgentId: string; serverVersion?: string; + title?: string; }; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index b3d65bd72b8..0a27950f316 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -358,6 +358,7 @@ export function handleControlUiHttpRequest( assistantAvatar: avatarValue ?? identity.avatar, assistantAgentId: identity.agentId, serverVersion: resolveRuntimeServiceVersion(process.env), + title: config?.gateway?.controlUi?.title, } satisfies ControlUiBootstrapConfig); return true; } diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index 33460c3cb9d..5effe3b0587 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -63,6 +63,62 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + it("sets document.title when title is present in bootstrap config", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Ops", + assistantAvatar: "O", + assistantAgentId: "main", + title: "Prod Gateway", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(document.title).toBe("Prod Gateway"); + + vi.unstubAllGlobals(); + }); + + it("does not override document.title when title is not set", async () => { + document.title = "OpenClaw Control"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Ops", + assistantAvatar: "O", + assistantAgentId: "main", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(document.title).toBe("OpenClaw Control"); + + vi.unstubAllGlobals(); + }); + it("normalizes trailing slash basePath for bootstrap fetch path", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index 6542fe1a9ba..3daa2bafbb6 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -45,6 +45,9 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat state.assistantAvatar = normalized.avatar; state.assistantAgentId = normalized.agentId ?? null; state.serverVersion = parsed.serverVersion ?? null; + if (parsed.title) { + document.title = parsed.title; + } } catch { // Ignore bootstrap failures; UI will update identity after connecting. } diff --git a/vitest.config.ts b/vitest.config.ts index 568f5dd03e6..37d3cbcb58f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -48,6 +48,7 @@ export default defineConfig({ "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts", "ui/src/ui/controllers/chat.test.ts", + "ui/src/ui/controllers/control-ui-bootstrap.test.ts", "ui/src/ui/controllers/sessions.test.ts", "ui/src/ui/app-gateway.sessions.node.test.ts", ],