mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)
The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.
The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.
Data layer:
- The count is no longer re-derived from the task snapshot. The surface
exposes `workingScopeIssues`: the render pipeline's own output with
`workingOnly` forced on, so "chip count === row count" holds by
construction instead of by convention. It previously counted running
issue_ids against the PRE-filter set, so any active status/assignee/label
filter — or a sub-issue hidden by the display toggle — made the chip
disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
into one bucket and read as a phantom issue, inflating the count by exactly
one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
window. The count stays list-anchored — counting rows a click cannot show
would break the chip's whole promise — and the gap is stated in the hover
card rather than dropped silently.
UI:
- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
reserved for "filter is ON".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)
Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.
Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.
Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.
Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.
Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)
Follows the approved final mock. Two changes, one root cause each.
SEMANTICS: the chip now counts agents, not issues.
The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.
Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.
The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.
Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.
COLOUR: three tiers, each a self-contained Button variant.
Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.
New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.
Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)
Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.
`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.
The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.
`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).
Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.
Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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-xs">
|
||
<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-xs 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-xs text-muted-foreground">
|
||
{t(($) => $.agent_activity.empty_hover)}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2.5">
|
||
<div className="text-xs 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-xs">
|
||
<span className="shrink-0 font-mono text-[10px] 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);
|
||
}
|