Skip to content

Commit 498c1ae

Browse files
committed
Layout improvements to the rendered chat view
1 parent ad21176 commit 498c1ae

5 files changed

Lines changed: 137 additions & 71 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function TextSquareIcon({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
12+
<line x1="7" y1="8" x2="13" y2="8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
13+
<line x1="7" y1="12" x2="10" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
14+
<line x1="13" y1="12" x2="17" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
15+
<line x1="7" y1="16" x2="15" y2="16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
16+
</svg>
17+
);
18+
}

apps/webapp/app/components/code/StreamdownRenderer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ export const StreamdownRenderer = lazy(() =>
1919
children: string;
2020
isAnimating?: boolean;
2121
}) => (
22-
<Streamdown isAnimating={isAnimating} plugins={{ code: codePlugin }}>
22+
<Streamdown
23+
isAnimating={isAnimating}
24+
plugins={{ code: codePlugin }}
25+
linkSafety={{ enabled: false }}
26+
>
2327
{children}
2428
</Streamdown>
2529
),

apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import type { UIMessage } from "@ai-sdk/react";
22
import { memo } from "react";
3-
import {
4-
AssistantResponse,
5-
ChatBubble,
6-
ToolUseRow,
7-
} from "~/components/runs/v3/ai/AIChatMessages";
3+
import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages";
84
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
95

106
// ---------------------------------------------------------------------------
@@ -42,11 +38,7 @@ export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
4238
// Default shallow prop comparison is fine: AI SDK's useChat keeps stable
4339
// references for messages that haven't changed, so only the last message
4440
// (the one receiving new chunks) re-renders.
45-
export const MessageBubble = memo(function MessageBubble({
46-
message,
47-
}: {
48-
message: UIMessage;
49-
}) {
41+
export const MessageBubble = memo(function MessageBubble({ message }: { message: UIMessage }) {
5042
if (message.role === "user") {
5143
const text =
5244
message.parts
@@ -67,16 +59,48 @@ export const MessageBubble = memo(function MessageBubble({
6759
const hasContent = message.parts && message.parts.length > 0;
6860
if (!hasContent) return null;
6961

70-
return (
71-
<div className="space-y-2">
72-
{message.parts?.map((part, i) => renderPart(part, i))}
73-
</div>
74-
);
62+
return <div className="space-y-2">{renderAssistantParts(message.parts ?? [])}</div>;
7563
}
7664

7765
return null;
7866
});
7967

68+
// Group consecutive data-* parts (rendered as inline DataPartPopover pills)
69+
// under a single "Tool calls:" label with a flex-wrap row so they have a
70+
// proper gap between them. Non-data parts pass through to renderPart
71+
// unchanged.
72+
function renderAssistantParts(parts: UIMessage["parts"]) {
73+
const nodes: React.ReactNode[] = [];
74+
let i = 0;
75+
while (i < parts.length) {
76+
const type = parts[i].type as string;
77+
if (type?.startsWith?.("data-") && type !== "data-subagent-run") {
78+
const groupStart = i;
79+
const group: UIMessage["parts"] = [];
80+
while (
81+
i < parts.length &&
82+
(parts[i].type as string)?.startsWith?.("data-") &&
83+
(parts[i].type as string) !== "data-subagent-run"
84+
) {
85+
group.push(parts[i]);
86+
i++;
87+
}
88+
nodes.push(
89+
<div key={`data-${groupStart}`} className="flex items-center gap-1.5">
90+
<span className="text-xs font-medium text-text-dimmed">AI SDK data parts:</span>
91+
<div className="flex flex-wrap gap-1.5">
92+
{group.map((g, k) => renderPart(g, groupStart + k))}
93+
</div>
94+
</div>
95+
);
96+
} else {
97+
nodes.push(renderPart(parts[i], i));
98+
i++;
99+
}
100+
}
101+
return nodes;
102+
}
103+
80104
export function renderPart(part: UIMessage["parts"][number], i: number) {
81105
const p = part as any;
82106
const type = part.type as string;
@@ -91,9 +115,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
91115
return (
92116
<div key={i} className="border-l-2 border-amber-500/40 pl-2">
93117
<ChatBubble>
94-
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">
95-
{p.text ?? ""}
96-
</div>
118+
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">{p.text ?? ""}</div>
97119
</ChatBubble>
98120
</div>
99121
);
@@ -117,8 +139,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
117139
.pop();
118140
resultOutput = lastText?.text ?? undefined;
119141
} else if (p.output != null) {
120-
resultOutput =
121-
typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2);
142+
resultOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2);
122143
}
123144

124145
return (

apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx

Lines changed: 72 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import {
2-
CheckIcon,
3-
ChevronDownIcon,
4-
ChevronUpIcon,
5-
ClipboardDocumentIcon,
6-
CodeBracketSquareIcon,
7-
} from "@heroicons/react/20/solid";
1+
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
2+
import { Clipboard, ClipboardCheck } from "lucide-react";
83
import { Suspense, useEffect, useState } from "react";
4+
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
5+
import { TextSquareIcon } from "~/assets/icons/TextSquareIcon";
96
import { CodeBlock } from "~/components/code/CodeBlock";
107
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
118
import { Button, LinkButton } from "~/components/primitives/Buttons";
129
import { Header3 } from "~/components/primitives/Headers";
1310
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
11+
import {
12+
Tooltip,
13+
TooltipContent,
14+
TooltipProvider,
15+
TooltipTrigger,
16+
} from "~/components/primitives/Tooltip";
17+
import { cn } from "~/utils/cn";
1418
import type { DisplayItem, ToolUse } from "./types";
1519

1620
export type PromptLink = {
@@ -69,13 +73,7 @@ export function ChatBubble({ children }: { children: React.ReactNode }) {
6973
// System
7074
// ---------------------------------------------------------------------------
7175

72-
function SystemSection({
73-
text,
74-
promptLink,
75-
}: {
76-
text: string;
77-
promptLink?: PromptLink;
78-
}) {
76+
function SystemSection({ text, promptLink }: { text: string; promptLink?: PromptLink }) {
7977
const [expanded, setExpanded] = useState(false);
8078
const isLong = text.length > 150;
8179
const preview = isLong ? text.slice(0, 150) + "..." : text;
@@ -109,7 +107,7 @@ function SystemSection({
109107
)}
110108
</div>
111109
<ChatBubble>
112-
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
110+
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
113111
<Suspense fallback={<span className="whitespace-pre-wrap">{displayText}</span>}>
114112
<StreamdownRenderer>{displayText}</StreamdownRenderer>
115113
</Suspense>
@@ -128,7 +126,7 @@ function UserSection({ text }: { text: string }) {
128126
<div className="flex flex-col gap-1.5 py-2.5">
129127
<SectionHeader label="User" />
130128
<ChatBubble>
131-
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
129+
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
132130
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
133131
<StreamdownRenderer>{text}</StreamdownRenderer>
134132
</Suspense>
@@ -190,22 +188,44 @@ export function AssistantResponse({
190188
<SectionHeader
191189
label={headerLabel}
192190
right={
193-
<div className="flex items-center">
194-
<Button
195-
variant="minimal/small"
196-
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
197-
LeadingIcon={CodeBracketSquareIcon}
198-
>
199-
{mode === "rendered" ? "Raw" : "Rendered"}
200-
</Button>
201-
<Button
202-
variant="minimal/small"
203-
onClick={handleCopy}
204-
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
205-
leadingIconClassName={copied ? "text-green-500" : undefined}
206-
>
207-
Copy
208-
</Button>
191+
<div className="flex items-center gap-2">
192+
<TooltipProvider>
193+
<Tooltip disableHoverableContent>
194+
<TooltipTrigger
195+
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
196+
className="text-text-dimmed transition-colors duration-100 focus-custom hover:cursor-pointer hover:text-text-bright"
197+
>
198+
{mode === "rendered" ? (
199+
<CodeSquareIcon className="size-4.5" />
200+
) : (
201+
<TextSquareIcon className="size-4.5" />
202+
)}
203+
</TooltipTrigger>
204+
<TooltipContent side="top" className="text-xs">
205+
{mode === "rendered" ? "Show raw" : "Show rendered"}
206+
</TooltipContent>
207+
</Tooltip>
208+
</TooltipProvider>
209+
<TooltipProvider>
210+
<Tooltip open={copied || undefined} disableHoverableContent>
211+
<TooltipTrigger
212+
onClick={handleCopy}
213+
className={cn(
214+
"transition-colors duration-100 focus-custom hover:cursor-pointer",
215+
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
216+
)}
217+
>
218+
{copied ? (
219+
<ClipboardCheck className="size-4" />
220+
) : (
221+
<Clipboard className="size-4" />
222+
)}
223+
</TooltipTrigger>
224+
<TooltipContent side="top" className="text-xs">
225+
{copied ? "Copied" : "Copy"}
226+
</TooltipContent>
227+
</Tooltip>
228+
</TooltipProvider>
209229
</div>
210230
}
211231
/>
@@ -285,8 +305,18 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
285305
>
286306
<div className="flex items-center gap-2 px-2.5 py-1.5">
287307
{hasSubAgent && (
288-
<svg className="size-3.5 text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
289-
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
308+
<svg
309+
className="size-3.5 text-indigo-400"
310+
viewBox="0 0 24 24"
311+
fill="none"
312+
stroke="currentColor"
313+
strokeWidth={1.5}
314+
>
315+
<path
316+
strokeLinecap="round"
317+
strokeLinejoin="round"
318+
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
319+
/>
290320
</svg>
291321
)}
292322
<code
@@ -327,9 +357,7 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
327357
))}
328358
</div>
329359

330-
{activeTab === "agent" && hasSubAgent && (
331-
<SubAgentContent parts={tool.subAgent!.parts} />
332-
)}
360+
{activeTab === "agent" && hasSubAgent && <SubAgentContent parts={tool.subAgent!.parts} />}
333361

334362
{activeTab === "input" && hasInput && (
335363
<div className="border-t border-grid-dimmed">
@@ -338,6 +366,7 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
338366
maxLines={12}
339367
showLineNumbers={false}
340368
showCopyButton
369+
className="rounded-none border-0"
341370
/>
342371
</div>
343372
)}
@@ -350,13 +379,12 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
350379
maxLines={16}
351380
showLineNumbers={false}
352381
showCopyButton
382+
className="rounded-none border-0"
353383
/>
354384
) : (
355-
<div className="p-2.5 font-sans text-sm font-normal text-text-dimmed streamdown-container">
385+
<div className="streamdown-container p-2.5 font-sans text-sm font-normal text-text-dimmed">
356386
<Suspense
357-
fallback={
358-
<span className="whitespace-pre-wrap">{tool.resultOutput}</span>
359-
}
387+
fallback={<span className="whitespace-pre-wrap">{tool.resultOutput}</span>}
360388
>
361389
<StreamdownRenderer>{tool.resultOutput!}</StreamdownRenderer>
362390
</Suspense>
@@ -380,6 +408,7 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
380408
maxLines={16}
381409
showLineNumbers={false}
382410
showCopyButton
411+
className="rounded-none border-0"
383412
/>
384413
</div>
385414
)}
@@ -393,20 +422,14 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
393422

394423
function SubAgentContent({ parts }: { parts: any[] }) {
395424
// Extract sub-agent run ID from injected metadata part
396-
const runPart = parts.find(
397-
(p: any) => p.type === "data-subagent-run" && p.data?.runId
398-
);
425+
const runPart = parts.find((p: any) => p.type === "data-subagent-run" && p.data?.runId);
399426
const subAgentRunId = runPart?.data?.runId as string | undefined;
400427

401428
return (
402429
<div className="space-y-2 border-t border-indigo-500/20 p-2.5">
403430
{subAgentRunId && (
404431
<div className="flex justify-end">
405-
<LinkButton
406-
to={`/runs/${subAgentRunId}`}
407-
variant="tertiary/small"
408-
target="_blank"
409-
>
432+
<LinkButton to={`/runs/${subAgentRunId}`} variant="tertiary/small" target="_blank">
410433
View sub-agent run
411434
</LinkButton>
412435
</div>

apps/webapp/app/tailwind.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@
207207
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
208208
}
209209
& a {
210-
@apply text-blue-400 hover:underline;
210+
@apply text-text-link hover:underline;
211211
}
212212
& strong {
213213
@apply font-semibold text-text-bright;

0 commit comments

Comments
 (0)