diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 28fbaceff..77d96dac5 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -4,6 +4,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@m import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { ChatBubbleSparkleIcon } from "@/components/icons" +import { vendorIcon } from "@/lib/agent-sessions/vendor-icon" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" /** The wire row from `listAiSessions` — one AI agent session, newest first. */ @@ -58,6 +59,7 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) {
{sessions.map((session) => { const hasErrors = session.errorSpanCount > 0 + const VendorIcon = vendorIcon(session.vendorId) const vendor = vendorLabel(session.vendorId) const secondary = session.vendorVersion && session.vendorVersion !== "0" @@ -95,7 +97,10 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) { {formatRelativeTimeOrDate(session.startTime)}
-
{secondary}
+
+ + {secondary} +
{hasErrors && (
diff --git a/apps/web/src/components/icons/claude.tsx b/apps/web/src/components/icons/claude.tsx new file mode 100644 index 000000000..5f03fa9a6 --- /dev/null +++ b/apps/web/src/components/icons/claude.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Claude spark mark — Claude-branded agent frameworks. */ +function ClaudeIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { ClaudeIcon } diff --git a/apps/web/src/components/icons/crewai.tsx b/apps/web/src/components/icons/crewai.tsx new file mode 100644 index 000000000..c8717deb1 --- /dev/null +++ b/apps/web/src/components/icons/crewai.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The CrewAI mark. */ +function CrewAiIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { CrewAiIcon } diff --git a/apps/web/src/components/icons/effect.tsx b/apps/web/src/components/icons/effect.tsx new file mode 100644 index 000000000..17c704f9f --- /dev/null +++ b/apps/web/src/components/icons/effect.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Effect mark, for Effect AI. */ +function EffectIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { EffectIcon } diff --git a/apps/web/src/components/icons/haystack.tsx b/apps/web/src/components/icons/haystack.tsx new file mode 100644 index 000000000..69417320c --- /dev/null +++ b/apps/web/src/components/icons/haystack.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Haystack mark. */ +function HaystackIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { HaystackIcon } diff --git a/apps/web/src/components/icons/huggingface.tsx b/apps/web/src/components/icons/huggingface.tsx new file mode 100644 index 000000000..43cf20ed2 --- /dev/null +++ b/apps/web/src/components/icons/huggingface.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Hugging Face mark, for smolagents. */ +function HuggingFaceIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { HuggingFaceIcon } diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index a4aa9e638..5085cd809 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -73,8 +73,10 @@ export { ChartBarTrendUpIcon } from "./chart-bar-trend-up" export { ChartLineIcon } from "./chart-line" export { CirclePercentageIcon } from "./circle-percentage" export { CircleQuestionIcon } from "./circle-question" +export { ClaudeIcon } from "./claude" export { ClickhouseIcon } from "./clickhouse" export { CloudflareIcon, CloudflareMonoIcon } from "./cloudflare" +export { CrewAiIcon } from "./crewai" export { ChromeIcon } from "./chrome" export { CompactLinesIcon } from "./compact-lines" export { CornerDownLeftIcon } from "./corner-down-left" @@ -83,6 +85,7 @@ export { CursorPointerIcon } from "./cursor-pointer" export { CreditCardIcon } from "./credit-card" export { CubeIcon } from "./cube" export { DiscordIcon } from "./discord" +export { EffectIcon } from "./effect" export { DotsVerticalIcon } from "./dots-vertical" export { DownloadIcon } from "./download" export { EdgeIcon } from "./edge" @@ -96,6 +99,8 @@ export { FolderIcon } from "./folder" export { GearIcon } from "./gear" export { GithubIcon } from "./github" export { GoogleIcon } from "./google" +export { HaystackIcon } from "./haystack" +export { HuggingFaceIcon } from "./huggingface" export { GridIcon } from "./grid" export { GridSquareCirclePlusIcon } from "./grid-square-circle-plus" export { GripDotsIcon } from "./grip-dots" @@ -107,6 +112,7 @@ export { HouseIcon } from "./house" export { IdBadgeIcon } from "./id-badge" export { ImageIcon } from "./image" export { KafkaIcon } from "./kafka" +export { LangchainIcon } from "./langchain" export { KeyIcon } from "./key" export { KeyboardIcon } from "./keyboard" export { KubernetesIcon } from "./kubernetes" @@ -118,18 +124,21 @@ export { LogoutIcon } from "./logout" export { MaximizeIcon } from "./maximize" export { MediaPauseIcon } from "./media-pause" export { MediaPlayIcon } from "./media-play" +export { MicrosoftIcon } from "./microsoft" export { MinimizeIcon } from "./minimize" export { MongodbIcon } from "./mongodb" export { MoonIcon } from "./moon" export { MysqlIcon } from "./mysql" export { NatsIcon } from "./nats" export { NodejsIcon } from "./nodejs" +export { OpenAiIcon } from "./openai" export { OpenjdkIcon } from "./openjdk" export { OperaIcon } from "./opera" export { PaletteIcon } from "./palette" export { PaperPlaneIcon } from "./paper-plane" export { PencilIcon } from "./pencil" export { PlanetScaleIcon } from "./planetscale" +export { PydanticIcon } from "./pydantic" // Nucleo Pixel Essential — a deliberately separate family, used where a row // needs a type marker rather than an action affordance (the session replay // event rail). Don't mix them with the outline icons inside one control. @@ -151,6 +160,7 @@ export { SafariIcon } from "./safari" export { ServerIcon } from "./server" export { ShieldIcon } from "./shield" export { SlackIcon, SlackMonoIcon } from "./slack" +export { SpringIcon } from "./spring" export { SlidersIcon } from "./sliders" export { SpinnerIcon } from "./spinner" export { SquareIcon } from "./square" @@ -160,6 +170,7 @@ export { SunIcon } from "./sun" export { TelegramIcon, TelegramMonoIcon } from "./telegram" export { TranscriptIcon } from "./transcript" export { TruckIcon } from "./truck" +export { VercelIcon } from "./vercel" export { WarpStreamIcon } from "./warpstream" export { UploadIcon } from "./upload" export { WorkflowRingIcon, WORKFLOW_LABEL, WORKFLOW_COLOR } from "./workflow-ring" diff --git a/apps/web/src/components/icons/langchain.tsx b/apps/web/src/components/icons/langchain.tsx new file mode 100644 index 000000000..9473b3eeb --- /dev/null +++ b/apps/web/src/components/icons/langchain.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The LangChain chain-link mark. */ +function LangchainIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { LangchainIcon } diff --git a/apps/web/src/components/icons/microsoft.tsx b/apps/web/src/components/icons/microsoft.tsx new file mode 100644 index 000000000..89851817f --- /dev/null +++ b/apps/web/src/components/icons/microsoft.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Microsoft four-square mark, flattened to one colour. */ +function MicrosoftIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { MicrosoftIcon } diff --git a/apps/web/src/components/icons/openai.tsx b/apps/web/src/components/icons/openai.tsx new file mode 100644 index 000000000..ebbafba8a --- /dev/null +++ b/apps/web/src/components/icons/openai.tsx @@ -0,0 +1,19 @@ +import type { IconProps } from "./icon" + +function OpenAiIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { OpenAiIcon } diff --git a/apps/web/src/components/icons/pydantic.tsx b/apps/web/src/components/icons/pydantic.tsx new file mode 100644 index 000000000..761213a5a --- /dev/null +++ b/apps/web/src/components/icons/pydantic.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Pydantic mark, for Pydantic AI. */ +function PydanticIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { PydanticIcon } diff --git a/apps/web/src/components/icons/spring.tsx b/apps/web/src/components/icons/spring.tsx new file mode 100644 index 000000000..bf3a43753 --- /dev/null +++ b/apps/web/src/components/icons/spring.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "./icon" + +/** The Spring mark, for Spring AI. */ +function SpringIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { SpringIcon } diff --git a/apps/web/src/components/icons/vercel.tsx b/apps/web/src/components/icons/vercel.tsx new file mode 100644 index 000000000..d411d0646 --- /dev/null +++ b/apps/web/src/components/icons/vercel.tsx @@ -0,0 +1,19 @@ +import type { IconProps } from "./icon" + +function VercelIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { VercelIcon } diff --git a/apps/web/src/lab/agent-session-fixture.ts b/apps/web/src/lab/agent-session-fixture.ts index 44ad9990f..fec60dcdd 100644 --- a/apps/web/src/lab/agent-session-fixture.ts +++ b/apps/web/src/lab/agent-session-fixture.ts @@ -256,12 +256,16 @@ function toolResult(tool: string): string | undefined { } } +/** The gateway stamps a vendor on every AI span, so the fixture carries one too + * — it is what puts a framework mark beside the label in the header and rows. */ +const VENDOR_ID = "claude_agent_sdk" + export function buildAgentSessionFixture(): readonly AiSessionSpan[] { const base = buildBaseTurns() const baseEndMs = Math.max(...base.map((span) => spanStartMs(span) + span.durationMs)) - T0 - return [...base, ...buildTranscriptTurns(baseEndMs + 2 * MINUTE)].sort( - (a, b) => spanStartMs(a) - spanStartMs(b), - ) + return [...base, ...buildTranscriptTurns(baseEndMs + 2 * MINUTE)] + .sort((a, b) => spanStartMs(a) - spanStartMs(b)) + .map((span) => ({ ...span, vendorId: VENDOR_ID })) } /** diff --git a/apps/web/src/lib/agent-sessions/vendor-icon.test.ts b/apps/web/src/lib/agent-sessions/vendor-icon.test.ts new file mode 100644 index 000000000..c35c1e6bb --- /dev/null +++ b/apps/web/src/lib/agent-sessions/vendor-icon.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" + +import { ChatBubbleSparkleIcon, ClaudeIcon, LangchainIcon, OpenAiIcon } from "@/components/icons" +import { vendorIcon } from "./vendor-icon" + +describe("vendorIcon", () => { + it("gives a recognised framework its brand mark", () => { + expect(vendorIcon("claude_agent_sdk")).toBe(ClaudeIcon) + expect(vendorIcon("langchain")).toBe(LangchainIcon) + // Two ids, one framework: the OpenInference instrumentation is still OpenAI. + expect(vendorIcon("openai_agents_sdk")).toBe(OpenAiIcon) + expect(vendorIcon("openinference-openai")).toBe(OpenAiIcon) + }) + + it("falls back for frameworks without a mark, and for no vendor at all", () => { + expect(vendorIcon("litellm")).toBe(ChatBubbleSparkleIcon) + expect(vendorIcon("unknown:genai")).toBe(ChatBubbleSparkleIcon) + expect(vendorIcon("")).toBe(ChatBubbleSparkleIcon) + }) +}) diff --git a/apps/web/src/lib/agent-sessions/vendor-icon.ts b/apps/web/src/lib/agent-sessions/vendor-icon.ts new file mode 100644 index 000000000..eb621fc67 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/vendor-icon.ts @@ -0,0 +1,50 @@ +import { MapleMark } from "@maple/ui/components/icons" + +import { + ChatBubbleSparkleIcon, + ClaudeIcon, + CrewAiIcon, + EffectIcon, + GoogleIcon, + HaystackIcon, + HuggingFaceIcon, + type IconComponent, + LangchainIcon, + MicrosoftIcon, + OpenAiIcon, + PydanticIcon, + SpringIcon, + VercelIcon, +} from "@/components/icons" + +/** + * Brand marks for the vendor ids the ingest gateway stamps. A framework is + * listed only when its logo is one we can draw faithfully — the rest fall back + * to the generic mark on purpose. An approximated logo names the wrong company; + * the fallback merely declines to name one, and `vendorLabel` says it in words + * either way. + */ +const VENDOR_ICONS: Record = { + claude_agent_sdk: ClaudeIcon, + crewai: CrewAiIcon, + effect_ai: EffectIcon, + // eve is Vercel's agent framework and has no mark of its own. + eve: VercelIcon, + google_adk: GoogleIcon, + haystack: HaystackIcon, + langchain: LangchainIcon, + maple: MapleMark, + microsoft_agent_framework: MicrosoftIcon, + openai_agents_sdk: OpenAiIcon, + "openinference-openai": OpenAiIcon, + pydantic_ai: PydanticIcon, + semantic_kernel: MicrosoftIcon, + smolagents: HuggingFaceIcon, + spring_ai: SpringIcon, + vercel_ai_sdk: VercelIcon, +} satisfies Record + +/** The mark that belongs beside `vendorLabel(vendorId)`. */ +export function vendorIcon(vendorId: string): IconComponent { + return VENDOR_ICONS[vendorId] ?? ChatBubbleSparkleIcon +} diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index 4f7337d25..26b6e7fd3 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -29,6 +29,7 @@ import { } from "@/lib/agent-sessions/session-window" import { buildSessionSummary, type SessionSummary } from "@/lib/agent-sessions/session-summary" import { buildSessionTurns } from "@/lib/agent-sessions/session-turns" +import { vendorIcon } from "@/lib/agent-sessions/vendor-icon" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" import { Result, useAtomValue } from "@/lib/effect-atom" import { displayError } from "@/lib/error-messages" @@ -225,6 +226,8 @@ function SessionDetailBody({ // Message content is opt-in and off by default, so most sessions have no // opening user message to title the page with. const title = summary.title ?? breadcrumbSessionId(sessionId) + // The framework that emitted the session, as its mark — the subtitle names it. + const VendorIcon = vendorIcon(summary.vendorIds[0] ?? "") return ( @@ -233,6 +236,7 @@ function SessionDetailBody({ + {summary.title === undefined ? ( // The id fallback title copies the full session id.