Files
multica/packages/views/workspace/create-workspace-form.tsx
Bohan Jiang 6f29a4c0a6 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 <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 11:12:10 +08:00

138 lines
4.6 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
import type { Workspace } from "@multica/core/types";
import { isImeComposing } from "@multica/core/utils";
import {
WORKSPACE_SLUG_REGEX,
isWorkspaceSlugConflict,
nameToWorkspaceSlug,
} 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>;
}
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);
const slugTouched = useRef(false);
const slugValidationError =
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
? t(($) => $.create_form.errors.slug_format)
: null;
const slugReservedError =
slug.length > 0 && isReservedSlug(slug)
? t(($) => $.create_form.errors.slug_reserved)
: null;
const slugError = slugValidationError ?? slugReservedError ?? slugServerError;
const canSubmit =
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched.current) {
setSlug(nameToWorkspaceSlug(value));
setSlugServerError(null);
}
};
const handleSlugChange = (value: string) => {
slugTouched.current = true;
setSlug(value);
setSlugServerError(null);
};
const handleCreate = () => {
if (!canSubmit) return;
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess,
onError: (error) => {
if (isWorkspaceSlugConflict(error)) {
setSlugServerError(t(($) => $.create_form.errors.slug_taken));
toast.error(t(($) => $.create_form.errors.slug_conflict_toast));
return;
}
toast.error(
error instanceof Error && error.message
? error.message
: t(($) => $.create_form.errors.create_failed),
);
},
},
);
};
return (
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label htmlFor="ws-name">{t(($) => $.create_form.name_label)}</Label>
<Input
id="ws-name"
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t(($) => $.create_form.name_placeholder)}
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleCreate();
}}
/>
</div>
<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">
<span className="pl-3 text-sm text-muted-foreground select-none">
{`${urlHost}/`}
</span>
<Input
id="ws-slug"
type="text"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder={t(($) => $.create_form.url_placeholder)}
className="border-0 shadow-none focus-visible:ring-0"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending
? t(($) => $.create_form.submitting)
: t(($) => $.create_form.submit)}
</Button>
</CardContent>
</Card>
);
}