Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/plugins/execution-history/src/react/detail-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -333,11 +334,14 @@ function DetailContent(props: {
</MetaCard>
<MetaCard label="Actor">
{run.actorId !== null ? (
<span className="font-mono text-xs break-all">
{actorLabel}
{run.actorKind !== null ? (
<span className="text-muted-foreground/60"> · {run.actorKind}</span>
) : null}
<span className="flex items-center gap-2 font-mono text-xs">
<span aria-hidden className={cn("size-2 shrink-0 rounded-full", actor.dot)} />
<span className={cn("min-w-0 break-all", actor.text)}>
{actorLabel}
{run.actorKind !== null ? (
<span className="text-muted-foreground/60"> · {run.actorKind}</span>
) : null}
</span>
</span>
) : (
<span className="text-muted-foreground/60">—</span>
Expand Down
9 changes: 7 additions & 2 deletions packages/plugins/execution-history/src/react/filter-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function FacetRow({
dotClass,
pulse,
label,
labelClass,
count,
monoLabel,
}: {
Expand All @@ -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;
}) {
Expand Down Expand Up @@ -153,7 +155,9 @@ function FacetRow({
className={cn("size-2 shrink-0 rounded-full", dotClass, pulse && "animate-pulse")}
/>

<span className={cn("flex-1 truncate", monoLabel && "font-mono text-[11px]")}>{label}</span>
<span className={cn("flex-1 truncate", monoLabel && "font-mono text-[11px]", labelClass)}>
{label}
</span>

<span className="font-mono text-[10px] tabular-nums text-muted-foreground/50">
{count ?? ""}
Expand Down Expand Up @@ -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 (
<li key={id}>
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/execution-history/src/react/run-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/execution-history/src/react/status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 33 additions & 12 deletions packages/plugins/execution-history/src/react/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,26 +95,47 @@ 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 {
readonly dot: string;
readonly text: string;
}

export const ACTOR_TONES: Record<string, ActorTone> = {
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;
};
Loading