Skip to content
Merged
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
19 changes: 11 additions & 8 deletions viewer/src/client/components/chat/ChatCodeBlock.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default function ChatCodeBlock({
lang,
label,
copyLabel = "Copy code",
showCopy = true,
className = "",
maxHeightClassName = "max-h-72",
}) {
Expand Down Expand Up @@ -137,14 +138,16 @@ export default function ChatCodeBlock({
color: #d4d4d8;
}
`}</style>
<div className="flex h-9 items-center justify-between border-b border-white/5 px-3 text-xs text-zinc-300">
<span className="truncate font-medium">{displayLabel}</span>
<ChatCopyButton
value={raw}
label={copyLabel}
className="size-6 text-zinc-300 opacity-80 hover:bg-white/10 hover:text-white"
/>
</div>
{showCopy ? (
<div className="flex h-9 items-center justify-between border-b border-white/5 px-3 text-xs text-zinc-300">
<span className="truncate font-medium">{displayLabel}</span>
<ChatCopyButton
value={raw}
label={copyLabel}
className="size-6 text-zinc-300 opacity-80 hover:bg-white/10 hover:text-white"
/>
</div>
) : null}
<pre className={`${maxHeightClassName} overflow-auto px-3 py-2.5 text-[12px] leading-relaxed`}>
<code
className="font-mono text-[#f4f4f5]"
Expand Down
9 changes: 8 additions & 1 deletion viewer/src/client/components/chat/ChatTurn.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ function turnCopyText(turn) {
.join("\n\n");
}

function turnShowsCopyButton(turn) {
return (turn.blocks || []).some((block) =>
block.kind === "text" || block.kind === "thinking" || block.kind === "plan",
);
}

function StatusLine({ turn }) {
if (turn.role !== "assistant") return null;
// "running" is conveyed by the live pulse inside PhaseBadge — no separate
Expand All @@ -103,6 +109,7 @@ export default function ChatTurn({ turn, onOpenArtifact }) {
turn.status === "complete" &&
turn.blocks.some((block) => block.kind === "artifact");
const copyText = turnCopyText(turn);
const showCopyButton = turnShowsCopyButton(turn);
return (
<article
data-slot="chat-turn"
Expand Down Expand Up @@ -202,7 +209,7 @@ export default function ChatTurn({ turn, onOpenArtifact }) {
) : null}
</div>
</div>
{!isUser ? (
{!isUser && showCopyButton ? (
<ChatCopyButton
value={copyText}
className="absolute bottom-1 right-1 z-10 opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100"
Expand Down
87 changes: 66 additions & 21 deletions viewer/src/client/components/chat/Markdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,89 @@ import remarkGfm from "remark-gfm";
import { cn } from "@/ui/utils";
import ChatCodeBlock from "./ChatCodeBlock";
import QuestionCard from "./QuestionCard";
import remarkCallouts from "./remarkCallouts";
import "./prose.css";

const REMARK_PLUGINS = [remarkGfm, remarkCallouts];

function flattenText(children) {
return Array.isArray(children) ? children.join("") : String(children || "");
}

// Tailwind v4 preflight resets headings/lists, so we style each element
// explicitly to match the chat type scale. Kept small and dependency-light
// (no typography plugin).
function CalloutIcon({ kind }) {
const warn = kind === "warning" || kind === "caution";
return (
<svg
className="prose-callout-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{warn ? (
<>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
) : (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>
)}
</svg>
);
}

function Callout({ kind, children }) {
return (
<div className="prose-callout" data-callout={kind}>
<CalloutIcon kind={kind} />
<div className="prose-callout-body">{children}</div>
</div>
);
}

// Tailwind v4 preflight resets headings/lists; the structural typography
// (headings, lists, markers, counters, checkboxes, callouts) lives in
// prose.css as one `.chat-prose` system. The component map below only covers
// behavioral overrides: link attrs, callout swap-in, inline code / fenced
// blocks, and the chat table.
const COMPONENTS = {
h1: ({ children }) => <h1 className="mt-1 mb-1 text-sm font-semibold text-foreground">{children}</h1>,
h2: ({ children }) => <h2 className="mt-1 mb-1 text-sm font-semibold text-foreground">{children}</h2>,
h3: ({ children }) => <h3 className="mt-1 mb-0.5 text-xs font-semibold uppercase tracking-wide text-foreground/80">{children}</h3>,
p: ({ children }) => <p className="my-1 leading-relaxed">{children}</p>,
ul: ({ children }) => <ul className="my-1 list-disc pl-4">{children}</ul>,
ol: ({ children }) => <ol className="my-1 list-decimal pl-4">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer" className="text-primary underline underline-offset-2">
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
),
blockquote: ({ node, children }) => {
// remark-rehype keeps hProperties keys verbatim, so the alert tag arrives
// as the literal `data-callout` property (not camel-cased).
const kind = node?.properties?.["data-callout"];
if (kind) return <Callout kind={String(kind)}>{children}</Callout>;
return <blockquote>{children}</blockquote>;
},
table: ({ children }) => (
<div
data-slot="chat-markdown-table"
className="scrollbar-thin my-2 overflow-x-auto rounded-md border border-border/50"
className="scrollbar-thin my-[0.85rem] overflow-x-auto rounded-lg border border-border/60 last:!mb-[0.85rem]"
>
<table className="w-full min-w-max border-collapse text-left text-[13px]">{children}</table>
<table className="w-full min-w-max border-collapse text-left text-[13px] [&_tbody_td:first-child]:font-semibold [&_tbody_td:first-child]:text-foreground">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border/60 bg-muted/40">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border/40">{children}</tbody>,
thead: ({ children }) => <thead className="border-b border-border/60">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr>{children}</tr>,
th: ({ children, align }) => (
<th
className={cn(
"px-2.5 py-1.5 font-semibold text-foreground",
"px-4 py-2.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground",
align === "center" && "text-center",
align === "right" && "text-right",
)}
Expand All @@ -51,7 +96,7 @@ const COMPONENTS = {
td: ({ children, align }) => (
<td
className={cn(
"px-2.5 py-1.5 align-top",
"px-4 py-3 align-top text-muted-foreground",
align === "center" && "text-center",
align === "right" && "text-right",
)}
Expand Down Expand Up @@ -91,8 +136,8 @@ const COMPONENTS = {
*/
export default function Markdown({ source, className }) {
return (
<div data-slot="chat-markdown" className={cn("text-sm text-foreground/90", className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={COMPONENTS}>
<div data-slot="chat-markdown" className={cn("chat-prose text-sm text-foreground/90", className)}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={COMPONENTS}>
{String(source || "")}
</ReactMarkdown>
</div>
Expand Down
Loading
Loading