mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
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>
700 lines
25 KiB
TypeScript
700 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { type ReactNode, useRef, useState } from "react";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
BookOpenText,
|
|
Bot,
|
|
FolderKanban,
|
|
Inbox,
|
|
ListTodo,
|
|
Lock,
|
|
MoreHorizontal,
|
|
Monitor,
|
|
Plus,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Label } from "@multica/ui/components/ui/label";
|
|
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
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";
|
|
import { RadioMark } from "../components/option-card";
|
|
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
|
|
import { useT } from "../../i18n";
|
|
import {
|
|
WORKSPACE_SLUG_REGEX,
|
|
isWorkspaceSlugConflict,
|
|
nameToWorkspaceSlug,
|
|
} from "../../workspace/slug";
|
|
import { isReservedSlug } from "@multica/core/paths";
|
|
|
|
/**
|
|
* Step 2 — create your first workspace, or continue with one set up in
|
|
* an earlier session.
|
|
*
|
|
* Shares Questionnaire's editorial two-column skeleton: 3-region app
|
|
* shell on the left, side panel on the right. One **unified footer CTA**
|
|
* handles both paths — `Open X` when the user picks an existing
|
|
* workspace, `Create X` when they name a new one. The name / slug
|
|
* fields are inlined here (not via the shared `CreateWorkspaceForm`)
|
|
* because the footer-driven interaction needs externalized submit; the
|
|
* 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 `<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.
|
|
*
|
|
* Resume path ships two picker cards (existing + create-new) and the
|
|
* user toggles between them. No-existing path just shows the create
|
|
* fields directly.
|
|
*/
|
|
|
|
function issuePrefix(slug: string): string {
|
|
// Mirrors the server's default prefix derivation — first 4 chars of
|
|
// the slug, uppercased. Falls back to "WS" when the slug is empty so
|
|
// the preview line never collapses to a single dangling "-".
|
|
const head = slug.trim().replace(/[^a-z0-9]/g, "").slice(0, 4);
|
|
return (head || "ws").toUpperCase();
|
|
}
|
|
|
|
export function StepWorkspace({
|
|
existing,
|
|
onCreated,
|
|
onBack,
|
|
}: {
|
|
existing?: Workspace | null;
|
|
onCreated: (workspace: Workspace) => void | Promise<void>;
|
|
onBack?: () => void;
|
|
}) {
|
|
const { t } = useT("onboarding");
|
|
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
|
|
// leak a clickable create affordance even if /api/config arrives late
|
|
// (#3433 review feedback).
|
|
const workspaceCreationAllowed = !workspaceCreationDisabled;
|
|
const logout = useLogout();
|
|
|
|
const reusing = existing ?? null;
|
|
// Resume path only: user picks which card. `null` = neither yet, so
|
|
// the footer CTA stays disabled. Clicking either card toggles — a
|
|
// second click on the same card deselects it. No-existing path
|
|
// ignores this state entirely. When workspace creation is disabled
|
|
// and a workspace already exists, default to "existing" so the user
|
|
// can press the CTA immediately — the only valid action.
|
|
const [mode, setMode] = useState<"existing" | "create" | null>(() =>
|
|
!workspaceCreationAllowed && existing ? "existing" : null,
|
|
);
|
|
const pickExisting = () =>
|
|
setMode((m) => (m === "existing" ? null : "existing"));
|
|
const pickCreate = () =>
|
|
setMode((m) => (m === "create" ? null : "create"));
|
|
|
|
// Form state for the create path. Mirrors CreateWorkspaceForm's
|
|
// internals: slug auto-fills from name until the user manually edits
|
|
// it; server-side slug conflicts show inline. Kept at this level so
|
|
// the footer CTA can read `canCreate` and trigger `handleCreate`.
|
|
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(($) => $.step_workspace.slug_format_error)
|
|
: null;
|
|
const slugReservedError =
|
|
slug.length > 0 && isReservedSlug(slug)
|
|
? t(($) => $.step_workspace.slug_reserved_error)
|
|
: null;
|
|
const slugError = slugValidationError ?? slugReservedError ?? slugServerError;
|
|
const canCreate =
|
|
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 createWorkspace = useCreateWorkspace();
|
|
|
|
const handleCreate = () => {
|
|
if (!canCreate || createWorkspace.isPending) return;
|
|
createWorkspace.mutate(
|
|
{ name: name.trim(), slug: slug.trim() },
|
|
{
|
|
onSuccess: onCreated,
|
|
onError: (error) => {
|
|
if (isWorkspaceSlugConflict(error)) {
|
|
setSlugServerError(t(($) => $.step_workspace.slug_taken_error));
|
|
toast.error(t(($) => $.step_workspace.slug_conflict_toast));
|
|
return;
|
|
}
|
|
toast.error(
|
|
error instanceof Error && error.message
|
|
? error.message
|
|
: t(($) => $.step_workspace.create_failed_toast),
|
|
);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
// Compute the footer CTA from whichever path the user is on. `null`
|
|
// is only reachable in the resume path; `existing` is only valid
|
|
// when we actually have a `reusing` workspace; everything else
|
|
// (including the no-existing path) funnels through `create` — except
|
|
// when this instance has DISABLE_WORKSPACE_CREATION=true, in which
|
|
// case the create path is unreachable and a no-reusing user falls
|
|
// through to the disabled notice (rendered separately below).
|
|
const isCreating = createWorkspace.isPending;
|
|
const creatingActive =
|
|
workspaceCreationAllowed && (!reusing || mode === "create");
|
|
const existingActive = Boolean(reusing) && mode === "existing";
|
|
|
|
let hint: string;
|
|
let continueLabel: string;
|
|
let continueDisabled: boolean;
|
|
let onContinue: () => void;
|
|
|
|
if (existingActive && reusing) {
|
|
hint = t(($) => $.step_workspace.hint_opening, { name: reusing.name });
|
|
continueLabel = t(($) => $.step_workspace.cta_open, { name: reusing.name });
|
|
continueDisabled = isCreating;
|
|
onContinue = () => onCreated(reusing);
|
|
} else if (creatingActive) {
|
|
if (isCreating) {
|
|
hint = t(($) => $.step_workspace.hint_creating_pending, {
|
|
name: name.trim() || t(($) => $.step_workspace.hint_creating_fallback),
|
|
});
|
|
continueLabel = t(($) => $.step_workspace.cta_creating);
|
|
continueDisabled = true;
|
|
onContinue = () => {};
|
|
} else if (canCreate) {
|
|
hint = t(($) => $.step_workspace.hint_creating, { name: name.trim() });
|
|
continueLabel = t(($) => $.step_workspace.cta_create_named, { name: name.trim() });
|
|
continueDisabled = false;
|
|
onContinue = handleCreate;
|
|
} else {
|
|
hint = t(($) => $.step_workspace.hint_name_first);
|
|
continueLabel = t(($) => $.step_workspace.cta_create_workspace);
|
|
continueDisabled = true;
|
|
onContinue = () => {};
|
|
}
|
|
} else {
|
|
hint = t(($) => $.step_workspace.hint_pick);
|
|
continueLabel = t(($) => $.common.continue);
|
|
continueDisabled = true;
|
|
onContinue = () => {};
|
|
}
|
|
|
|
const createFields = (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label
|
|
htmlFor="ws-name"
|
|
className="text-xs font-medium text-muted-foreground"
|
|
>
|
|
{t(($) => $.step_workspace.name_label)}
|
|
</Label>
|
|
<Input
|
|
id="ws-name"
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
placeholder={t(($) => $.step_workspace.name_placeholder)}
|
|
onKeyDown={(e) => {
|
|
if (isImeComposing(e)) return;
|
|
if (e.key === "Enter") handleCreate();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label
|
|
htmlFor="ws-slug"
|
|
className="text-xs font-medium text-muted-foreground"
|
|
>
|
|
{t(($) => $.step_workspace.url_label)}
|
|
</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">
|
|
{`${urlHost}/`}
|
|
</span>
|
|
<Input
|
|
id="ws-slug"
|
|
type="text"
|
|
value={slug}
|
|
onChange={(e) => handleSlugChange(e.target.value)}
|
|
placeholder={t(($) => $.step_workspace.slug_placeholder)}
|
|
className="border-0 bg-transparent font-mono 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>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.step_workspace.issue_prefix_label)}
|
|
</div>
|
|
<div className="text-sm leading-[1.55] text-muted-foreground">
|
|
{t(($) => $.step_workspace.issue_prefix_prefix)}
|
|
<span className="font-mono text-foreground">
|
|
{issuePrefix(slug)}-123
|
|
</span>
|
|
{t(($) => $.step_workspace.issue_prefix_suffix)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
|
|
{/* Left column — DragStrip + 3-region app shell */}
|
|
<div className="flex min-h-0 flex-col">
|
|
<DragStrip />
|
|
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
|
|
{onBack ? (
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
disabled={isCreating}
|
|
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
{t(($) => $.common.back)}
|
|
</button>
|
|
) : (
|
|
<span aria-hidden className="w-0" />
|
|
)}
|
|
<div className="flex-1">
|
|
<StepHeader currentStep="workspace" />
|
|
</div>
|
|
</header>
|
|
|
|
<main
|
|
ref={mainRef}
|
|
style={fadeStyle}
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
>
|
|
<div className="mx-auto w-full max-w-[620px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14">
|
|
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
|
{reusing
|
|
? workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.eyebrow_resume)
|
|
: t(($) => $.step_workspace.creation_disabled_eyebrow_resume)
|
|
: workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.eyebrow_first)
|
|
: t(($) => $.step_workspace.creation_disabled_eyebrow)}
|
|
</div>
|
|
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
|
|
{reusing
|
|
? workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.headline_resume, { name: reusing.name })
|
|
: t(($) => $.step_workspace.creation_disabled_headline_resume, { name: reusing.name })
|
|
: workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.headline_first)
|
|
: t(($) => $.step_workspace.creation_disabled_headline)}
|
|
</h1>
|
|
<p className="mt-4 text-[15.5px] leading-[1.55] text-foreground/80">
|
|
{reusing
|
|
? workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.lede_resume)
|
|
: t(($) => $.step_workspace.creation_disabled_lede_resume)
|
|
: workspaceCreationAllowed
|
|
? t(($) => $.step_workspace.lede_first)
|
|
: t(($) => $.step_workspace.creation_disabled_lede)}
|
|
</p>
|
|
|
|
<div className="mt-10">
|
|
{reusing ? (
|
|
<div className="flex flex-col gap-3">
|
|
<ExistingWorkspaceCard
|
|
workspace={reusing}
|
|
selected={mode === "existing"}
|
|
onSelect={pickExisting}
|
|
/>
|
|
{/* Hide the create-new card entirely when the self-host
|
|
gate (DISABLE_WORKSPACE_CREATION) is on (#3433) — the
|
|
backend would 403 the POST and the user would be stuck
|
|
with a useless form. */}
|
|
{!workspaceCreationDisabled && (
|
|
<CreateNewWorkspaceCard
|
|
selected={mode === "create"}
|
|
onSelect={pickCreate}
|
|
>
|
|
{createFields}
|
|
</CreateNewWorkspaceCard>
|
|
)}
|
|
</div>
|
|
) : workspaceCreationDisabled ? (
|
|
<CreationDisabledNotice onLogout={logout} />
|
|
) : (
|
|
createFields
|
|
)}
|
|
</div>
|
|
|
|
{!(workspaceCreationDisabled && !reusing) && (
|
|
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
|
<span
|
|
aria-live="polite"
|
|
className="mr-auto text-xs text-muted-foreground"
|
|
>
|
|
{hint}
|
|
</span>
|
|
<Button size="lg" disabled={continueDisabled} onClick={onContinue}>
|
|
{continueLabel}
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
{/* Right — side panel.
|
|
Swap sides based on what the user is currently picking:
|
|
switching to "create" in the resume path swaps the preview
|
|
from "your existing workspace + what's next" to the generic
|
|
"what lives inside / things you'll do here" so the preview
|
|
stays honest to the user's current choice. */}
|
|
<aside className="hidden min-h-0 border-l bg-muted/40 lg:flex lg:flex-col">
|
|
<DragStrip />
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-12 py-12">
|
|
{reusing && mode !== "create" ? (
|
|
<ExistingWorkspaceSide workspace={reusing} />
|
|
) : (
|
|
<CreateWorkspaceSide />
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Onboarding-step notice rendered when the operator has set
|
|
* DISABLE_WORKSPACE_CREATION=true (#3433) AND the user has no existing
|
|
* workspace yet. The headline / lede above this block already carry the
|
|
* messaging; this component only provides the logout escape so a user who
|
|
* landed here without an invitation is not trapped.
|
|
*/
|
|
function CreationDisabledNotice({ onLogout }: { onLogout: () => void }) {
|
|
const { t } = useT("onboarding");
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<Button variant="outline" size="lg" onClick={onLogout}>
|
|
{t(($) => $.step_workspace.creation_disabled_logout)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExistingWorkspaceCard({
|
|
workspace,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
workspace: Workspace;
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const urlHost = workspaceUrlHost(useConfigStore((s) => s.daemonAppUrl));
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
"flex w-full items-center gap-4 rounded-lg border bg-card px-5 py-4 text-left transition-all",
|
|
selected
|
|
? "border-foreground shadow-[inset_0_0_0_1px_var(--color-foreground)]"
|
|
: "hover:border-foreground/20 hover:bg-accent/30",
|
|
)}
|
|
>
|
|
<WorkspaceAvatar name={workspace.name} avatarUrl={workspace.avatar_url} size="lg" />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="truncate text-[14.5px] font-medium text-foreground">
|
|
{workspace.name}
|
|
</div>
|
|
<div className="truncate font-mono text-xs text-muted-foreground">
|
|
{`${urlHost}/${workspace.slug}`}
|
|
</div>
|
|
</div>
|
|
<RadioMark selected={selected} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Collapsible "Create a new workspace" radio card — shown in the resume
|
|
* path alongside the existing-workspace card. Clicking the header
|
|
* toggles selection; selected state expands to reveal the name / slug
|
|
* fields (passed in as children by the caller). Submission is driven
|
|
* by the parent's footer CTA, not a button inside this card.
|
|
*/
|
|
function CreateNewWorkspaceCard({
|
|
selected,
|
|
onSelect,
|
|
children,
|
|
}: {
|
|
selected: boolean;
|
|
onSelect: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
const { t } = useT("onboarding");
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-lg border bg-card transition-all",
|
|
selected
|
|
? "border-foreground shadow-[inset_0_0_0_1px_var(--color-foreground)]"
|
|
: "hover:border-foreground/20",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
aria-expanded={selected}
|
|
onClick={onSelect}
|
|
className="flex w-full items-center gap-4 px-5 py-4 text-left"
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="truncate text-[14.5px] font-medium text-foreground">
|
|
{t(($) => $.step_workspace.create_new_title)}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{t(($) => $.step_workspace.create_new_subtitle)}
|
|
</div>
|
|
</div>
|
|
<RadioMark selected={selected} />
|
|
</button>
|
|
{selected && <div className="border-t px-5 py-5">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateWorkspaceSide() {
|
|
const { t } = useT("onboarding");
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
|
{t(($) => $.step_workspace.side_create_eyebrow)}
|
|
</div>
|
|
|
|
<WorkspacePreviewCard
|
|
name={t(($) => $.step_workspace.side_preview_name)}
|
|
slug={t(($) => $.step_workspace.side_preview_slug)}
|
|
/>
|
|
|
|
<div className="mt-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
|
{t(($) => $.step_workspace.side_things_eyebrow)}
|
|
</div>
|
|
<div className="flex flex-col gap-3.5">
|
|
<PerkRow>{t(($) => $.step_workspace.perk_assign)}</PerkRow>
|
|
<PerkRow>{t(($) => $.step_workspace.perk_chat)}</PerkRow>
|
|
<PerkRow>{t(($) => $.step_workspace.perk_invite)}</PerkRow>
|
|
<PerkRow>{t(($) => $.step_workspace.perk_switch)}</PerkRow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExistingWorkspaceSide({ workspace }: { workspace: Workspace }) {
|
|
const { t } = useT("onboarding");
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
|
{t(($) => $.step_workspace.side_existing_eyebrow)}
|
|
</div>
|
|
|
|
<WorkspacePreviewCard name={workspace.name} slug={workspace.slug} />
|
|
|
|
<div className="mt-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
|
{t(($) => $.step_workspace.side_next_eyebrow)}
|
|
</div>
|
|
<div className="flex flex-col gap-3.5">
|
|
<PerkRow>{t(($) => $.step_workspace.next_runtime)}</PerkRow>
|
|
<PerkRow>{t(($) => $.step_workspace.next_agent)}</PerkRow>
|
|
<PerkRow>{t(($) => $.step_workspace.next_starter)}</PerkRow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Visual preview of the sidebar the user is about to land on — same
|
|
* icons, same labels as the live `<AppSidebar />`, so the onboarding
|
|
* card doubles as "this is what your sidebar will look like." Entity
|
|
* set mirrors the Workspace + Configure groups, lifting Members from
|
|
* Settings to a first-class row because it's the most intuitive way
|
|
* to express "workspaces are multi-player."
|
|
*/
|
|
function WorkspacePreviewCard({
|
|
name,
|
|
slug,
|
|
}: {
|
|
name: string;
|
|
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">
|
|
<WorkspaceAvatar name={name} size="md" />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="truncate text-[14px] font-medium text-foreground">
|
|
{name}
|
|
</div>
|
|
<div className="truncate font-mono text-[11.5px] text-muted-foreground">
|
|
{`${urlHost}/${slug}`}
|
|
</div>
|
|
</div>
|
|
<Lock
|
|
aria-hidden
|
|
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<EntityRow
|
|
icon={<Inbox className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.inbox_label)}
|
|
meta={t(($) => $.step_workspace.preview.inbox_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<ListTodo className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.issues_label)}
|
|
meta={t(($) => $.step_workspace.preview.issues_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<Bot className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.agents_label)}
|
|
meta={t(($) => $.step_workspace.preview.agents_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<FolderKanban className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.projects_label)}
|
|
meta={t(($) => $.step_workspace.preview.projects_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<Zap className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.autopilot_label)}
|
|
meta={t(($) => $.step_workspace.preview.autopilot_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<Monitor className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.runtimes_label)}
|
|
meta={t(($) => $.step_workspace.preview.runtimes_meta)}
|
|
/>
|
|
<EntityRow
|
|
icon={<BookOpenText className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.skills_label)}
|
|
meta={t(($) => $.step_workspace.preview.skills_meta)}
|
|
/>
|
|
<EntityRow
|
|
dim
|
|
icon={<MoreHorizontal className="h-4 w-4" />}
|
|
label={t(($) => $.step_workspace.preview.more_label)}
|
|
meta={t(($) => $.step_workspace.preview.more_meta)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EntityRow({
|
|
icon,
|
|
label,
|
|
meta,
|
|
dim,
|
|
}: {
|
|
icon: ReactNode;
|
|
label: string;
|
|
meta: string;
|
|
/** Visually de-emphasized — used for the "and more" row at the bottom. */
|
|
dim?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-2.5 [&:not(:last-child)]:border-b">
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"shrink-0",
|
|
dim ? "text-muted-foreground/60" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{icon}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"flex-1 text-[13.5px]",
|
|
dim ? "text-muted-foreground" : "text-foreground",
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"font-mono text-[11.5px]",
|
|
dim ? "text-muted-foreground/70" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{meta}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PerkRow({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-[18px_1fr] items-start gap-3">
|
|
<span
|
|
aria-hidden
|
|
className="mt-[11px] h-px w-3 shrink-0 bg-muted-foreground/40"
|
|
/>
|
|
<div className="text-[13.5px] leading-[1.55] text-foreground/85">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|