feat(lens): show agent, user and slack thread first in the run header (#45261)

* feat(lens): add who started the run to RunSource

* feat(lens): carry agent.source.user on trace span rows

* feat(lens): resolve the run source user from the span row

* feat(lens): read agent.source.user in trace_spans query

* feat(lens): read agent.source.user in trace_page_spans query

* feat(lens): read agent.source.user in trace_span_batch query

* feat(lens): read agent.source.user in trace_list_span_batch query

* feat(lens): decode the source user from clickhouse span rows

* test(lens): add source user to trace cache test rows

* test(lens): add source user to trace cache read fixtures

* test(lens): add source user to trace cache snapshot fixtures

* test(lens): add source user to capture fixture rows

* test(lens): round-trip source user on span row contract

* test(lens): cover run source user resolution

* chore(lens): regenerate python trace types with source user

* chore(lens): regenerate trace json schema with source user

* chore(lens): regenerate trace page json schema with source user

* chore(ui): add source user to api types

* feat(lens): add run user chip and slack thread chip

* feat(lens): put agent, user and thread on the first row of the run header

* test(lens): cover the run header identity row and compact totals

* feat(lens): start demo runs from a slack thread
This commit is contained in:
ishaan-berri 2026-10-07 20:01:55 -07:00 • committed by GitHub
parent 0e09e99146
commit b85756104f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 279 additions and 131 deletions

View file

@ -328,6 +328,7 @@ mod tests {
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: String::new(),
api_key_hash: String::new(),
user_id: String::new(),

View file

@ -289,6 +289,7 @@ fn span(index: usize) -> TraceSpansRow {
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: "team".into(),
api_key_hash: "key".into(),
user_id: "user".into(),

View file

@ -42,6 +42,7 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: String::new(),
api_key_hash: String::new(),
user_id: String::new(),

View file

@ -14,7 +14,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), ''))
AS tool_call_id,
o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url,
o.SpanAttributes['agent.source.title'] AS source_title,
o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user,
o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash
FROM otel_traces AS o
WHERE o.Timestamp >= fromUnixTimestamp64Milli({start_ms:Int64})

View file

@ -13,7 +13,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), ''))
AS tool_call_id,
o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url,
o.SpanAttributes['agent.source.title'] AS source_title,
o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user,
o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash
FROM otel_traces AS o
WHERE o.Timestamp >= fromUnixTimestamp64Milli({start_ms:Int64})

View file

@ -14,7 +14,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), ''))
AS tool_call_id,
o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url,
o.SpanAttributes['agent.source.title'] AS source_title,
o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user,
o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash
FROM otel_traces AS o
WHERE o.TraceId = {trace_id:String}

View file

@ -13,7 +13,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), ''))
AS tool_call_id,
o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url,
o.SpanAttributes['agent.source.title'] AS source_title,
o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user,
o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash
FROM otel_traces AS o
WHERE o.TraceId = {trace_id:String}

View file

@ -134,6 +134,8 @@ struct TraceSpansRowEncoding {
pub source_url: String,
#[serde(default)]
pub source_title: String,
#[serde(default)]
pub source_user: String,
pub team_id: String,
pub api_key_hash: String,
pub user_id: String,
@ -355,7 +357,7 @@ mod tests {
quoted,
);
round_trip::<TraceSpansRow>(
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "source_user": "tin@berri.ai", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
quoted,
);
round_trip::<SpanDetailRow>(

View file

@ -116,6 +116,8 @@ pub struct TraceSpansRow {
pub source_url: String,
#[serde(default)]
pub source_title: String,
#[serde(default)]
pub source_user: String,
pub team_id: String,
pub api_key_hash: String,
pub user_id: String,

View file

@ -146,6 +146,7 @@ fn source(row: &TraceSpansRow) -> Option<RunSource> {
.unwrap_or(RunSourceType::Custom),
url: row.source_url.clone(),
title: row.source_title.clone(),
user: row.source_user.clone(),
})
}

View file

@ -88,6 +88,10 @@ pub struct RunSource {
pub kind: RunSourceType,
pub url: String,
pub title: String,
/// Who started the conversation, e.g. the Slack user's email.
#[serde(default, skip_serializing_if = "String::is_empty")]
#[cfg_attr(feature = "schema", schemars(extend("x-python-optional" = true)))]
pub user: String,
}
#[macro_rules_attribute::apply(response_type)]

View file

@ -210,6 +210,7 @@ fn trace_span(span: DecodedSpan) -> TraceSpansRow {
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: "fixture-team".into(),
api_key_hash: "fixture-key".into(),
user_id: "fixture-user".into(),
@ -297,6 +298,7 @@ fn unrelated_transport(call: &TraceSpansRow) -> TraceSpansRow {
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: call.team_id.clone(),
api_key_hash: call.api_key_hash.clone(),
user_id: call.user_id.clone(),

View file

@ -53,7 +53,7 @@ fn result_contracts_preserve_public_field_names() {
json!({"trace_id": "trace", "trace_ref": "ref", "team_id": "team", "api_key_hash": "key", "user_id": "user", "name": "agent", "service": "service", "input_preview": "input", "status": "STATUS_CODE_OK", "start_ms": -1, "duration_ms": 20, "span_count": u64::MAX, "agent_count": 1, "agent_invocations": 2, "agent_names": ["agent"], "frameworks": ["framework"], "llm_calls": 3, "tool_calls": 4, "input_tokens": 5, "output_tokens": 6, "models": ["model"], "error_count": 0, "request_ids": ["request"]}),
);
round_trip::<TraceSpansRow>(
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "source_user": "tin@berri.ai", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
);
round_trip::<SpanDetailRow>(
json!({"span_id": "span", "input": "input", "output": "output", "attributes": {"count": "42"}}),

View file

@ -36,6 +36,7 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac
source_type: String::new(),
source_url: String::new(),
source_title: String::new(),
source_user: String::new(),
team_id: "team".into(),
api_key_hash: "key".into(),
user_id: String::new(),
@ -227,6 +228,16 @@ fn summary_source_type_picks_the_app(#[case] source_type: &str, #[case] expected
assert_eq!(source.map(|source| source.kind), Some(expected));
}
#[rstest]
#[case::set("tin@berri.ai")]
#[case::missing("")]
fn summary_source_carries_who_started_it(#[case] user: &str) {
let mut root = sourced(row("root", "", "agent", "agent", "agent"), THREAD, "t");
root.source_user = user.into();
let source = resolve_trace("t", "", &[root], &[]).unwrap().summary.source;
assert_eq!(source.map(|source| source.user), Some(user.to_owned()));
}
#[rstest]
fn spans_are_offset_from_the_trace_start() {
let trace = resolve_trace("t1", "", &deep_agent(1), &[]).unwrap();

View file

@ -109,6 +109,7 @@ class RunSource(typing_extensions.TypedDict):
type: ReadOnly[RunSourceType]
url: ReadOnly[str]
title: ReadOnly[str]
user: ReadOnly[NotRequired[str]]
class Span(typing_extensions.TypedDict):

View file

@ -71,6 +71,11 @@
},
"url": {
"type": "string"
},
"user": {
"description": "Who started the conversation, e.g. the Slack user's email.",
"type": "string",
"x-python-optional": true
}
},
"required": [

View file

@ -11,6 +11,11 @@
},
"url": {
"type": "string"
},
"user": {
"description": "Who started the conversation, e.g. the Slack user's email.",
"type": "string",
"x-python-optional": true
}
},
"required": [

View file

@ -5,6 +5,15 @@ import { scenarios, type Scenario } from "./scenarios";
const executionId = (traceId: string) => btoa(JSON.stringify(["traces", "", traceId]));
const iso = (time: number) => new Date(time).toISOString();
const DEMO_ASKERS = ["maya@acme.dev", "jordan@acme.dev", "priya@acme.dev"];
/** Demo runs start from a Slack thread so the run header shows who asked and where. */
const demoSource = (scene: Scenario, index: number): NonNullable<Trace["summary"]["source"]> => ({
type: "slack",
url: `https://acme.slack.com/archives/C0DEMO/p${1_700_000_000_000 + index}`,
title: scene.question,
user: DEMO_ASKERS[index % DEMO_ASKERS.length],
});
function makeTrace(scene: Scenario, index: number, now: number) {
const traceId = (index + 1).toString(16).padStart(32, "0");
@ -79,6 +88,7 @@ function makeTrace(scene: Scenario, index: number, now: number) {
status: scene.failed ? "error" : "ok",
tool_calls: toolCount,
trace_id: traceId,
source: demoSource(scene, index),
},
agents: [
{

View file

@ -1,58 +1,55 @@
"use client";
import { ArrowLeft, Check, Copy, Link, ListTree, MessagesSquare, RefreshCw } from "lucide-react";
import {
ArrowLeft,
Check,
ChevronDown,
Copy,
DatabaseZap,
Link,
ListTree,
MessagesSquare,
RefreshCw,
} from "lucide-react";
import { useState } from "react";
import { useTimeout } from "usehooks-ts";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { AddToDatasetButton } from "../../../datasets/AddToDatasetDialog";
import { AddToDatasetDialog, useCanAddToDataset } from "../../../datasets/AddToDatasetDialog";
import type { TraceHandoff } from "../../api";
import { runCost } from "../../list/AgentTracesTable";
import { traceRefOf, traceShareUrl } from "../../routing";
import { IdChip } from "../../ui/IdChip";
import { RunSourceLink } from "../../ui/RunSource";
import { RunSourceLink, RunUser } from "../../ui/RunSource";
import { SpanIcon } from "../../ui/SpanIcon";
import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework";
import type { SignalFlag, Trace } from "../../types";
import { SignalPills } from "../../ui/SignalPills";
import { fmtMs, fmtTok, traceAgentNames, traceDisplayName } from "../../utils";
interface CopyButtonProps {
label: string;
icon: typeof Copy;
text: () => string;
toast: string;
type Summary = Trace["summary"];
function statusLabel(failed: boolean, errors: number): string {
if (errors === 0) return failed ? "Errors recorded" : "Recorded";
const stepErrors = `${errors.toLocaleString()} step ${errors === 1 ? "error" : "errors"}`;
return failed ? stepErrors : `Recorded · ${stepErrors}`;
}
function CopyButton({ label, icon: Icon, text, toast }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<Button
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(text(), toast))}
>
{copied ? <Check className="size-3" /> : <Icon className="size-3" />}
{copied ? "Copied" : label}
</Button>
);
}
function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
return (
<span className={cn("inline-flex items-center gap-1 tabular-nums", error && "text-destructive")}>
<span>{label}</span> <span className={cn("font-medium", !error && "text-foreground")}>{value}</span>
</span>
);
}
function StatusPill({ failed }: { failed: boolean }) {
/** One pill for how the run went, so a failure is stated once instead of as a pill, a stat and a red icon. */
function StatusPill({ summary }: { summary: Summary }) {
const failed = summary.status === "error";
const label = statusLabel(failed, summary.error_count);
return (
<span
title="Status of received spans. More spans may still arrive."
@ -62,14 +59,26 @@ function StatusPill({ failed }: { failed: boolean }) {
)}
>
<span className={cn("size-1.5 rounded-full", failed ? "bg-destructive" : "bg-trace-ok-glyph")} />
{failed ? "Errors recorded" : "Recorded"}
{label}
</span>
);
}
function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) {
/** Duration, steps, tokens and cost as one quiet line; cost is left out when nothing was priced. */
function Totals({ summary }: { summary: Summary }) {
const cost = runCost(summary);
const parts = [
fmtMs(summary.duration_ms),
`${summary.span_count.toLocaleString()} steps`,
`${fmtTok(summary.input_tokens + summary.output_tokens)} tokens`,
cost && [cost.label, cost.partial?.long].filter(Boolean).join(" · "),
].filter(Boolean);
return <span className="text-foreground tabular-nums">{parts.join(" · ")}</span>;
}
function RunIcon({ summary }: { summary: Summary }) {
const framework = traceFramework(summary);
if (!framework) return <SpanIcon type="agent" error={failed} size="lg" />;
if (!framework) return <SpanIcon type="agent" size="lg" />;
return (
<span
className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md border border-border px-1.5 text-xs text-muted-foreground"
@ -82,6 +91,61 @@ function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boole
);
}
/** "Copy for agent" up front; the link and dataset actions sit behind its chevron. */
function RunActions({ summary, handoff }: { summary: Summary; handoff: TraceHandoff }) {
const canAddToDataset = useCanAddToDataset();
const [copied, setCopied] = useState(false);
const [addingToDataset, setAddingToDataset] = useState(false);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<>
<ButtonGroup>
<Button
variant="outline"
size="xs"
className="h-7 gap-1.5 text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "Copied" : "Copy for agent"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-xs" className="h-7 w-7 shadow-none" aria-label="More run actions" />
}
>
<ChevronDown className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() =>
void copyToClipboard(traceShareUrl(traceRefOf(summary), window.location), "Trace link copied")
}
>
<Link />
Copy link
</DropdownMenuItem>
{canAddToDataset && (
<DropdownMenuItem onClick={() => setAddingToDataset(true)}>
<DatabaseZap />
Add run to dataset
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
{addingToDataset && (
<AddToDatasetDialog
sources={[{ kind: "trace", trace_id: summary.trace_id, trace_ref: summary.trace_ref ?? "", span_id: "" }]}
agentName={traceAgentNames(summary)[0]}
onClose={() => setAddingToDataset(false)}
/>
)}
</>
);
}
interface RunHeaderProps {
trace: Trace;
handoff: TraceHandoff;
@ -95,7 +159,7 @@ interface RunHeaderProps {
signals?: readonly SignalFlag[];
}
/** Run identity, view switch and totals in two tight rows. */
/** Row one says which agent ran, for whom and from where; row two says how it went. */
export function RunHeader({
trace,
handoff,
@ -109,73 +173,66 @@ export function RunHeader({
signals = [],
}: RunHeaderProps) {
const { summary } = trace;
const failed = summary.status === "error";
const cost = runCost(summary);
return (
<header className="@container/run-header flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
<div className="flex min-w-0 flex-col gap-2 @xl/run-header:flex-row @xl/run-header:items-center">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<h1 className="min-w-0 flex-1 truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" />
<RunIcon summary={summary} failed={failed} />
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 @xl/run-header:ml-auto">
<TabsList aria-label="Trace view" className="group-data-horizontal/tabs:h-7">
<TabsTrigger value="steps" className="gap-1.5 px-2.5 text-xs">
<ListTree className="size-3.5" />
Steps
</TabsTrigger>
<TabsTrigger value="thread" className="gap-1.5 px-2.5 text-xs">
<MessagesSquare className="size-3.5" />
Thread
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="xs"
aria-pressed={live}
disabled={!canLive}
onClick={onLiveChange}
aria-label="Live updates"
>
Live
</Button>
<Button variant="outline" size="xs" disabled={refreshing} onClick={onRefresh} aria-label="Refresh run">
<RefreshCw className={cn("size-3", refreshing && "animate-spin")} />
Refresh
</Button>
<AddToDatasetButton
sources={[{ kind: "trace", trace_id: summary.trace_id, trace_ref: summary.trace_ref ?? "", span_id: "" }]}
agentName={traceAgentNames(summary)[0]}
<header className="flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
<div className="flex min-w-0 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<RunIcon summary={summary} />
<h1 className="min-w-0 shrink truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
{summary.source?.user && <RunUser user={summary.source.user} />}
{summary.source && <RunSourceLink source={summary.source} />}
<IdChip value={summary.trace_id} label="Copy trace ID" />
<div className="ml-auto flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="xs"
aria-pressed={live}
disabled={!canLive}
onClick={onLiveChange}
aria-label="Live updates"
title={live ? "Live: refreshes every 30s. Click to pause." : "Paused. Click to follow live."}
className="text-muted-foreground"
>
<span
className={cn(
"size-1.5 rounded-full",
live ? "bg-trace-ok-glyph motion-safe:animate-pulse" : "bg-muted-foreground/50",
)}
/>
<CopyButton
label="Copy link"
icon={Link}
text={() => traceShareUrl(traceRefOf(summary), window.location)}
toast="Trace link copied"
/>
<CopyButton label="Copy for agent" icon={Copy} text={() => handoff.text} toast={handoff.copied} />
</div>
Live
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={refreshing}
onClick={onRefresh}
aria-label="Refresh run"
title="Refresh"
className="mr-1 text-muted-foreground"
>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
</Button>
<RunActions summary={summary} handoff={handoff} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<StatusPill failed={failed} />
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
<StatusPill summary={summary} />
{signals.length > 0 && <SignalPills flags={signals} showScore className="flex-wrap" />}
{summary.source && <RunSourceLink source={summary.source} />}
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />
<Stat
label="Cost"
value={cost ? [cost.label, cost.partial?.long].filter(Boolean).join(" · ") : "Not reported"}
/>
{summary.error_count > 0 && <Stat label="Step errors" value={summary.error_count.toLocaleString()} error />}
<Totals summary={summary} />
<TabsList aria-label="Trace view" className="ml-auto group-data-horizontal/tabs:h-7">
<TabsTrigger value="steps" className="gap-1.5 px-2.5 text-xs">
<ListTree className="size-3.5" />
Steps
</TabsTrigger>
<TabsTrigger value="thread" className="gap-1.5 px-2.5 text-xs">
<MessagesSquare className="size-3.5" />
Thread
</TabsTrigger>
</TabsList>
</div>
</header>
);

View file

@ -78,8 +78,8 @@ describe("RunView", () => {
"title",
research.summary.trace_id,
);
expect(header).toHaveTextContent("Duration 40.20s");
expect(header).toHaveTextContent(`Steps ${research.summary.span_count}`);
expect(header).toHaveTextContent("40.20s");
expect(header).toHaveTextContent(`${research.summary.span_count} steps`);
expect(header).toHaveTextContent("Recorded");
expect(header).not.toHaveTextContent("Completed");
});
@ -97,6 +97,37 @@ describe("RunView", () => {
expect(within(header).queryByTestId("span-icon")).not.toBeInTheDocument();
});
it("puts who started the run and its Slack thread next to the agent name", async () => {
const thread = "https://acme.slack.com/archives/C1/p1";
renderRun({
...research,
summary: {
...research.summary,
spend: null,
source: { type: "slack", url: thread, title: "why is the deploy failing?", user: "tin@berri.ai" },
},
});
const header = await screen.findByRole("banner");
expect(within(header).getByTestId("run-user")).toHaveTextContent("tin@berri.ai");
expect(within(header).getByRole("link", { name: "Open Slack thread" })).toHaveAttribute("href", thread);
expect(header).not.toHaveTextContent("Not reported");
});
it("leaves the user out when the source does not say who started it", async () => {
renderRun({
...research,
summary: {
...research.summary,
source: { type: "slack", url: "https://acme.slack.com/archives/C1/p1", title: "" },
},
});
const header = await screen.findByRole("banner");
expect(within(header).queryByTestId("run-user")).not.toBeInTheDocument();
expect(within(header).getByRole("link", { name: "Open Slack thread" })).toBeInTheDocument();
});
it("keeps the generic agent icon when the trace has no known SDK", async () => {
renderRun({ ...research, summary: { ...research.summary, frameworks: ["some-other-sdk"] } });
@ -110,7 +141,7 @@ describe("RunView", () => {
const tree = await screen.findByRole("tree", { name: "Spans in time order" });
expect(tree).toHaveTextContent("researcher×12");
expect(screen.getByRole("banner")).toHaveTextContent(`Step errors ${swarm.summary.error_count}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${swarm.summary.error_count} step errors`);
});
it.each([
@ -395,7 +426,7 @@ describe("RunView", () => {
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
fireEvent.click(screen.getByRole("button", { name: "Refresh run" }));
expect(await screen.findByText("newly received step")).toBeVisible();
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 1}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count + 1} steps`);
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
});
@ -413,11 +444,11 @@ describe("RunView", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count} steps`);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 7}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count + 7} steps`);
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research));
} finally {
vi.useRealTimers();
@ -476,7 +507,7 @@ describe("RunView", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(60_100);
});
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count} steps`);
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
@ -517,7 +548,7 @@ describe("RunView", () => {
expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument();
expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2);
if (action === "Refresh run") {
expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${updated.summary.span_count}`);
expect(screen.getByRole("banner")).toHaveTextContent(`${updated.summary.span_count} steps`);
}
} finally {
vi.useRealTimers();
@ -642,7 +673,7 @@ describe("RunView", () => {
renderRun({ ...research, summary: { ...research.summary, status: "ok", error_count: 2 } });
const header = await screen.findByRole("banner");
expect(header).toHaveTextContent("Recorded");
expect(header).toHaveTextContent("Step errors 2");
expect(header).toHaveTextContent("Recorded · 2 step errors");
expect(header).not.toHaveTextContent("Failed");
});
@ -684,7 +715,8 @@ describe("RunView", () => {
const user = userEvent.setup();
renderRun(research);
await user.click(await screen.findByRole("button", { name: /copy link/i }));
fireEvent.click(await screen.findByRole("button", { name: "More run actions" }));
await user.click(await screen.findByRole("menuitem", { name: /copy link/i }));
const url = new URL(vi.mocked(copyToClipboard).mock.calls[0][0] as string);
expect(url.pathname).toBe(window.location.pathname);
expect(url.searchParams.get("trace")).toBe(research.summary.trace_id);

View file

@ -1,6 +1,6 @@
"use client";
import { MessagesSquare } from "lucide-react";
import { ArrowUpRight, MessagesSquare, UserRound } from "lucide-react";
import githubLogo from "../../../../../public/assets/logos/github.svg";
import jiraLogo from "../../../../../public/assets/logos/jira.svg";
@ -52,25 +52,36 @@ function AppMark({ app, className }: { app: SourceApp; className: string }) {
);
}
/** Links a run back to the conversation that started it, e.g. a Slack thread. */
const chip =
"inline-flex h-6 min-w-0 shrink items-center gap-1.5 rounded-full border border-border px-2 text-xs text-foreground";
/** Who started the run, e.g. the person who asked in Slack. */
export function RunUser({ user }: { user: string }) {
return (
<span className={chip} title={user} data-testid="run-user">
<UserRound aria-hidden className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">{user}</span>
</span>
);
}
/** Links a run back to the conversation that started it, e.g. a Slack thread; hover previews its title. */
export function RunSourceLink({ source }: { source: Source }) {
const app = sourceApp(source);
if (!app) return null;
return (
<HoverCard>
<span className="inline-flex items-center gap-1">
Source
<HoverCardTrigger
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`Source: ${app.label}`}
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
<AppMark app={app} className="size-3 shrink-0" />
{app.label}
</HoverCardTrigger>
</span>
<HoverCardTrigger
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${app.label} thread`}
className={`${chip} shrink-0 transition-colors hover:bg-muted`}
>
<AppMark app={app} className="size-3 shrink-0" />
<span className="truncate">{app.label} thread</span>
<ArrowUpRight aria-hidden className="size-3 shrink-0 text-muted-foreground" />
</HoverCardTrigger>
<HoverCardContent align="start" className="w-72 p-3">
<a href={source.url} target="_blank" rel="noopener noreferrer" className="flex flex-col gap-1.5">
<span className="line-clamp-2 text-sm font-medium text-foreground">
@ -78,7 +89,7 @@ export function RunSourceLink({ source }: { source: Source }) {
</span>
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<AppMark app={app} className="size-3 shrink-0" />
{app.label}
{source.user ? `${app.label} · ${source.user}` : app.label}
</span>
</a>
</HoverCardContent>

View file

@ -44979,6 +44979,8 @@ export interface components {
type: "slack" | "teams" | "discord" | "linear" | "github" | "jira" | "custom";
/** Url */
url: string;
/** User */
user?: string;
};
/** SCIMEnterpriseUser */
SCIMEnterpriseUser: {