From 6f29a4c0a6ea2c8262c07c160bbcd6ffc7144e06 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:12:10 +0800 Subject: [PATCH] fix(workspace): derive workspace URL prefix from deployment host (MUL-3400) (#4286) The create-workspace and onboarding UI hardcoded `multica.ai/` as the workspace slug URL prefix, so self-hosted deployments showed the wrong domain. Add a `workspaceUrlHost` helper that derives the host from the deployment's app URL (`daemon_app_url` from `/api/config`, via the config store) and falls back to the brand host when none is configured, then use it in both views. Fixes #4263. Co-authored-by: J Co-authored-by: multica-agent --- packages/core/package.json | 1 + packages/core/workspace/workspace-url.test.ts | 37 +++++++++++++++++++ packages/core/workspace/workspace-url.ts | 31 ++++++++++++++++ .../onboarding/steps/step-workspace.test.tsx | 37 ++++++++++++++++--- .../views/onboarding/steps/step-workspace.tsx | 14 +++++-- .../workspace/create-workspace-form.test.tsx | 18 ++++++++- .../views/workspace/create-workspace-form.tsx | 6 ++- 7 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 packages/core/workspace/workspace-url.test.ts create mode 100644 packages/core/workspace/workspace-url.ts diff --git a/packages/core/package.json b/packages/core/package.json index 1f8ff61a3..5e5f17f06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "./workspace/queries": "./workspace/queries.ts", "./workspace/mutations": "./workspace/mutations.ts", "./workspace/hooks": "./workspace/hooks.ts", + "./workspace/workspace-url": "./workspace/workspace-url.ts", "./workspace/avatar-url": "./workspace/avatar-url.ts", "./issues": "./issues/index.ts", "./issues/queries": "./issues/queries.ts", diff --git a/packages/core/workspace/workspace-url.test.ts b/packages/core/workspace/workspace-url.test.ts new file mode 100644 index 000000000..f5076c63d --- /dev/null +++ b/packages/core/workspace/workspace-url.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { workspaceUrlHost } from "./workspace-url"; + +describe("workspaceUrlHost", () => { + it("returns the host of a full app URL", () => { + expect(workspaceUrlHost("https://multica.example.com")).toBe( + "multica.example.com", + ); + }); + + it("ignores scheme, path, and trailing slash", () => { + expect(workspaceUrlHost("https://multica.example.com/")).toBe( + "multica.example.com", + ); + expect(workspaceUrlHost("http://multica.example.com/app/onboarding")).toBe( + "multica.example.com", + ); + }); + + it("preserves a non-default port", () => { + expect(workspaceUrlHost("https://my.host:3000")).toBe("my.host:3000"); + }); + + it("accepts a bare host without a scheme", () => { + expect(workspaceUrlHost("multica.example.com")).toBe("multica.example.com"); + expect(workspaceUrlHost("multica.example.com/path")).toBe( + "multica.example.com", + ); + }); + + it("falls back to the brand host when no app URL is configured", () => { + expect(workspaceUrlHost("")).toBe("multica.ai"); + expect(workspaceUrlHost(" ")).toBe("multica.ai"); + expect(workspaceUrlHost(null)).toBe("multica.ai"); + expect(workspaceUrlHost(undefined)).toBe("multica.ai"); + }); +}); diff --git a/packages/core/workspace/workspace-url.ts b/packages/core/workspace/workspace-url.ts new file mode 100644 index 000000000..646baf1ef --- /dev/null +++ b/packages/core/workspace/workspace-url.ts @@ -0,0 +1,31 @@ +// Brand host shown as the workspace URL prefix on the managed Multica Cloud, +// and the fallback whenever the deployment exposes no app URL. `/api/config` +// deliberately omits `daemon_app_url` for the managed cloud (and for any +// self-hosted server that has not set MULTICA_APP_URL / FRONTEND_ORIGIN), so +// this literal must remain the ultimate fallback. +const BRAND_WORKSPACE_HOST = "multica.ai"; + +/** + * Host rendered as the `/` workspace URL prefix in the + * create-workspace and onboarding UI. Derived from the deployment's app URL + * (`daemon_app_url` from `/api/config`, surfaced through the config store) so + * self-hosted instances show their own domain instead of `multica.ai`. Falls + * back to the brand host when no app URL is configured. + */ +export function workspaceUrlHost( + daemonAppUrl: string | null | undefined, +): string { + const trimmed = daemonAppUrl?.trim(); + if (!trimmed) return BRAND_WORKSPACE_HOST; + try { + return new URL(trimmed).host || BRAND_WORKSPACE_HOST; + } catch { + // `daemon_app_url` may arrive without a scheme; treat it as a bare host + // and strip any path/query/fragment so only the authority remains. + const bare = trimmed + .replace(/^.*?:\/\//, "") + .replace(/[/?#].*$/, "") + .trim(); + return bare || BRAND_WORKSPACE_HOST; + } +} diff --git a/packages/views/onboarding/steps/step-workspace.test.tsx b/packages/views/onboarding/steps/step-workspace.test.tsx index 853ea2411..1d4139ef4 100644 --- a/packages/views/onboarding/steps/step-workspace.test.tsx +++ b/packages/views/onboarding/steps/step-workspace.test.tsx @@ -15,11 +15,15 @@ const TEST_RESOURCES = { }, }; +type MockConfigState = { + workspaceCreationDisabled: boolean; + daemonAppUrl: string; +}; + const mockLogout = vi.hoisted(() => vi.fn()); const mockUseConfigStore = vi.hoisted(() => - vi.fn( - (selector: (state: { workspaceCreationDisabled: boolean }) => unknown) => - selector({ workspaceCreationDisabled: false }), + vi.fn((selector: (state: MockConfigState) => unknown) => + selector({ workspaceCreationDisabled: false, daemonAppUrl: "" }), ), ); @@ -28,7 +32,7 @@ vi.mock("../../auth", () => ({ })); vi.mock("@multica/core/config", () => ({ - useConfigStore: (selector: (state: { workspaceCreationDisabled: boolean }) => unknown) => + useConfigStore: (selector: (state: MockConfigState) => unknown) => mockUseConfigStore(selector), })); @@ -53,13 +57,15 @@ function I18nWrapper({ children }: { children: ReactNode }) { function renderStep({ existing, disabled, + daemonAppUrl = "", }: { existing: Workspace | null; disabled: boolean; + daemonAppUrl?: string; }) { mockUseConfigStore.mockImplementation( - (selector: (state: { workspaceCreationDisabled: boolean }) => unknown) => - selector({ workspaceCreationDisabled: disabled }), + (selector: (state: MockConfigState) => unknown) => + selector({ workspaceCreationDisabled: disabled, daemonAppUrl }), ); return render( , @@ -137,3 +143,22 @@ describe("StepWorkspace — DISABLE_WORKSPACE_CREATION gate", () => { expect(cta).toBeEnabled(); }); }); + +// #4263: the workspace URL prefix must reflect the deployment's own host on +// self-hosted instances instead of the hardcoded `multica.ai`. +describe("StepWorkspace — workspace URL prefix", () => { + it("shows the brand host when no app URL is configured", () => { + renderStep({ existing: null, disabled: false }); + expect(screen.getByText("multica.ai/")).toBeInTheDocument(); + }); + + it("shows the deployment host for self-hosted instances", () => { + renderStep({ + existing: null, + disabled: false, + daemonAppUrl: "https://multica.example.com", + }); + expect(screen.getByText("multica.example.com/")).toBeInTheDocument(); + expect(screen.queryByText("multica.ai/")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/onboarding/steps/step-workspace.tsx b/packages/views/onboarding/steps/step-workspace.tsx index 6e48c40ee..770c7780d 100644 --- a/packages/views/onboarding/steps/step-workspace.tsx +++ b/packages/views/onboarding/steps/step-workspace.tsx @@ -25,6 +25,7 @@ import { useCreateWorkspace } from "@multica/core/workspace/mutations"; import type { Workspace } from "@multica/core/types"; import { isImeComposing } from "@multica/core/utils"; import { useConfigStore } from "@multica/core/config"; +import { workspaceUrlHost } from "@multica/core/workspace/workspace-url"; import { DragStrip } from "@multica/views/platform"; import { useLogout } from "../../auth"; import { StepHeader } from "../components/step-header"; @@ -51,7 +52,9 @@ import { isReservedSlug } from "@multica/core/paths"; * shared form's own button would fight the footer CTA. * * The create-fields block doubles as a pedagogical preview: the URL is - * rendered as a `multica.ai/[slug]` pill, and a live `Issues will look + * rendered as a `/[slug]` pill (host derived from the deployment's + * app URL so self-hosted instances show their own domain), and a live + * `Issues will look * like ACME-123` line shows the user what their issue IDs will read * like before they've created anything. * @@ -81,6 +84,7 @@ export function StepWorkspace({ const mainRef = useRef(null); const fadeStyle = useScrollFade(mainRef); const workspaceCreationDisabled = useConfigStore((s) => s.workspaceCreationDisabled); + const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl)); // Single source of truth for "can the user reach the create path on this // instance?" — drives the resume-mode picker, the eyebrow/headline/lede // copy, the side panel, and the footer CTA so the disabled state can't @@ -242,7 +246,7 @@ export function StepWorkspace({
- {"multica.ai/"} + {`${urlHost}/`} void; }) { + const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl)); return (
@@ -571,6 +576,7 @@ function WorkspacePreviewCard({ slug: string; }) { const { t } = useT("onboarding"); + const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl)); return (
@@ -580,7 +586,7 @@ function WorkspacePreviewCard({ {name}
- {`multica.ai/${slug}`} + {`${urlHost}/${slug}`}
{ - beforeEach(() => mockMutate.mockReset()); + beforeEach(() => { + mockMutate.mockReset(); + configStore.setState({ daemonAppUrl: "" }); + }); + + it("shows the brand host as the URL prefix when no app URL is configured", () => { + renderForm(); + expect(screen.getByText("multica.ai/")).toBeInTheDocument(); + }); + + it("shows the deployment host as the URL prefix for self-hosted instances", () => { + configStore.setState({ daemonAppUrl: "https://multica.example.com" }); + renderForm(); + expect(screen.getByText("multica.example.com/")).toBeInTheDocument(); + expect(screen.queryByText("multica.ai/")).not.toBeInTheDocument(); + }); it("auto-generates slug from name until user edits slug", () => { renderForm(); diff --git a/packages/views/workspace/create-workspace-form.tsx b/packages/views/workspace/create-workspace-form.tsx index 121632f02..944fea6f2 100644 --- a/packages/views/workspace/create-workspace-form.tsx +++ b/packages/views/workspace/create-workspace-form.tsx @@ -16,6 +16,8 @@ import { } from "./slug"; import { useT } from "../i18n"; import { isReservedSlug } from "@multica/core/paths"; +import { useConfigStore } from "@multica/core/config"; +import { workspaceUrlHost } from "@multica/core/workspace/workspace-url"; export interface CreateWorkspaceFormProps { onSuccess: (workspace: Workspace) => void | Promise; @@ -24,6 +26,7 @@ export interface CreateWorkspaceFormProps { export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) { const { t } = useT("workspace"); const createWorkspace = useCreateWorkspace(); + const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl)); const [name, setName] = useState(""); const [slug, setSlug] = useState(""); const [slugServerError, setSlugServerError] = useState(null); @@ -98,9 +101,8 @@ export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) {
- {/* eslint-disable-next-line i18next/no-literal-string -- brand URL prefix, not translatable */} - multica.ai/ + {`${urlHost}/`}