Compare commits

...

1 Commits

Author SHA1 Message Date
J
e8194c73e7 fix(workspace): derive workspace URL prefix from deployment host (MUL-3400)
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: multica-agent <github@multica.ai>
2026-06-18 11:02:59 +08:00
7 changed files with 131 additions and 13 deletions

View File

@@ -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",

View File

@@ -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");
});
});

View File

@@ -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 `<host>/<slug>` 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;
}
}

View File

@@ -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(
<StepWorkspace existing={existing} onCreated={vi.fn()} onBack={vi.fn()} />,
@@ -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();
});
});

View File

@@ -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 `<host>/[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<HTMLElement>(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({
</Label>
<div className="flex items-center rounded-md border bg-muted transition-colors focus-within:border-foreground">
<span className="select-none pl-3 font-mono text-sm text-muted-foreground">
{"multica.ai/"}
{`${urlHost}/`}
</span>
<Input
id="ws-slug"
@@ -425,6 +429,7 @@ function ExistingWorkspaceCard({
selected: boolean;
onSelect: () => void;
}) {
const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl));
return (
<button
type="button"
@@ -444,7 +449,7 @@ function ExistingWorkspaceCard({
{workspace.name}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{`multica.ai/${workspace.slug}`}
{`${urlHost}/${workspace.slug}`}
</div>
</div>
<RadioMark selected={selected} />
@@ -571,6 +576,7 @@ function WorkspacePreviewCard({
slug: string;
}) {
const { t } = useT("onboarding");
const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl));
return (
<div className="overflow-hidden rounded-xl border bg-card shadow-xs">
<div className="flex items-center gap-3 border-b px-4 py-3.5">
@@ -580,7 +586,7 @@ function WorkspacePreviewCard({
{name}
</div>
<div className="truncate font-mono text-[11.5px] text-muted-foreground">
{`multica.ai/${slug}`}
{`${urlHost}/${slug}`}
</div>
</div>
<Lock

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import { configStore } from "@multica/core/config";
import enCommon from "../locales/en/common.json";
import enWorkspace from "../locales/en/workspace.json";
import { CreateWorkspaceForm } from "./create-workspace-form";
@@ -35,7 +36,22 @@ function renderForm(onSuccess = vi.fn()) {
}
describe("CreateWorkspaceForm", () => {
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();

View File

@@ -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<void>;
@@ -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<string | null>(null);
@@ -98,9 +101,8 @@ export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) {
<div className="space-y-1.5">
<Label htmlFor="ws-slug">{t(($) => $.create_form.url_label)}</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
{/* eslint-disable-next-line i18next/no-literal-string -- brand URL prefix, not translatable */}
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.ai/
{`${urlHost}/`}
</span>
<Input
id="ws-slug"