mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)
Surface who each agent run is on behalf of where runs are actually browsed: - Agent detail activity tab: an avatar-only AttributionBadge on every task row's meta line (Now + Recent work), tooltip carries the name + resolution source. - Execution-record (transcript) dialog header: the full on-behalf-of badge next to the status pill. Adds a compact variant="avatar" mode to AttributionBadge, reusing its source-label mapping and degraded-attribution tone. Renders nothing when a run has no resolved accountable member. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -37,6 +37,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { AppLink } from "../../../navigation";
|
||||
import { TranscriptButton } from "../../../common/task-transcript";
|
||||
import { AttributionBadge } from "../../../issues/components/attribution-badge";
|
||||
import { taskStatusConfig } from "../../config";
|
||||
import { failureReasonLabel } from "./task-failure";
|
||||
import { Sparkline } from "../sparkline";
|
||||
@@ -639,6 +640,14 @@ function TaskRow({
|
||||
<span className="text-destructive">{failureLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{/* Accountable member (MUL-4302 §9): whose behalf this run is on.
|
||||
Avatar-only keeps the meta line tight; renders nothing when the
|
||||
run has no resolved responsible member. */}
|
||||
<AttributionBadge
|
||||
attribution={task.attribution}
|
||||
variant="avatar"
|
||||
className="ml-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { ActorAvatar } from "../actor-avatar";
|
||||
import { AttributionBadge } from "../../issues/components/attribution-badge";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
useTranscriptViewStore,
|
||||
@@ -500,6 +501,9 @@ export function AgentTranscriptDialog({
|
||||
|
||||
{statusBadge}
|
||||
|
||||
{/* Accountable member (MUL-4302 §9): whose behalf this run is on. */}
|
||||
<AttributionBadge attribution={task.attribution} className="shrink-0" />
|
||||
|
||||
<div className="flex w-full max-w-full flex-wrap items-center justify-end gap-1 sm:ml-auto sm:w-auto">
|
||||
{detailSeqs.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -86,4 +86,31 @@ describe("AttributionBadge", () => {
|
||||
// Unknown sources fall through to the raw label so nothing renders blank.
|
||||
expect(screen.getByTitle("future_source")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("avatar variant renders just the accountable member's avatar", () => {
|
||||
const attribution: TaskAttribution = {
|
||||
source: "direct_human",
|
||||
precise: true,
|
||||
initiator: { id: "u1", name: "Ada Lovelace" },
|
||||
};
|
||||
renderWithI18n(
|
||||
<AttributionBadge attribution={attribution} variant="avatar" />,
|
||||
);
|
||||
|
||||
// Only the avatar (name plumbs through the stub) — no "on behalf of" chip.
|
||||
expect(screen.getByTestId("actor-avatar")).toHaveTextContent("Ada Lovelace");
|
||||
expect(screen.queryByText("on behalf of Ada Lovelace")).toBeNull();
|
||||
});
|
||||
|
||||
it("avatar variant renders nothing without an accountable member", () => {
|
||||
const attribution: TaskAttribution = {
|
||||
source: "unattributed",
|
||||
precise: false,
|
||||
};
|
||||
const { container } = renderWithI18n(
|
||||
<AttributionBadge attribution={attribution} variant="avatar" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import type { TaskAttribution } from "@multica/core/types";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
@@ -17,18 +22,29 @@ function initialsOf(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* AttributionBadge renders the "on behalf of <member>" chip for an agent run
|
||||
* (MUL-4302 §9): who the run is accountable to, with the resolution source as a
|
||||
* tooltip and a distinct warning tone for degraded (non-precise) attribution.
|
||||
* Renders nothing when the task has no attribution (older backends) — the caller
|
||||
* should optional-chain `task.attribution`.
|
||||
* AttributionBadge renders who an agent run is accountable to (MUL-4302 §9):
|
||||
* the "on behalf of <member>" provenance, with the resolution source and a
|
||||
* distinct warning tone for degraded (non-precise) attribution.
|
||||
*
|
||||
* Two shapes:
|
||||
* - `variant="badge"` (default): the full "on behalf of <name>" chip, with an
|
||||
* explicit "unattributed" chip when no responsible member resolved.
|
||||
* - `variant="avatar"`: just the accountable member's avatar, with the name +
|
||||
* source in a hover tooltip. Compact enough for a dense task row. Renders
|
||||
* nothing when there's no accountable member — an avatar-only surface has
|
||||
* nothing meaningful to show for an unattributed run.
|
||||
*
|
||||
* Renders nothing when the task has no attribution at all (older backends) —
|
||||
* the caller should optional-chain `task.attribution`.
|
||||
*/
|
||||
export function AttributionBadge({
|
||||
attribution,
|
||||
className,
|
||||
variant = "badge",
|
||||
}: {
|
||||
attribution?: TaskAttribution;
|
||||
className?: string;
|
||||
variant?: "badge" | "avatar";
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
if (!attribution) return null;
|
||||
@@ -70,6 +86,52 @@ export function AttributionBadge({
|
||||
const degraded = attribution.precise === false;
|
||||
const initiator = attribution.initiator;
|
||||
|
||||
// Avatar-only shape: just the accountable member's face, with the name +
|
||||
// source in a hover tooltip. Nothing to show without an accountable member.
|
||||
if (variant === "avatar") {
|
||||
if (!initiator) return null;
|
||||
const name = initiator.name || t(($) => $.execution_log.attribution.someone);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0",
|
||||
// A subtle ring flags degraded attribution so an owner-fallback
|
||||
// face never silently reads as a precise responsible member.
|
||||
degraded && "rounded-full ring-1 ring-warning/60",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ActorAvatar
|
||||
name={name}
|
||||
initials={initialsOf(name)}
|
||||
avatarUrl={initiator.avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{t(($) => $.execution_log.attribution.on_behalf_of, { name })}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
degraded ? "text-warning" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initiator) {
|
||||
return (
|
||||
<Badge
|
||||
|
||||
Reference in New Issue
Block a user