From cbc8adc3236093088adccc24bee1a1fa72283295 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sun, 23 Aug 2026 07:46:31 +0530 Subject: [PATCH] feat(execution-history): color actors by identity Hash actorId into a stable palette so machines that share a credential class stay distinguishable in the filter rail, table, and detail drawer. --- .../src/react/detail-drawer.tsx | 16 ++++--- .../src/react/filter-rail.tsx | 9 +++- .../execution-history/src/react/run-row.tsx | 2 +- .../src/react/status.test.ts | 30 +++++++++++++ .../execution-history/src/react/status.ts | 45 ++++++++++++++----- 5 files changed, 81 insertions(+), 21 deletions(-) create mode 100644 packages/plugins/execution-history/src/react/status.test.ts diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx index ed4b8e00c6..76fe015448 100644 --- a/packages/plugins/execution-history/src/react/detail-drawer.tsx +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -22,7 +22,7 @@ import type { InteractionRow, InteractionStatus, RunRow, ToolCallRow } from "../ import { runDetailAtom } from "./atoms"; import { formatDateTime, formatDuration, logLines, prettyJson, statusLabel } from "./format"; import { HoverCardTimestamp } from "./hover-card-timestamp"; -import { STATUS_TONES, triggerTone } from "./status"; +import { actorTone, STATUS_TONES, triggerTone } from "./status"; // --------------------------------------------------------------------------- // Right-side run detail drawer: a 3-tab Sheet (Properties / Tool calls / Logs) @@ -248,6 +248,7 @@ function DetailContent(props: { const { run } = props; const tone = STATUS_TONES[run.status]; const trigger = triggerTone(run.triggerKind); + const actor = actorTone(run.actorId); // Prefer the live friendly label (e.g. a service-token machine name) over the // run-time snapshot, falling back to the snapshot then the id. const resolveActorLabel = useResolveActorLabel(); @@ -333,11 +334,14 @@ function DetailContent(props: { {run.actorId !== null ? ( - - {actorLabel} - {run.actorKind !== null ? ( - · {run.actorKind} - ) : null} + + + + {actorLabel} + {run.actorKind !== null ? ( + · {run.actorKind} + ) : null} + ) : ( diff --git a/packages/plugins/execution-history/src/react/filter-rail.tsx b/packages/plugins/execution-history/src/react/filter-rail.tsx index 3e8ad64d47..defa94ae0b 100644 --- a/packages/plugins/execution-history/src/react/filter-rail.tsx +++ b/packages/plugins/execution-history/src/react/filter-rail.tsx @@ -103,6 +103,7 @@ function FacetRow({ dotClass, pulse, label, + labelClass, count, monoLabel, }: { @@ -112,6 +113,7 @@ function FacetRow({ readonly dotClass: string; readonly pulse?: boolean; readonly label: string; + readonly labelClass?: string; readonly count: number | undefined; readonly monoLabel?: boolean; }) { @@ -153,7 +155,9 @@ function FacetRow({ className={cn("size-2 shrink-0 rounded-full", dotClass, pulse && "animate-pulse")} /> - {label} + + {label} + {count ?? ""} @@ -310,7 +314,7 @@ export function RunsFilterRail({ filters, meta, onChange, onReset }: RunsFilterR const id = entry.actorId; // null actor (unattributed runs) isn't filterable — skip it. if (id === null) return null; - const tone = actorTone(entry.actorKind); + const tone = actorTone(id); const checked = filters.actor.includes(id); return (
  • @@ -319,6 +323,7 @@ export function RunsFilterRail({ filters, meta, onChange, onReset }: RunsFilterR onToggle={() => onChange({ ...filters, actor: toggle(filters.actor, id) })} onOnly={() => onChange({ ...filters, actor: [id] })} dotClass={tone.dot} + labelClass={tone.text} label={resolveActorLabel(entry.actorKind, id) ?? entry.actorLabel ?? id} count={entry.count} monoLabel diff --git a/packages/plugins/execution-history/src/react/run-row.tsx b/packages/plugins/execution-history/src/react/run-row.tsx index 8c3101dd0a..cbd072c494 100644 --- a/packages/plugins/execution-history/src/react/run-row.tsx +++ b/packages/plugins/execution-history/src/react/run-row.tsx @@ -27,7 +27,7 @@ export interface RunListRowProps { export function RunListRow({ run, selected, isPast, columns, onSelect }: RunListRowProps) { const tone = STATUS_TONES[run.status]; const trigger = triggerTone(run.triggerKind); - const actor = actorTone(run.actorKind); + const actor = actorTone(run.actorId); // Prefer the live friendly label (e.g. a service-token machine name) over the // snapshot captured at run time, falling back to the snapshot then the id. const resolveActorLabel = useResolveActorLabel(); diff --git a/packages/plugins/execution-history/src/react/status.test.ts b/packages/plugins/execution-history/src/react/status.test.ts new file mode 100644 index 0000000000..dfb114c317 --- /dev/null +++ b/packages/plugins/execution-history/src/react/status.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { ACTOR_PALETTE, actorTone } from "./status"; + +const MUTED = { + dot: "bg-muted-foreground/40", + text: "text-muted-foreground", +} as const; + +describe("actorTone", () => { + it("mutes a missing actor id", () => { + expect(actorTone(null)).toEqual(MUTED); + expect(actorTone(undefined)).toEqual(MUTED); + }); + + it("returns the same palette slot for the same id", () => { + expect(actorTone("tok.phoenix")).toEqual(actorTone("tok.phoenix")); + }); + + it("picks a palette entry, not the muted fallback, for a real id", () => { + const tone = actorTone("tok.phoenix"); + expect(ACTOR_PALETTE).toContainEqual(tone); + }); + + it("spreads distinct ids across more than one hue", () => { + const ids = ["phoenix", "agni", "blaze", "cursor", "527888ce-060c-5"]; + const uniqueDots = new Set(ids.map((id) => actorTone(id).dot)); + expect(uniqueDots.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/plugins/execution-history/src/react/status.ts b/packages/plugins/execution-history/src/react/status.ts index 498f20c2c4..47f601dc7d 100644 --- a/packages/plugins/execution-history/src/react/status.ts +++ b/packages/plugins/execution-history/src/react/status.ts @@ -95,10 +95,12 @@ export const triggerTone = (kind: string | null | undefined): TriggerTone => { }; // --------------------------------------------------------------------------- -// Actor tones — colour the dot in the Actor facet/column by credential class -// (`actorKind`: "user", "service-token", …). The label itself is the actor's -// own display string (machine name, email), not a fixed vocabulary, so there is -// no ACTOR_ORDER — facet keys come from `meta.actorCounts`. +// Actor tones — colour the Actor facet/column/drawer by stable identity +// (`actorId`: token commonName, user subject). Credential class (`actorKind`) +// is a drawer suffix, not a hue: a host full of service tokens would otherwise +// paint every row the same violet. The facet is unbounded, so there is no +// ACTOR_ORDER — keys come from `meta.actorCounts`. Hash collisions are accepted +// once cardinality exceeds the palette. // --------------------------------------------------------------------------- export interface ActorTone { @@ -106,15 +108,34 @@ export interface ActorTone { readonly text: string; } -export const ACTOR_TONES: Record = { - user: { dot: "bg-sky-500", text: "text-foreground/80" }, - "service-token": { dot: "bg-violet-500", text: "text-foreground/80" }, +const MUTED_ACTOR_TONE: ActorTone = { + dot: "bg-muted-foreground/40", + text: "text-muted-foreground", }; -export const actorTone = (kind: string | null | undefined): ActorTone => { - if (kind != null) { - const known = ACTOR_TONES[kind]; - if (known) return known; +/** Identity-stable hues. Avoids status (emerald/sky/amber/red) and trigger + * (violet/cyan/slate) so a row's three dots stay independently readable. */ +export const ACTOR_PALETTE: readonly ActorTone[] = [ + { dot: "bg-fuchsia-500", text: "text-fuchsia-600 dark:text-fuchsia-300" }, + { dot: "bg-orange-500", text: "text-orange-600 dark:text-orange-300" }, + { dot: "bg-teal-500", text: "text-teal-600 dark:text-teal-300" }, + { dot: "bg-rose-500", text: "text-rose-600 dark:text-rose-300" }, + { dot: "bg-indigo-500", text: "text-indigo-600 dark:text-indigo-300" }, + { dot: "bg-lime-500", text: "text-lime-600 dark:text-lime-300" }, + { dot: "bg-pink-500", text: "text-pink-600 dark:text-pink-300" }, + { dot: "bg-yellow-500", text: "text-yellow-700 dark:text-yellow-300" }, +]; + +const fnv1a = (value: string): number => { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); } - return { dot: "bg-muted-foreground/40", text: "text-muted-foreground" }; + return hash >>> 0; +}; + +export const actorTone = (actorId: string | null | undefined): ActorTone => { + if (actorId == null) return MUTED_ACTOR_TONE; + return ACTOR_PALETTE[fnv1a(actorId) % ACTOR_PALETTE.length] ?? MUTED_ACTOR_TONE; };