mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
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>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { TriangleAlert } from "lucide-react";
|
|
import type { CommentTriggerPreviewAgent, CommentTriggerOutcome } from "@multica/core/types";
|
|
import { useAgentPresenceDetail } from "@multica/core/agents";
|
|
import { mentionLabelsByTarget } from "@multica/core/issues/comment-trigger-outcomes";
|
|
import { useCurrentWorkspace } from "@multica/core/paths";
|
|
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
|
|
import { AVATAR_SIZE_PX } from "@multica/ui/lib/avatar-size";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { AgentStatusDot } from "../../common/actor-avatar";
|
|
import { useT } from "../../i18n";
|
|
import { blockedReasonLabel, blockedShortReasonLabel } from "../blocked-trigger-copy";
|
|
|
|
// One agent renders in full ("Walt will start working", avatar + presence
|
|
// dot, click toggles). Several agents collapse to an overlapping avatar
|
|
// stack + count sentence, mirroring WorkspaceAgentWorkingChip on the issues
|
|
// header. Hover layers stay read-only (Tooltip); per-agent toggling lives in
|
|
// a click-opened Popover so the layer survives consecutive clicks.
|
|
// Suppression is communicated by brightness alone: lit = will trigger,
|
|
// dimmed = skipped.
|
|
// The single-agent avatar renders at the `xs` tier; the `+N` overflow chip
|
|
// and stack overlap below reuse that tier's pixel diameter so the collapsed
|
|
// stack lines up exactly with the avatars.
|
|
const AVATAR_SIZE = AVATAR_SIZE_PX.xs;
|
|
const MAX_STACK_HEADS = 4;
|
|
|
|
interface CommentTriggerChipsProps {
|
|
agents: CommentTriggerPreviewAgent[];
|
|
// Explicit @agent / @squad mentions that will NOT trigger if posted as-is
|
|
// (MUL-4525 §2). Each renders as a named warning chip so the user sees WHICH
|
|
// target won't run and why, not a silent no-op after sending.
|
|
blocked?: CommentTriggerOutcome[];
|
|
// The draft markdown, used only to label each blocked target with the name the
|
|
// user typed in its mention markup. The server omits blocked target names
|
|
// (enumeration-safety); this is the user's own text, so it discloses nothing new.
|
|
draftContent?: string;
|
|
suppressedAgentIds: Set<string>;
|
|
onToggle: (agentId: string) => void;
|
|
}
|
|
|
|
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
|
|
|
|
function sourceLabel(source: string, t: IssuesT): string {
|
|
switch (source) {
|
|
case "issue_assignee":
|
|
return t(($) => $.comment.trigger_source_issue_assignee);
|
|
case "mention_agent":
|
|
return t(($) => $.comment.trigger_source_mention_agent);
|
|
case "mention_squad_leader":
|
|
return t(($) => $.comment.trigger_source_mention_squad_leader);
|
|
default:
|
|
return t(($) => $.comment.trigger_source_unknown);
|
|
}
|
|
}
|
|
|
|
// Assignee / @mention reasons are intentionally omitted: the header
|
|
// (name · source) already says why they fire, so a reason line there would
|
|
// just restate it. Only the squad-leader link (non-obvious) and the unknown
|
|
// fallback carry information the header doesn't.
|
|
function sourceReason(agent: CommentTriggerPreviewAgent, t: IssuesT): string | null {
|
|
switch (agent.source) {
|
|
case "issue_assignee":
|
|
case "mention_agent":
|
|
return null;
|
|
case "mention_squad_leader":
|
|
return t(($) => $.comment.trigger_reason_mention_squad_leader);
|
|
default:
|
|
return agent.reason || t(($) => $.comment.trigger_reason_unknown);
|
|
}
|
|
}
|
|
|
|
// Presence is display metadata only — the trigger list itself is always the
|
|
// backend preview. Online-ish agents start right away; offline ones queue.
|
|
function useTriggerPresenceLine(agentId: string, t: IssuesT): string | null {
|
|
const ws = useCurrentWorkspace();
|
|
const detail = useAgentPresenceDetail(ws?.id, agentId);
|
|
if (detail === "loading") return null;
|
|
return detail.availability === "online" || detail.availability === "unstable"
|
|
? t(($) => $.comment.trigger_starts_now)
|
|
: t(($) => $.comment.trigger_starts_when_online);
|
|
}
|
|
|
|
// One tooltip body for every trigger surface (single chip, popover rows):
|
|
// who · why it fires (+ presence) · what a click does.
|
|
function TriggerAgentTooltipBody({
|
|
agent,
|
|
suppressed,
|
|
t,
|
|
}: {
|
|
agent: CommentTriggerPreviewAgent;
|
|
suppressed: boolean;
|
|
t: IssuesT;
|
|
}) {
|
|
const presenceLine = useTriggerPresenceLine(agent.id, t);
|
|
return (
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="font-medium">{agent.name}</span>
|
|
<span className="text-micro text-muted-foreground">{sourceLabel(agent.source, t)}</span>
|
|
</div>
|
|
{suppressed ? (
|
|
<div>{t(($) => $.comment.trigger_click_to_restore)}</div>
|
|
) : (
|
|
<>
|
|
{(() => {
|
|
// Reason (when present) and presence share one line; either may be
|
|
// absent, so join only the parts that exist to avoid a stray space.
|
|
const line = [sourceReason(agent, t), presenceLine].filter(Boolean).join(" ");
|
|
return line ? <div>{line}</div> : null;
|
|
})()}
|
|
<div className="text-muted-foreground">{t(($) => $.comment.trigger_click_to_skip)}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CommentTriggerChips({
|
|
agents,
|
|
blocked = [],
|
|
draftContent = "",
|
|
suppressedAgentIds,
|
|
onToggle,
|
|
}: CommentTriggerChipsProps) {
|
|
const { t } = useT("issues");
|
|
// Blocked outcomes carry no name (enumeration-safety); recover the label the
|
|
// user typed from their own draft so each chip can say which target it is.
|
|
const blockedLabels = useMemo(() => mentionLabelsByTarget(draftContent), [draftContent]);
|
|
|
|
// Loading and errors render nothing: the preview is an enhancement, and
|
|
// any interim chrome here reads as composer noise.
|
|
if (agents.length === 0 && blocked.length === 0) return null;
|
|
|
|
const allowed =
|
|
agents.length === 1 ? (
|
|
<SingleTriggerChip
|
|
agent={agents[0]!}
|
|
suppressed={suppressedAgentIds.has(agents[0]!.id)}
|
|
onToggle={onToggle}
|
|
t={t}
|
|
/>
|
|
) : agents.length > 1 ? (
|
|
<MultiTriggerChip
|
|
agents={agents}
|
|
suppressedAgentIds={suppressedAgentIds}
|
|
onToggle={onToggle}
|
|
t={t}
|
|
/>
|
|
) : null;
|
|
|
|
if (blocked.length === 0) return allowed;
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{allowed}
|
|
{blocked.map((outcome) => (
|
|
<BlockedTriggerChip
|
|
key={`${outcome.target_type}:${outcome.target_id}`}
|
|
outcome={outcome}
|
|
label={blockedLabels.get(`${outcome.target_type}:${outcome.target_id}`)}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// One blocked mention: named like an allowed chip ("Go"), but with an error
|
|
// indicator and a short reason ("No permission") instead of "will start", so a
|
|
// refused @mention reads as a clear, specific error rather than a vague count.
|
|
function BlockedTriggerChip({
|
|
outcome,
|
|
label,
|
|
t,
|
|
}: {
|
|
outcome: CommentTriggerOutcome;
|
|
label?: string;
|
|
t: IssuesT;
|
|
}) {
|
|
const shortReason = blockedShortReasonLabel(outcome.reason_code, t);
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span
|
|
className="inline-flex h-6 min-w-0 max-w-full animate-in fade-in items-center gap-1.5 rounded-md px-1.5 text-micro font-medium text-destructive"
|
|
aria-label={
|
|
label
|
|
? t(($) => $.comment.trigger_blocked_chip_aria, { name: label, reason: shortReason })
|
|
: shortReason
|
|
}
|
|
>
|
|
<TriangleAlert className="size-3 shrink-0" />
|
|
{label ? (
|
|
<span className="inline-flex min-w-0 items-center gap-1">
|
|
<span className="truncate">{label}</span>
|
|
<span className="shrink-0">·</span>
|
|
<span className="shrink-0">{shortReason}</span>
|
|
</span>
|
|
) : (
|
|
<span className="truncate">{shortReason}</span>
|
|
)}
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top" className="max-w-72 text-caption">
|
|
{blockedReasonLabel(outcome.reason_code, t)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function SingleTriggerChip({
|
|
agent,
|
|
suppressed,
|
|
onToggle,
|
|
t,
|
|
}: {
|
|
agent: CommentTriggerPreviewAgent;
|
|
suppressed: boolean;
|
|
onToggle: (agentId: string) => void;
|
|
t: IssuesT;
|
|
}) {
|
|
const state = suppressed
|
|
? t(($) => $.comment.trigger_skipped_label)
|
|
: sourceLabel(agent.source, t);
|
|
// The avatar carries "who"; the sentence carries only condition + outcome,
|
|
// so it stays fixed-width and never truncates on long agent names.
|
|
const sentence = suppressed
|
|
? t(($) => $.comment.trigger_wont_trigger)
|
|
: t(($) => $.comment.trigger_will_start);
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-pressed={suppressed}
|
|
aria-label={t(($) => $.comment.trigger_chip_aria, { name: agent.name, state })}
|
|
onClick={() => onToggle(agent.id)}
|
|
className={cn(
|
|
// Sidebar-style resting state: muted until hover so the strip
|
|
// reads as metadata, not content (see app-sidebar nav items).
|
|
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-micro font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground",
|
|
suppressed && "opacity-60",
|
|
)}
|
|
>
|
|
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
|
|
<span className="truncate">{sentence}</span>
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="top" className="max-w-72 text-caption">
|
|
<TriggerAgentTooltipBody agent={agent} suppressed={suppressed} t={t} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function MultiTriggerChip({
|
|
agents,
|
|
suppressedAgentIds,
|
|
onToggle,
|
|
t,
|
|
}: {
|
|
agents: CommentTriggerPreviewAgent[];
|
|
suppressedAgentIds: Set<string>;
|
|
onToggle: (agentId: string) => void;
|
|
t: IssuesT;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [tooltipHover, setTooltipHover] = useState(false);
|
|
const activeCount = agents.filter((a) => !suppressedAgentIds.has(a.id)).length;
|
|
const heads = agents.slice(0, MAX_STACK_HEADS);
|
|
const overflow = agents.length - heads.length;
|
|
// Mirror AgentAvatarStack: ~30% overlap reads as "stacked" without
|
|
// obscuring the next avatar.
|
|
const overlap = Math.round(AVATAR_SIZE * 0.3);
|
|
// The avatar stack shows who; the sentence promises only what WILL happen,
|
|
// so the count covers non-suppressed agents — skipped ones read as the
|
|
// dimmed heads right next to the number.
|
|
const sentence =
|
|
activeCount === 0
|
|
? t(($) => $.comment.trigger_none_will_trigger)
|
|
: t(($) => $.comment.trigger_will_start_count, { count: activeCount });
|
|
|
|
const popoverTrigger = (
|
|
<PopoverTrigger
|
|
render={
|
|
// Ghost-button affordance (hover fill + aria-expanded pin) so the
|
|
// stack reads as clickable, matching pickers across the app.
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex h-6 min-w-0 max-w-full animate-in fade-in cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-micro font-medium text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
activeCount === 0 && "opacity-60",
|
|
)}
|
|
/>
|
|
}
|
|
>
|
|
<span className="inline-flex items-center">
|
|
{heads.map((agent, i) => (
|
|
<span
|
|
key={agent.id}
|
|
style={{ marginLeft: i === 0 ? 0 : -overlap }}
|
|
className="inline-flex rounded-full ring-2 ring-background"
|
|
>
|
|
<TriggerAgentAvatar
|
|
agent={agent}
|
|
suppressed={suppressedAgentIds.has(agent.id)}
|
|
showDot={false}
|
|
/>
|
|
</span>
|
|
))}
|
|
{overflow > 0 && (
|
|
<span
|
|
style={{
|
|
marginLeft: -overlap,
|
|
width: AVATAR_SIZE,
|
|
height: AVATAR_SIZE,
|
|
fontSize: Math.max(9, Math.round(AVATAR_SIZE * 0.45)),
|
|
}}
|
|
className="inline-flex items-center justify-center rounded-full bg-muted font-medium tabular-nums text-muted-foreground ring-2 ring-background"
|
|
>
|
|
+{overflow}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="truncate">{sentence}</span>
|
|
</PopoverTrigger>
|
|
);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<Tooltip open={tooltipHover && !open} onOpenChange={setTooltipHover}>
|
|
<TooltipTrigger render={popoverTrigger} />
|
|
<TooltipContent side="top" className="text-caption">
|
|
{t(($) => $.comment.trigger_click_to_manage)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent align="start" className="w-64 p-2">
|
|
<div className="px-1.5 pb-1 text-caption font-medium text-muted-foreground">
|
|
{t(($) => $.comment.trigger_preview_title)}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
{agents.map((agent) => {
|
|
const suppressed = suppressedAgentIds.has(agent.id);
|
|
const state = suppressed
|
|
? t(($) => $.comment.trigger_skipped_label)
|
|
: sourceLabel(agent.source, t);
|
|
return (
|
|
<Tooltip key={agent.id}>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-pressed={suppressed}
|
|
aria-label={t(($) => $.comment.trigger_chip_aria, { name: agent.name, state })}
|
|
onClick={() => onToggle(agent.id)}
|
|
className={cn(
|
|
"flex w-full cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-muted",
|
|
suppressed && "opacity-60",
|
|
)}
|
|
>
|
|
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
|
|
<span
|
|
className={cn(
|
|
"min-w-0 flex-1 truncate text-caption",
|
|
suppressed && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{agent.name}
|
|
</span>
|
|
<span className="shrink-0 text-micro text-muted-foreground">{state}</span>
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="right" className="max-w-72 text-caption">
|
|
<TriggerAgentTooltipBody agent={agent} suppressed={suppressed} t={t} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function TriggerAgentAvatar({
|
|
agent,
|
|
suppressed,
|
|
showDot = true,
|
|
}: {
|
|
agent: CommentTriggerPreviewAgent;
|
|
suppressed: boolean;
|
|
showDot?: boolean;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"relative inline-flex shrink-0",
|
|
suppressed && "opacity-40 grayscale",
|
|
)}
|
|
>
|
|
<ActorAvatarBase
|
|
name={agent.name}
|
|
initials=""
|
|
avatarUrl={agent.avatar_url}
|
|
isAgent
|
|
size="xs"
|
|
/>
|
|
{showDot && !suppressed && <AgentStatusDot agentId={agent.id} size="xs" />}
|
|
</span>
|
|
);
|
|
}
|