Files
multica/packages/views/issues/components/issue-agent-activity-indicator.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

168 lines
6.5 KiB
TypeScript

"use client";
import { memo, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import type { AgentTask } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import type { AvatarSize } from "@multica/ui/lib/avatar-size";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { AgentActivityHoverContent } from "../../agents/components/agent-activity-hover-content";
import { selectIssueTasks, type IssueTaskGroups } from "../surface/activity";
import { useT } from "../../i18n";
const EMPTY_GROUPS: IssueTaskGroups = { running: [], queued: [] };
// Dwell threshold before the activity card opens (MUL-5189).
//
// This badge is a passive cue riding on the right edge of dense scrolling
// lists (inbox rows, issue rows, board cards), and it appears on every issue
// an agent currently touches. Base UI's 600ms default is tuned for a hover
// target the user aims at; here the pointer crosses the badge constantly on
// its way to the row, the archive button, or the next row, so 600ms fires on
// travel rather than on intent and a 288px card lands over the rows below.
//
// 900ms sits past casual travel but still inside a deliberate "what is it
// doing?" pause. The header chip (issue-agent-header-chip) keeps its 150ms
// on purpose: it is one large chip the user aims at, not a per-row cue.
//
// The card body is read-only — no links, no buttons — so there is no hover
// bridge to protect and the close delay only needs to absorb pointer wobble
// across the 4px gap.
const OPEN_DELAY_MS = 900;
const CLOSE_DELAY_MS = 150;
interface IssueAgentActivityIndicatorProps {
issueId: string;
// Avatar tier. Kept very small — this is a corner-of-card cue, not a
// primary control. Default xs (16 px) reads as a dot at typical board
// densities while still showing the agent's face on hover-zoom.
size?: AvatarSize;
// Whether hovering opens the activity card. Opt OUT where the card's only
// incremental information is not worth a popup (Inbox — see below).
hoverCard?: boolean;
}
/**
* Small "is there an agent working on this issue right now" badge shown
* in the top-right of board cards and right after the identifier in list
* rows. Derives state from the workspace-wide agent task snapshot:
*
* - has ≥1 running task → tiny avatar stack + shimmering "Working"
* - 0 running, ≥1 queued → half-opacity stack + muted "Queued"
* - nothing → return null (no chrome, no placeholder)
*
* The shimmer reuses chat's `animate-chat-text-shimmer` utility (defined
* in packages/ui/styles/base.css). Earlier iterations layered a brand
* ring + opacity pulse around the avatars; both read as nervous on a
* dense board. Moving the "alive" signal onto the label keeps the
* avatars themselves still and lets the cue ride a piece of text the
* user can already read.
*
* Hover opens AgentActivityHoverContent which lists every active task
* with status dot + duration. No link rows — the card itself is the
* navigation target for issue detail.
*
* Surfaces that only need the cue can pass `hoverCard={false}` and get the
* badge alone. Inbox does (MUL-5189): the badge already shows who is running
* and whether they are working or queued, so on a triage surface the card's
* only incremental fact is elapsed time — which never changes the one
* decision an inbox row exists to support ("do I open this?"). Issue lists
* and board cards keep it: monitoring work in flight is what those views are
* for, and elapsed time is load-bearing there.
*
* Subscribes to the one shared workspace snapshot query but narrows it to
* this issue's tasks with a `select`. React Query's structural sharing keeps
* that selected value referentially stable when this issue's tasks are
* unchanged, so a snapshot invalidation (WS task:* events, driven by
* use-realtime-sync) only re-renders the rows whose own tasks actually moved
* — not the whole list. This is the de-amplification that keeps large issue
* lists cheap when agents are busy (MUL-4474). 30s staleTime is the offline
* fallback only.
*/
export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndicator({
issueId,
size = "xs",
hoverCard = true,
}: IssueAgentActivityIndicatorProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const select = useCallback(
(snapshot: AgentTask[]) => selectIssueTasks(snapshot, issueId),
[issueId],
);
const { data: groups = EMPTY_GROUPS } = useQuery({
...agentTaskSnapshotOptions(wsId),
select,
});
const { agentIds, opacity } = useMemo(() => {
// Stack heads: prefer running. If 0 running, fall back to queued.
// Each case is visually distinct (running gets shimmer, queued gets
// muted text) so the indicator always offers a face to hover.
const primary = groups.running.length > 0 ? groups.running : groups.queued;
const uniqueAgents = [...new Set(primary.map((t) => t.agent_id))];
return {
agentIds: uniqueAgents,
opacity: (groups.running.length > 0 ? "full" : "half") as "full" | "half",
};
}, [groups]);
if (agentIds.length === 0) return null;
const isRunning = opacity === "full";
const badge = (
<>
<AgentAvatarStack
agentIds={agentIds}
size={size}
opacity={opacity}
max={3}
/>
<span
className={cn(
"text-micro leading-none",
isRunning
? "animate-chat-text-shimmer"
: "text-muted-foreground",
)}
>
{isRunning
? t(($) => $.agent_activity.status_running)
: t(($) => $.agent_activity.status_queued)}
</span>
</>
);
if (!hoverCard) {
return (
<span className="inline-flex shrink-0 items-center gap-1">{badge}</span>
);
}
const hoverTasks = [...groups.running, ...groups.queued];
return (
<HoverCard>
<HoverCardTrigger
delay={OPEN_DELAY_MS}
closeDelay={CLOSE_DELAY_MS}
render={
<span className="inline-flex shrink-0 items-center gap-1" />
}
>
{badge}
</HoverCardTrigger>
<HoverCardContent align="end" className="w-72">
<AgentActivityHoverContent tasks={hoverTasks} />
</HoverCardContent>
</HoverCard>
);
});