mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
0e09e99146
commit
b85756104f
22 changed files with 279 additions and 131 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"}}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -44979,6 +44979,8 @@ export interface components {
|
|||
type: "slack" | "teams" | "discord" | "linear" | "github" | "jira" | "custom";
|
||||
/** Url */
|
||||
url: string;
|
||||
/** User */
|
||||
user?: string;
|
||||
};
|
||||
/** SCIMEnterpriseUser */
|
||||
SCIMEnterpriseUser: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue