mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8194c73e7 |
@@ -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",
|
||||
|
||||
37
packages/core/workspace/workspace-url.test.ts
Normal file
37
packages/core/workspace/workspace-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
31
packages/core/workspace/workspace-url.ts
Normal file
31
packages/core/workspace/workspace-url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user