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:
J
2026-07-15 02:20:53 +08:00
parent 9f437fade3
commit fcbfe677b5
4 changed files with 107 additions and 5 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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