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>
273 lines
9.7 KiB
TypeScript
273 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
|
||
import { useActorName } from "@multica/core/workspace/hooks";
|
||
import { useWorkspaceId } from "@multica/core/hooks";
|
||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||
import { deriveAgentAvailability } from "@multica/core/agents";
|
||
import type { AgentTask, Issue } from "@multica/core/types";
|
||
import { workloadConfig } from "../presence";
|
||
import { useT } from "../../i18n";
|
||
|
||
interface AgentActivityHoverContentProps {
|
||
// Active tasks (running / queued / dispatched) to render — caller filters
|
||
// by issue id or by workspace scope. Order is preserved; we render every
|
||
// task as its own row.
|
||
tasks: readonly AgentTask[];
|
||
}
|
||
|
||
/**
|
||
* Tick `now` once per second so duration labels update live while a hover
|
||
* card is open. setInterval only runs while the card is mounted (Base UI
|
||
* portals the content but tears it down on close), so this costs nothing
|
||
* when the card is closed.
|
||
*/
|
||
function useActivityNow(): number {
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
return now;
|
||
}
|
||
|
||
/**
|
||
* O(1) agent + runtime lookups so each task row resolves without an N×M
|
||
* scan. Cheap — agents/runtimes count in tens at most.
|
||
*/
|
||
function useActivityLookups() {
|
||
const wsId = useWorkspaceId();
|
||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||
const agentById = new Map(agents.map((a) => [a.id, a] as const));
|
||
const runtimeById = new Map(runtimes.map((r) => [r.id, r] as const));
|
||
return { agentById, runtimeById };
|
||
}
|
||
|
||
type ActivityLookups = ReturnType<typeof useActivityLookups>;
|
||
|
||
/**
|
||
* One task row: agent avatar, name, status dot, status label, duration.
|
||
*
|
||
* Status colour follows the workspace's existing composition rule:
|
||
* - running → brand (text-brand)
|
||
* - queued, runtime online → muted gray (transient race)
|
||
* - queued, runtime offline/etc. → warning amber (genuine stuck)
|
||
* — same rule as agent-presence-indicator.tsx so users see a single,
|
||
* consistent language for "agent is in trouble" vs "just enqueued".
|
||
*/
|
||
function AgentActivityTaskRow({
|
||
task,
|
||
now,
|
||
agentById,
|
||
runtimeById,
|
||
}: {
|
||
task: AgentTask;
|
||
now: number;
|
||
} & ActivityLookups) {
|
||
const { t } = useT("issues");
|
||
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
|
||
|
||
const agent = agentById.get(task.agent_id);
|
||
const runtime = runtimeFrom(agent?.runtime_id, runtimeById);
|
||
const availability = deriveAgentAvailability(runtime, now);
|
||
const isRunning = task.status === "running";
|
||
// queued/dispatched both read as "queued" in the user-facing copy —
|
||
// `dispatched` is the daemon-acked sub-state of queued and not
|
||
// user-meaningful here.
|
||
const wl = isRunning ? workloadConfig.working : workloadConfig.queued;
|
||
// queued + online → muted gray (transient race, no warning);
|
||
// queued + offline/unstable → keep warning amber from workloadConfig.
|
||
// Mirrors agent-presence-indicator.tsx.
|
||
const dotClass = isRunning
|
||
? "bg-brand"
|
||
: availability === "online"
|
||
? "bg-muted-foreground/40"
|
||
: "bg-warning";
|
||
const labelClass = isRunning
|
||
? wl.textClass
|
||
: availability === "online"
|
||
? "text-muted-foreground"
|
||
: wl.textClass;
|
||
const startedFrom = isRunning
|
||
? (task.started_at ?? task.dispatched_at ?? task.created_at)
|
||
: task.created_at;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2 text-caption">
|
||
<ActorAvatarBase
|
||
name={getActorName("agent", task.agent_id)}
|
||
initials={getActorInitials("agent", task.agent_id)}
|
||
avatarUrl={getActorAvatarUrl("agent", task.agent_id)}
|
||
isAgent
|
||
size="sm"
|
||
/>
|
||
<span className="flex-1 truncate font-medium">
|
||
{getActorName("agent", task.agent_id)}
|
||
</span>
|
||
<span className="flex shrink-0 items-center gap-1.5">
|
||
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
|
||
<span className={labelClass}>
|
||
{isRunning
|
||
? t(($) => $.agent_activity.status_running)
|
||
: t(($) => $.agent_activity.status_queued)}
|
||
</span>
|
||
<span className="tabular-nums text-muted-foreground">
|
||
{formatDuration(startedFrom, now)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Shared hover-card body for "what are these agents doing right now?" — used
|
||
* by IssueAgentActivityIndicator (per-issue). One row per task.
|
||
*
|
||
* The workspace-wide chip uses WorkspaceAgentActivityHoverContent below,
|
||
* which groups the same rows by issue.
|
||
*/
|
||
export function AgentActivityHoverContent({
|
||
tasks,
|
||
}: AgentActivityHoverContentProps) {
|
||
const { t } = useT("issues");
|
||
const now = useActivityNow();
|
||
const { agentById, runtimeById } = useActivityLookups();
|
||
|
||
if (tasks.length === 0) return null;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="text-caption font-medium text-muted-foreground">
|
||
{/* One row per task, so count tasks — not agents. A single agent can
|
||
run several tasks at once, so an agent-worded header here would
|
||
disagree with the row count below. */}
|
||
{t(($) => $.agent_activity.hover_header_tasks, { count: tasks.length })}
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{tasks.map((task) => (
|
||
<AgentActivityTaskRow
|
||
key={task.id}
|
||
task={task}
|
||
now={now}
|
||
agentById={agentById}
|
||
runtimeById={runtimeById}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface WorkspaceAgentActivityHoverContentProps {
|
||
/** Issues the working filter leaves on screen, in list order. Each has at
|
||
* least one running task. */
|
||
issues: readonly Issue[];
|
||
/** Running tasks for those issues, keyed by issue id. */
|
||
tasksByIssueId: ReadonlyMap<string, readonly AgentTask[]>;
|
||
/** Total running tasks across `issues` — the second header figure. */
|
||
taskCount: number;
|
||
}
|
||
|
||
/**
|
||
* Hover-card body for the workspace working chip (MUL-4884).
|
||
*
|
||
* The chip says WHO is working ("N agents working"); this card says WHERE.
|
||
* The header carries the two figures the chip does not — how many issues
|
||
* that work lands on, and how many tasks it takes — and the rows group by
|
||
* issue, mirroring what clicking the chip does to the list.
|
||
*
|
||
* It says nothing about work it excludes. Chat/autopilot runs have no
|
||
* linked issue and leave no trace anywhere on this page: no row, no head,
|
||
* no indicator. A footnote about them would explain an absence the user
|
||
* never perceived — inventing a discrepancy rather than resolving one.
|
||
* Same for tasks on issues the current filters or the loaded page exclude.
|
||
*
|
||
* Deliberately not a dashboard: two figures and grouped rows.
|
||
*/
|
||
export function WorkspaceAgentActivityHoverContent({
|
||
issues,
|
||
tasksByIssueId,
|
||
taskCount,
|
||
}: WorkspaceAgentActivityHoverContentProps) {
|
||
const { t } = useT("issues");
|
||
const now = useActivityNow();
|
||
const { agentById, runtimeById } = useActivityLookups();
|
||
|
||
if (issues.length === 0) {
|
||
return (
|
||
<p className="text-caption text-muted-foreground">
|
||
{t(($) => $.agent_activity.empty_hover)}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2.5">
|
||
<div className="text-caption font-medium text-muted-foreground">
|
||
{`${t(($) => $.agent_activity.issues_count, {
|
||
count: issues.length,
|
||
})} · ${t(($) => $.agent_activity.tasks_count, { count: taskCount })}`}
|
||
</div>
|
||
<div className="flex flex-col gap-2.5">
|
||
{issues.map((issue) => (
|
||
<div key={issue.id} className="flex flex-col gap-1.5">
|
||
<div className="flex items-baseline gap-1.5 text-caption">
|
||
<span className="shrink-0 font-mono text-micro text-muted-foreground">
|
||
{issue.identifier}
|
||
</span>
|
||
<span className="truncate">{issue.title}</span>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{(tasksByIssueId.get(issue.id) ?? []).map((task) => (
|
||
<AgentActivityTaskRow
|
||
key={task.id}
|
||
task={task}
|
||
now={now}
|
||
agentById={agentById}
|
||
runtimeById={runtimeById}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function runtimeFrom<T extends { id: string }>(
|
||
id: string | undefined,
|
||
byId: Map<string, T>,
|
||
): T | null {
|
||
if (!id) return null;
|
||
return byId.get(id) ?? null;
|
||
}
|
||
|
||
// Compact `2m 14s` / `45s` / `1h 03m` duration since the given ISO string.
|
||
// Capped at hours — anything over a day for a running task is a sign of a
|
||
// stuck runtime, but the hover card is not the place to relitigate that;
|
||
// the row will read as `26h 12m` and the user can act.
|
||
//
|
||
// Exported so the issue-detail header live chip formats its collapsed
|
||
// single-agent elapsed with the same `2m 14s` / `1h 03m` rule used here.
|
||
export function formatDuration(fromIso: string, nowMs: number): string {
|
||
const start = new Date(fromIso).getTime();
|
||
if (!Number.isFinite(start)) return "";
|
||
const sec = Math.max(0, Math.round((nowMs - start) / 1000));
|
||
if (sec < 60) return `${sec}s`;
|
||
const min = Math.floor(sec / 60);
|
||
const remSec = sec % 60;
|
||
if (min < 60) return `${min}m ${pad2(remSec)}s`;
|
||
const hr = Math.floor(min / 60);
|
||
const remMin = min % 60;
|
||
return `${hr}h ${pad2(remMin)}m`;
|
||
}
|
||
|
||
function pad2(n: number): string {
|
||
return n < 10 ? `0${n}` : String(n);
|
||
}
|