Skip to content

Commit d2687c4

Browse files
committed
New AI streamText span sidebar
1 parent 93309c3 commit d2687c4

File tree

16 files changed

+1377
-96
lines changed

16 files changed

+1377
-96
lines changed
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { lazy, Suspense, useState } from "react";
2+
import { CodeBlock } from "~/components/code/CodeBlock";
3+
import type { DisplayItem, ToolUse } from "./types";
4+
5+
// Lazy load streamdown to avoid SSR issues
6+
const StreamdownRenderer = lazy(() =>
7+
import("streamdown").then((mod) => ({
8+
default: ({ children }: { children: string }) => (
9+
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
10+
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
11+
</mod.ShikiThemeContext.Provider>
12+
),
13+
}))
14+
);
15+
16+
export function AIChatMessages({ items }: { items: DisplayItem[] }) {
17+
return (
18+
<div className="flex flex-col divide-y divide-grid-bright">
19+
{items.map((item, i) => {
20+
switch (item.type) {
21+
case "system":
22+
return <SystemSection key={i} text={item.text} />;
23+
case "user":
24+
return <UserSection key={i} text={item.text} />;
25+
case "tool-use":
26+
return <ToolUseSection key={i} tools={item.tools} />;
27+
case "assistant":
28+
return <AssistantResponse key={i} text={item.text} />;
29+
}
30+
})}
31+
</div>
32+
);
33+
}
34+
35+
// ---------------------------------------------------------------------------
36+
// Section header (shared across all sections)
37+
// ---------------------------------------------------------------------------
38+
39+
function SectionHeader({
40+
label,
41+
right,
42+
}: {
43+
label: string;
44+
right?: React.ReactNode;
45+
}) {
46+
return (
47+
<div className="flex items-center justify-between">
48+
<span className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{label}</span>
49+
{right && <div className="flex items-center gap-2">{right}</div>}
50+
</div>
51+
);
52+
}
53+
54+
// ---------------------------------------------------------------------------
55+
// System
56+
// ---------------------------------------------------------------------------
57+
58+
function SystemSection({ text }: { text: string }) {
59+
const [expanded, setExpanded] = useState(false);
60+
const isLong = text.length > 150;
61+
const preview = isLong ? text.slice(0, 150) + "..." : text;
62+
63+
return (
64+
<div className="flex flex-col gap-1 py-2.5">
65+
<SectionHeader
66+
label="System"
67+
right={
68+
isLong ? (
69+
<button
70+
onClick={() => setExpanded(!expanded)}
71+
className="text-[10px] text-text-link hover:underline"
72+
>
73+
{expanded ? "Collapse" : "Expand"}
74+
</button>
75+
) : undefined
76+
}
77+
/>
78+
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-text-dimmed">
79+
{expanded || !isLong ? text : preview}
80+
</pre>
81+
</div>
82+
);
83+
}
84+
85+
// ---------------------------------------------------------------------------
86+
// User
87+
// ---------------------------------------------------------------------------
88+
89+
function UserSection({ text }: { text: string }) {
90+
return (
91+
<div className="flex flex-col gap-1 py-2.5">
92+
<SectionHeader label="User" />
93+
<p className="text-sm text-text-bright">{text}</p>
94+
</div>
95+
);
96+
}
97+
98+
// ---------------------------------------------------------------------------
99+
// Assistant response (with markdown/raw toggle)
100+
// ---------------------------------------------------------------------------
101+
102+
export function AssistantResponse({
103+
text,
104+
headerLabel = "Assistant",
105+
}: {
106+
text: string;
107+
headerLabel?: string;
108+
}) {
109+
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
110+
111+
return (
112+
<div className="flex flex-col gap-1 py-2.5">
113+
<SectionHeader
114+
label={headerLabel}
115+
right={
116+
<div className="flex items-center gap-2">
117+
<button
118+
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
119+
className="text-[10px] text-text-link hover:underline"
120+
>
121+
{mode === "rendered" ? "Raw" : "Rendered"}
122+
</button>
123+
<button
124+
onClick={() => navigator.clipboard.writeText(text)}
125+
className="text-[10px] text-text-link hover:underline"
126+
>
127+
Copy
128+
</button>
129+
</div>
130+
}
131+
/>
132+
{mode === "rendered" ? (
133+
<div className="streamdown-container text-sm text-text-bright">
134+
<Suspense fallback={<pre className="whitespace-pre-wrap">{text}</pre>}>
135+
<StreamdownRenderer>{text}</StreamdownRenderer>
136+
</Suspense>
137+
</div>
138+
) : (
139+
<CodeBlock code={text} maxLines={20} showLineNumbers={false} showCopyButton />
140+
)}
141+
</div>
142+
);
143+
}
144+
145+
// ---------------------------------------------------------------------------
146+
// Tool use (merged calls + results)
147+
// ---------------------------------------------------------------------------
148+
149+
function ToolUseSection({ tools }: { tools: ToolUse[] }) {
150+
return (
151+
<div className="flex flex-col gap-1.5 py-2.5">
152+
<SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} />
153+
{tools.map((tool) => (
154+
<ToolUseRow key={tool.toolCallId} tool={tool} />
155+
))}
156+
</div>
157+
);
158+
}
159+
160+
type ToolTab = "input" | "output" | "details";
161+
162+
function ToolUseRow({ tool }: { tool: ToolUse }) {
163+
const hasInput = tool.inputJson !== "{}";
164+
const hasResult = !!tool.resultOutput;
165+
const hasDetails = !!tool.description || !!tool.parametersJson;
166+
167+
const availableTabs: ToolTab[] = [
168+
...(hasInput ? (["input"] as const) : []),
169+
...(hasResult ? (["output"] as const) : []),
170+
...(hasDetails ? (["details"] as const) : []),
171+
];
172+
173+
const defaultTab: ToolTab | null = hasInput ? "input" : null;
174+
const [activeTab, setActiveTab] = useState<ToolTab | null>(defaultTab);
175+
176+
function handleTabClick(tab: ToolTab) {
177+
setActiveTab(activeTab === tab ? null : tab);
178+
}
179+
180+
return (
181+
<div className="rounded-sm border border-grid-bright bg-charcoal-800/40">
182+
<div className="flex items-center gap-2 px-2.5 py-1.5">
183+
<code className="font-mono text-xs text-text-bright">{tool.toolName}</code>
184+
{tool.resultSummary && (
185+
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
186+
)}
187+
</div>
188+
189+
{availableTabs.length > 0 && (
190+
<>
191+
<div className="flex gap-0 border-t border-grid-bright">
192+
{availableTabs.map((tab) => (
193+
<button
194+
key={tab}
195+
onClick={() => handleTabClick(tab)}
196+
className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
197+
activeTab === tab
198+
? "bg-charcoal-750 text-text-bright"
199+
: "text-text-dimmed hover:text-text-bright"
200+
}`}
201+
>
202+
{tab}
203+
</button>
204+
))}
205+
</div>
206+
207+
{activeTab === "input" && hasInput && (
208+
<div className="border-t border-grid-dimmed">
209+
<CodeBlock
210+
code={tool.inputJson}
211+
maxLines={12}
212+
showLineNumbers={false}
213+
showCopyButton
214+
/>
215+
</div>
216+
)}
217+
218+
{activeTab === "output" && hasResult && (
219+
<div className="border-t border-grid-dimmed">
220+
<CodeBlock
221+
code={tool.resultOutput!}
222+
maxLines={16}
223+
showLineNumbers={false}
224+
showCopyButton
225+
/>
226+
</div>
227+
)}
228+
229+
{activeTab === "details" && hasDetails && (
230+
<div className="border-t border-grid-dimmed px-2.5 py-2 flex flex-col gap-2">
231+
{tool.description && (
232+
<p className="text-xs text-text-dimmed leading-relaxed">{tool.description}</p>
233+
)}
234+
{tool.parametersJson && (
235+
<div>
236+
<span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed">
237+
Parameters schema
238+
</span>
239+
<CodeBlock
240+
code={tool.parametersJson}
241+
maxLines={16}
242+
showLineNumbers={false}
243+
showCopyButton
244+
/>
245+
</div>
246+
)}
247+
</div>
248+
)}
249+
</>
250+
)}
251+
</div>
252+
);
253+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
2+
import type { AISpanData } from "./types";
3+
4+
export function AITagsRow({ aiData }: { aiData: AISpanData }) {
5+
return (
6+
<div className="flex flex-wrap items-center gap-1.5 py-2.5">
7+
<Pill>{aiData.model}</Pill>
8+
{aiData.provider !== "unknown" && <Pill variant="dimmed">{aiData.provider}</Pill>}
9+
{aiData.finishReason && <Pill variant="dimmed">{aiData.finishReason}</Pill>}
10+
{aiData.serviceTier && <Pill variant="dimmed">tier: {aiData.serviceTier}</Pill>}
11+
{aiData.toolChoice && <Pill variant="dimmed">tools: {aiData.toolChoice}</Pill>}
12+
{aiData.toolCount != null && aiData.toolCount > 0 && (
13+
<Pill variant="dimmed">
14+
{aiData.toolCount} {aiData.toolCount === 1 ? "tool" : "tools"}
15+
</Pill>
16+
)}
17+
{aiData.messageCount != null && (
18+
<Pill variant="dimmed">
19+
{aiData.messageCount} {aiData.messageCount === 1 ? "msg" : "msgs"}
20+
</Pill>
21+
)}
22+
{aiData.telemetryMetadata &&
23+
Object.entries(aiData.telemetryMetadata).map(([key, value]) => (
24+
<Pill key={key} variant="dimmed">
25+
{key}: {value}
26+
</Pill>
27+
))}
28+
</div>
29+
);
30+
}
31+
32+
export function AIStatsSummary({ aiData }: { aiData: AISpanData }) {
33+
return (
34+
<div className="flex flex-col gap-1 py-2.5">
35+
<span className="text-xs font-medium uppercase tracking-wide text-text-dimmed">Stats</span>
36+
37+
<div className="flex flex-col text-[11px]">
38+
<MetricRow label="Input" value={aiData.inputTokens.toLocaleString()} unit="tokens" />
39+
<MetricRow label="Output" value={aiData.outputTokens.toLocaleString()} unit="tokens" />
40+
{aiData.cachedTokens != null && aiData.cachedTokens > 0 && (
41+
<MetricRow label="Cached" value={aiData.cachedTokens.toLocaleString()} unit="tokens" />
42+
)}
43+
{aiData.reasoningTokens != null && aiData.reasoningTokens > 0 && (
44+
<MetricRow
45+
label="Reasoning"
46+
value={aiData.reasoningTokens.toLocaleString()}
47+
unit="tokens"
48+
/>
49+
)}
50+
<MetricRow
51+
label="Total"
52+
value={aiData.totalTokens.toLocaleString()}
53+
unit="tokens"
54+
bold
55+
border
56+
/>
57+
58+
{aiData.totalCost != null && (
59+
<MetricRow label="Cost" value={formatCurrencyAccurate(aiData.totalCost)} />
60+
)}
61+
{aiData.msToFirstChunk != null && (
62+
<MetricRow label="TTFC" value={formatTtfc(aiData.msToFirstChunk)} />
63+
)}
64+
{aiData.tokensPerSecond != null && (
65+
<MetricRow label="Speed" value={`${Math.round(aiData.tokensPerSecond)} tok/s`} />
66+
)}
67+
</div>
68+
</div>
69+
);
70+
}
71+
72+
function MetricRow({
73+
label,
74+
value,
75+
unit,
76+
bold,
77+
border,
78+
}: {
79+
label: string;
80+
value: string;
81+
unit?: string;
82+
bold?: boolean;
83+
border?: boolean;
84+
}) {
85+
return (
86+
<div
87+
className={`flex items-center justify-between py-1 ${
88+
border ? "border-t border-grid-dimmed" : ""
89+
}`}
90+
>
91+
<span className="text-text-dimmed">{label}</span>
92+
<span className={bold ? "font-medium text-text-bright" : "text-text-bright"}>
93+
{value}
94+
{unit && <span className="ml-1 text-text-dimmed">{unit}</span>}
95+
</span>
96+
</div>
97+
);
98+
}
99+
100+
function formatTtfc(ms: number): string {
101+
if (ms >= 10_000) {
102+
return `${(ms / 1000).toFixed(1)}s`;
103+
}
104+
return `${Math.round(ms)}ms`;
105+
}
106+
107+
function Pill({
108+
children,
109+
variant = "default",
110+
}: {
111+
children: React.ReactNode;
112+
variant?: "default" | "dimmed";
113+
}) {
114+
return (
115+
<span
116+
className={`inline-flex items-center rounded-sm px-1.5 py-0.5 text-[11px] font-medium ${
117+
variant === "dimmed"
118+
? "bg-charcoal-750 text-text-dimmed"
119+
: "bg-charcoal-700 text-text-bright"
120+
}`}
121+
>
122+
{children}
123+
</span>
124+
);
125+
}

0 commit comments

Comments
 (0)