mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 03:15:34 +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>
168 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
});
|