Skip to content
Draft
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
96 changes: 76 additions & 20 deletions components/home/HeroDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isToolPart,
type ToolPartLike,
} from "@/components/tool-call/tool-part";
import { AgentTree } from "@/components/tool-call/agent-tree";

interface SpotifyArtist {
id: string;
Expand All @@ -38,6 +39,65 @@ const FEATURED_ARTIST_NAMES = ["Drake", "Tyla", "SZA", "Doja Cat"];
const CYCLE_INTERVAL_MS = 3500;
const transport = new DefaultChatTransport({ api: "/api/demo" });

// Discriminated union of "things to render" once we've grouped consecutive
// tool parts. A run of 2+ tool parts collapses into a single "agent-tree"
// group so it renders as a grid; a lone tool part stays inline.
type RenderGroup =
| { kind: "text"; text: string; idx: number }
| { kind: "reasoning"; text: string; idx: number }
| { kind: "single-tool"; part: ToolPartLike; idx: number }
| { kind: "agent-tree"; tools: ToolPartLike[]; idx: number };

/**
* Walk an assistant message's parts in order and bucket them into
* RenderGroups so the renderer can decide where to slot an AgentTree
* (multi-tool fan-out) vs. a single-tool inline card vs. text vs.
* reasoning. Empty text parts (mid-stream placeholders) are dropped.
*/
function groupParts(parts: readonly unknown[]): RenderGroup[] {
const groups: RenderGroup[] = [];
let toolBuffer: { part: ToolPartLike; idx: number }[] = [];

function flushTools() {
if (toolBuffer.length === 0) return;
if (toolBuffer.length === 1) {
groups.push({
kind: "single-tool",
part: toolBuffer[0].part,
idx: toolBuffer[0].idx,
});
} else {
groups.push({
kind: "agent-tree",
tools: toolBuffer.map((t) => t.part),
idx: toolBuffer[0].idx,
});
}
toolBuffer = [];
}

parts.forEach((rawPart, idx) => {
const part = rawPart as { type?: string; text?: string };
if (typeof part.type !== "string") return;

if (isToolPart(part as { type: string })) {
toolBuffer.push({ part: part as unknown as ToolPartLike, idx });
return;
}

flushTools();

if (part.type === "text" && part.text) {
groups.push({ kind: "text", text: part.text, idx });
} else if (part.type === "reasoning" && part.text) {
groups.push({ kind: "reasoning", text: part.text, idx });
}
});

flushTools();
return groups;
}

// Follow-up action pills under the latest assistant response. Every option
// has to map to behavior we can actually deliver β€” either by re-using the
// data already in the prior `similar_artists` tool result (no new tool call
Expand Down Expand Up @@ -439,42 +499,38 @@ export function HeroDemo() {
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-(--foreground)/50 animate-pulse" aria-label="streaming" />
)}
</div>
{/* Card body β€” render parts in their actual order so
tool calls interleave correctly with text + reasoning */}
{/* Card body β€” render parts in their actual order, but
group consecutive tool parts so a multi-agent fan-out
renders as a single AgentTree (grid) instead of
stacked individual cards. */}
<div className="text-[13px] leading-[1.8] text-(--foreground)/80 space-y-3 px-4 py-2">
{msg.parts.map((part, idx) => {
if (part.type === "text") {
if (!part.text) return null;
{groupParts(msg.parts).map((g, gi) => {
if (g.kind === "text") {
return (
<MessageResponse
key={idx}
isAnimating={isThisStreaming && idx === lastTextIdx}
key={gi}
isAnimating={isThisStreaming && g.idx === lastTextIdx}
>
{part.text}
{g.text}
</MessageResponse>
);
}
if (part.type === "reasoning") {
if (!part.text) return null;
if (g.kind === "reasoning") {
return (
<Reasoning
key={idx}
key={gi}
isStreaming={isThisStreaming && lastTextIdx === -1}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
<ReasoningContent>{g.text}</ReasoningContent>
</Reasoning>
);
}
if (isToolPart(part)) {
return (
<ToolPart
key={idx}
part={part as unknown as ToolPartLike}
/>
);
if (g.kind === "single-tool") {
return <ToolPart key={gi} part={g.part} />;
}
return null;
// g.kind === "agent-tree"
return <AgentTree key={gi} tools={g.tools} />;
})}
{isThisStreaming && !hasRenderableContent && (
<ThinkingIndicator />
Expand Down
89 changes: 89 additions & 0 deletions components/tool-call/agent-tree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import { ToolPart, type ToolPartLike } from "./tool-part";

/**
* AgentTree renders a fan-out turn β€” when the orchestrator spawned 2+
* agents in parallel on a single user prompt. Visually it's a header
* chip with the live agent counter ("3 of 4 agents working…") followed
* by a 2-column grid of agent cards. Each card surfaces its own state
* via its ToolHeader, so users see individual agents go from pulsing
* (running) to settled (complete) as their tool calls resolve.
*
* Used by HeroDemo when an assistant message contains 2+ contiguous
* tool parts. Single-tool responses render inline through ToolPart
* directly β€” no orchestrator framing needed.
*/
export function AgentTree({ tools }: { tools: ToolPartLike[] }) {
const total = tools.length;
const done = tools.filter(
(t) => t.state === "output-available" || t.state === "output-error",
).length;
const errored = tools.filter((t) => t.state === "output-error").length;
const allDone = done === total;

return (
<div className="space-y-2">
<OrchestratorChip total={total} done={done} errored={errored} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{tools.map((part, idx) => (
<AgentBranch key={part.toolCallId ?? idx} part={part} />
))}
</div>
{!allDone && (
<div className="text-[10px] font-pixel uppercase tracking-[0.12em] text-(--foreground)/35 pt-1">
Synthesis incoming…
</div>
)}
</div>
);
}

function OrchestratorChip({
total,
done,
errored,
}: {
total: number;
done: number;
errored: number;
}) {
const allDone = done === total;
const label = allDone
? errored > 0
? `${total} agents Β· ${errored} unavailable`
: `${total} agents Β· complete`
: `${done} of ${total} agents working…`;

return (
<div className="flex items-center gap-1.5">
<span
className={
allDone
? "w-1.5 h-1.5 rounded-full bg-(--foreground)/60"
: "w-1.5 h-1.5 rounded-full bg-(--foreground)/40 animate-pulse"
}
aria-hidden
/>
<span className="text-[10px] font-pixel uppercase tracking-[0.14em] text-(--foreground)/45">
Orchestrator Β· {label}
</span>
</div>
);
}

/**
* Each agent branch is just a wrapped ToolPart with the brand surface
* (shadow-as-border, rounded corners, background). The ToolHeader inside
* the renderer handles its own state pulse + label.
*/
function AgentBranch({ part }: { part: ToolPartLike }) {
return (
<div
className="rounded-xl bg-(--background) p-3"
style={{ boxShadow: "0 0 0 1px var(--border)" }}
>
<ToolPart part={part} />
</div>
);
}
128 changes: 128 additions & 0 deletions components/tool-call/renderers/audience-agent-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
AudienceAgentOutput,
Market,
} from "@/lib/agent/tools/audience-agent";

/**
* Audience Agent card body. Three stacked sections:
* 1. Top 5 markets β€” city + country + listener count + trend arrow
* 2. Age split β€” horizontal stacked bar (18-24 / 25-34 / 35-44 / 45+)
* 3. Gender split β€” horizontal stacked bar (female / male / other)
*
* The stacked bars are deliberately achromatic β€” varying opacity steps
* instead of color β€” so the eye reads the proportions, not the palette.
* Brand stays monochrome; data is the splash of meaning.
*/
export function AudienceAgentRenderer({
output,
}: {
output: AudienceAgentOutput;
}) {
const ageEntries = Object.entries(output.ageSplit) as Array<
[keyof AudienceAgentOutput["ageSplit"], number]
>;
const genderEntries = Object.entries(output.genderSplit) as Array<
[keyof AudienceAgentOutput["genderSplit"], number]
>;

return (
<div className="space-y-3">
<div className="space-y-1">
{output.topMarkets.map((m) => (
<MarketRow key={`${m.city}-${m.country}`} market={m} />
))}
</div>
<SegmentBar
label="AGE"
segments={ageEntries.map(([k, v]) => ({ key: k, value: v }))}
/>
<SegmentBar
label="GENDER"
segments={genderEntries.map(([k, v]) => ({ key: k, value: v }))}
/>
</div>
);
}

function MarketRow({ market }: { market: Market }) {
const TrendIcon =
market.trend === "growth"
? ArrowUpRight
: market.trend === "decline"
? ArrowDownRight
: Minus;
const trendTone =
market.trend === "growth"
? "text-(--foreground)/85"
: market.trend === "decline"
? "text-(--foreground)/40"
: "text-(--foreground)/55";

return (
<div className="flex items-center justify-between gap-2">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-[12px] font-medium text-(--foreground) truncate">
{market.city}
</span>
<span className="text-[10px] font-pixel uppercase tracking-[0.12em] text-(--foreground)/40 shrink-0">
{market.country}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] tabular-nums text-(--foreground)/70">
{formatListeners(market.listeners)}
</span>
<TrendIcon className={cn("w-3 h-3", trendTone)} aria-hidden />
</div>
</div>
);
}

function SegmentBar({
label,
segments,
}: {
label: string;
segments: { key: string; value: number }[];
}) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] font-pixel uppercase tracking-[0.14em] text-(--foreground)/35">
{label}
</span>
<div className="flex gap-1.5 text-[9.5px] font-pixel uppercase tracking-[0.08em] text-(--foreground)/45">
{segments.map((s) => (
<span key={s.key}>
{s.key} {Math.round(s.value * 100)}%
</span>
))}
</div>
</div>
<div className="flex h-1 rounded-full overflow-hidden bg-(--foreground)/8">
{segments.map((s, i) => (
<div
key={s.key}
className="h-full"
style={{
width: `${s.value * 100}%`,
backgroundColor: `color-mix(in srgb, var(--foreground) ${
100 - i * 22
}%, transparent)`,
}}
/>
))}
</div>
</div>
);
}

function formatListeners(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n}`;
}
Loading