Files
multica/packages/views/modals/create-squad.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

618 lines
22 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, UserPlus, X } from "lucide-react";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import {
agentListOptions,
memberListOptions,
workspaceKeys,
} from "@multica/core/workspace/queries";
import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents";
import { isImeComposing } from "@multica/core/utils";
import type { Agent, MemberWithUser } from "@multica/core/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
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 { toast } from "sonner";
import { useNavigation } from "../navigation";
import { ActorAvatar } from "../common/actor-avatar";
import { AvatarUploadControl } from "../common/avatar-upload-control";
import { CharCounter } from "../agents/components/char-counter";
import {
PickerEmpty,
PickerItem,
PickerSection,
} from "../issues/components/pickers/property-picker";
import { matchesPinyin } from "../editor/extensions/pinyin-match";
import { useT } from "../i18n";
type SelectedMember = {
type: "agent" | "member";
id: string;
name: string;
};
// How many chips we show inline before collapsing the tail into "+N".
const CHIP_DISPLAY_LIMIT = 3;
export function CreateSquadModal({ onClose }: { onClose: () => void }) {
const { t } = useT("modals");
const router = useNavigation();
const wsPaths = useWorkspacePaths();
const wsId = useWorkspaceId();
const queryClient = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const currentUserId = currentUser?.id ?? null;
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: wsMembers = [] } = useQuery(memberListOptions(wsId));
const activeAgents = useMemo(
() => agents.filter((a: Agent) => !a.archived_at && a.runtime_id),
[agents],
);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [leaderId, setLeaderId] = useState("");
const [selectedMembers, setSelectedMembers] = useState<SelectedMember[]>([]);
const [creating, setCreating] = useState(false);
// Promoting an agent to leader must actually drop it from selectedMembers,
// not merely hide it. Otherwise switching leader away later resurrects the
// hidden pick and silently submits it as a member.
const handleLeaderChange = (id: string) => {
setLeaderId(id);
if (id) {
setSelectedMembers((prev) =>
prev.filter((m) => !(m.type === "agent" && m.id === id)),
);
}
};
const canSubmit = !!name.trim() && !!leaderId && !creating;
const handleSubmit = async () => {
if (!canSubmit) return;
setCreating(true);
try {
const squad = await api.createSquad({
name: name.trim(),
description: description.trim() || undefined,
leader_id: leaderId,
avatar_url: avatarUrl ?? undefined,
});
queryClient.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
if (selectedMembers.length > 0) {
await Promise.allSettled(
selectedMembers.map(async (m) => {
try {
await api.addSquadMember(squad.id, {
member_type: m.type,
member_id: m.id,
});
} catch (err) {
toast.warning(
t(($) => $.create_squad.toast_member_add_failed, {
name: m.name,
error:
err instanceof Error ? err.message : "unknown error",
}),
);
}
}),
);
queryClient.invalidateQueries({
queryKey: [...workspaceKeys.squads(wsId), squad.id, "members"],
});
}
onClose();
toast.success(t(($) => $.create_squad.toast_created));
router.push(wsPaths.squadDetail(squad.id));
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t(($) => $.create_squad.toast_failed),
);
setCreating(false);
}
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-2xl !h-[85vh]">
<DialogHeader className="border-b px-5 py-3 space-y-0">
<DialogTitle className="text-title-sm font-semibold">
{t(($) => $.create_squad.title)}
</DialogTitle>
<DialogDescription className="mt-1 text-caption">
{t(($) => $.create_squad.description)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-5">
<div className="space-y-4 min-w-0">
{/* Identity row mirrors CreateAgentDialog so the two creates read
as siblings — avatar (left) + name/description stack (right). */}
<div className="flex items-start gap-4">
<AvatarUploadControl
variant="squad"
value={avatarUrl}
name={name}
size={64}
onUploaded={setAvatarUrl}
onClear={() => setAvatarUrl(null)}
/>
<div className="flex-1 min-w-0 space-y-3">
<div>
<Label className="text-caption text-muted-foreground">
{t(($) => $.create_squad.name_label)}
</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $.create_squad.name_placeholder)}
className="mt-1"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") void handleSubmit();
}}
/>
</div>
<div>
<Label className="text-caption text-muted-foreground">
{t(($) => $.create_squad.description_label)}
</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(($) => $.create_squad.description_placeholder)}
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
className="mt-1"
/>
<div className="mt-1">
<CharCounter
length={[...description].length}
max={AGENT_DESCRIPTION_MAX_LENGTH}
/>
</div>
</div>
</div>
</div>
<LeaderPicker
agents={activeAgents}
currentUserId={currentUserId}
value={leaderId}
onChange={handleLeaderChange}
/>
<AdditionalMembersPicker
agents={activeAgents}
members={wsMembers}
currentUserId={currentUserId}
leaderId={leaderId}
value={selectedMembers}
onChange={setSelectedMembers}
/>
</div>
</div>
{/* Inline footer — see CreateAgentDialog: shadcn DialogFooter applies
negative margins assuming a padded DialogContent. Our content is
p-0, so a plain bordered row is the right call. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button variant="ghost" onClick={onClose}>
{t(($) => $.create_squad.cancel)}
</Button>
<Button onClick={() => void handleSubmit()} disabled={!canSubmit}>
{creating
? t(($) => $.create_squad.submitting)
: t(($) => $.create_squad.submit)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// LeaderPicker — single-select agent picker, grouped "My Agents" first then
// "Workspace Agents". Empty workspaces render a disabled trigger that points
// the user at agent creation.
// ---------------------------------------------------------------------------
function LeaderPicker({
agents,
currentUserId,
value,
onChange,
}: {
agents: Agent[];
currentUserId: string | null;
value: string;
onChange: (id: string) => void;
}) {
const { t } = useT("modals");
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const myAgents = useMemo(
() => (currentUserId ? agents.filter((a) => a.owner_id === currentUserId) : []),
[agents, currentUserId],
);
const otherAgents = useMemo(
() =>
currentUserId
? agents.filter((a) => a.owner_id !== currentUserId)
: agents,
[agents, currentUserId],
);
const q = filter.trim().toLowerCase();
const matches = (a: Agent) =>
!q || a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q);
const filteredMine = myAgents.filter(matches);
const filteredOthers = otherAgents.filter(matches);
const selected = agents.find((a) => a.id === value) ?? null;
const noAgents = agents.length === 0;
return (
<div>
<Label className="text-caption text-muted-foreground">
{t(($) => $.create_squad.leader_label)}
</Label>
<p className="mt-0.5 mb-1.5 text-caption text-muted-foreground">
{t(($) => $.create_squad.leader_hint)}
</p>
{noAgents ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed bg-muted/30 px-3 py-2.5 text-body text-muted-foreground">
{t(($) => $.create_squad.no_agents)}
</div>
) : (
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setFilter("");
}}
>
<PopoverTrigger className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-body transition-colors hover:bg-muted">
{selected ? (
<ActorAvatar actorType="agent" actorId={selected.id} size="sm" showStatusDot />
) : (
<UserPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{selected?.name ?? t(($) => $.create_squad.leader_placeholder)}
</div>
{selected?.description && (
<div className="truncate text-caption text-muted-foreground">
{selected.description}
</div>
)}
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-0">
<div className="border-b px-2 py-1.5">
<input
autoFocus
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t(($) => $.create_squad.picker_search_placeholder)}
className="w-full bg-transparent text-body placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="max-h-72 overflow-y-auto p-1">
{filteredMine.length > 0 && (
<PickerSection label={t(($) => $.create_squad.group_my_agents)}>
{filteredMine.map((a) => (
<PickerItem
key={a.id}
selected={value === a.id}
onClick={() => {
onChange(a.id);
setOpen(false);
setFilter("");
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredOthers.length > 0 && (
<PickerSection label={t(($) => $.create_squad.group_workspace_agents)}>
{filteredOthers.map((a) => (
<PickerItem
key={a.id}
selected={value === a.id}
onClick={() => {
onChange(a.id);
setOpen(false);
setFilter("");
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredMine.length === 0 && filteredOthers.length === 0 && (
<PickerEmpty />
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AdditionalMembersPicker — multi-select agents + workspace members. The
// trigger shows up to 3 chips inline; the rest collapse into "+N". The popup
// stays open while the user toggles selections so they can pick multiple
// without re-opening it.
// ---------------------------------------------------------------------------
function AdditionalMembersPicker({
agents,
members,
currentUserId,
leaderId,
value,
onChange,
}: {
agents: Agent[];
members: MemberWithUser[];
currentUserId: string | null;
leaderId: string;
value: SelectedMember[];
onChange: (next: SelectedMember[]) => void;
}) {
const { t } = useT("modals");
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const isSelected = (type: "agent" | "member", id: string) =>
value.some((m) => m.type === type && m.id === id);
const toggle = (m: SelectedMember) => {
if (isSelected(m.type, m.id)) {
onChange(value.filter((x) => !(x.type === m.type && x.id === m.id)));
} else {
onChange([...value, m]);
}
};
const remove = (m: SelectedMember) => {
onChange(value.filter((x) => !(x.type === m.type && x.id === m.id)));
};
const myAgents = useMemo(
() =>
currentUserId
? agents.filter((a) => a.owner_id === currentUserId && a.id !== leaderId)
: [],
[agents, currentUserId, leaderId],
);
const otherAgents = useMemo(
() =>
agents.filter(
(a) =>
a.id !== leaderId &&
(currentUserId ? a.owner_id !== currentUserId : true),
),
[agents, currentUserId, leaderId],
);
const q = filter.trim().toLowerCase();
const agentMatches = (a: Agent) =>
!q || a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q);
const memberMatches = (m: MemberWithUser) =>
!q || m.name.toLowerCase().includes(q) || matchesPinyin(m.name, q);
const filteredMine = myAgents.filter(agentMatches);
const filteredOthers = otherAgents.filter(agentMatches);
const filteredMembers = members.filter(memberMatches);
const anyResults =
filteredMine.length + filteredOthers.length + filteredMembers.length > 0;
return (
<div>
<Label className="text-caption text-muted-foreground">
{t(($) => $.create_squad.members_label)}{" "}
<span className="text-muted-foreground/60">
{t(($) => $.create_squad.members_optional)}
</span>
</Label>
<p className="mt-0.5 mb-1.5 text-caption text-muted-foreground">
{t(($) => $.create_squad.members_hint)}
</p>
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setFilter("");
}}
>
{/* render={<div role="combobox" />} — chips contain their own remove
<button>, so the trigger cannot itself be a <button> without
nesting interactive content. Base UI injects click/keyboard/ARIA
wiring into the rendered element. */}
<PopoverTrigger
render={
<div
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="squad-member-listbox"
tabIndex={0}
className="flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-body transition-colors hover:bg-muted focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
{value.length === 0 ? (
<>
<UserPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{t(($) => $.create_squad.members_placeholder)}
</span>
</>
) : (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{value.slice(0, CHIP_DISPLAY_LIMIT).map((m) => (
<MemberChip
key={`${m.type}:${m.id}`}
m={m}
onRemove={() => remove(m)}
/>
))}
{value.length > CHIP_DISPLAY_LIMIT && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-caption font-medium text-muted-foreground tabular-nums">
{t(($) => $.create_squad.members_more_count, {
count: value.length - CHIP_DISPLAY_LIMIT,
})}
</span>
)}
</div>
)}
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</div>
}
/>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-0">
<div className="border-b px-2 py-1.5">
<input
autoFocus
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t(($) => $.create_squad.picker_search_placeholder)}
className="w-full bg-transparent text-body placeholder:text-muted-foreground outline-none"
/>
</div>
<div id="squad-member-listbox" role="listbox" className="max-h-72 overflow-y-auto p-1">
{filteredMine.length > 0 && (
<PickerSection label={t(($) => $.create_squad.group_my_agents)}>
{filteredMine.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() =>
toggle({ type: "agent", id: a.id, name: a.name })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredOthers.length > 0 && (
<PickerSection label={t(($) => $.create_squad.group_workspace_agents)}>
{filteredOthers.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() =>
toggle({ type: "agent", id: a.id, name: a.name })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredMembers.length > 0 && (
<PickerSection label={t(($) => $.create_squad.group_members)}>
{filteredMembers.map((m) => (
<PickerItem
key={m.user_id}
selected={isSelected("member", m.user_id)}
onClick={() =>
toggle({ type: "member", id: m.user_id, name: m.name })
}
>
<ActorAvatar actorType="member" actorId={m.user_id} size="sm" />
<span className="truncate">{m.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{!anyResults && <PickerEmpty />}
</div>
</PopoverContent>
</Popover>
</div>
);
}
function MemberChip({
m,
onRemove,
}: {
m: SelectedMember;
onRemove: () => void;
}) {
const { t } = useT("modals");
return (
<span className="inline-flex items-center gap-1 rounded-full border bg-background px-1.5 py-0.5 text-caption">
<ActorAvatar actorType={m.type} actorId={m.id} size="xs" />
<span className="max-w-[120px] truncate">{m.name}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={t(($) => $.create_squad.members_remove_aria, { name: m.name })}
className="rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</span>
);
}