feat(chat): cleaner tool UI, inline LaTeX, clickable links (#4561)

* feat(chat): ghost-style tool containers

Remove borders and card styling from tool call UI. ToolFallback
uses minimal padding with indented content. ToolGroup defaults
to ghost variant with subtle background for multi-tool grouping.

* feat(chat): compact web search source pills

Switch sources from vertical full-width badges to horizontal
wrapping pills with smaller icons.

* feat(chat): left-accent code and terminal tool UI

Replace bordered card layout with a left border accent for
Python and Terminal tool output. Add timer cleanup on unmount
for the copy button in both components.

* feat(chat): inline latex and clickable links

Enable single-dollar $...$ math rendering via createMathPlugin.
Add styled link component with target=_blank for external links.

* fix(chat): inline generating indicator, static tailwind classes, misc fixes

Move generating indicator from viewport footer into assistant
message using AnimatedShinyText shimmer. Only shows when message
content is empty, hides once tool calls or text appear.

Use static size class map in SourceIcon for Tailwind v4 compat.
Use unique keys for web search sources. Remove px-3 from ghost
tool group variant.

* fix(chat): only show generating indicator while message is running

Hide the shimmer when message is cancelled or errored with no
content, preventing stale loading UI on empty completed messages.

* fix: escape currency dollar signs in LaTeX math rendering and fix TS build error

- Add preprocessLaTeX() in lib/latex.ts to escape currency patterns ($5, $1,000, $5.99, $100K)
  before they reach the math parser, preventing false positives when singleDollarTextMath is enabled.
  Code blocks and already-escaped dollars are left untouched.
- Use preprocessLaTeX via useMemo in markdown-text.tsx so Streamdown receives clean input.
- Fix TS18048 in thread.tsx: message.status?.type (optional chaining) since status can be undefined.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Wasim Yousef Said 2026-03-25 10:06:03 +01:00 committed by GitHub
commit 926e74509d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 185 additions and 37 deletions

View file

@ -4,19 +4,39 @@
"use client";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
const math = createMathPlugin({ singleDollarTextMath: true });
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
}: React.ComponentProps<"a">) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary transition-colors"
{...props}
>
{children}
</a>
),
};
const COPY_RESET_MS = 2000;
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
@ -375,6 +395,7 @@ const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
const MarkdownTextImpl = () => {
const { text, status } = useMessagePartText();
const processedText = useMemo(() => preprocessLaTeX(text), [text]);
const audioMatch = text.match(AUDIO_PLAYER_RE);
if (audioMatch) {
@ -387,6 +408,7 @@ const MarkdownTextImpl = () => {
mode="streaming"
isAnimating={status.type === "running"}
plugins={{ code, math, mermaid }}
components={STREAMDOWN_COMPONENTS}
controls={{
code: false,
mermaid: {
@ -399,7 +421,7 @@ const MarkdownTextImpl = () => {
shikiTheme={["github-light", "github-dark"]}
BlockComponent={StreamdownBlock}
>
{text}
{processedText}
</Streamdown>
</div>
);

View file

@ -43,7 +43,8 @@ function SourceIcon({
}: ComponentProps<"span"> & { url: string; size?: number }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
const sizeClass = `size-${size}`;
const SIZE_CLASSES: Record<number, string> = { 3: "size-3", 4: "size-4", 5: "size-5" };
const sizeClass = SIZE_CLASSES[size] ?? "size-3";
if (hasError) {
return (

View file

@ -16,6 +16,7 @@ import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
@ -90,7 +91,6 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
<ThreadScrollToBottom />
<GeneratingSpinner />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
{!hideComposer && <ComposerAnimated />}
</AuiIf>
@ -541,6 +541,17 @@ const MessageError: FC = () => {
);
};
const GeneratingIndicator: FC = () => {
const show = useAuiState(
({ message }) =>
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
return (
<AnimatedShinyText className="text-sm">Generating...</AnimatedShinyText>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
@ -548,6 +559,7 @@ const AssistantMessage: FC = () => {
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<GeneratingIndicator />
<MessagePrimitive.Parts
components={{
Text: MarkdownText,

View file

@ -77,7 +77,7 @@ function ToolFallbackRoot({
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full corner-squircle rounded-lg border py-3",
"aui-tool-fallback-root group/tool-fallback-root w-full py-1",
className,
)}
style={
@ -124,7 +124,7 @@ function ToolFallbackTrigger({
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 py-1.5 text-sm transition-colors",
className,
)}
{...props}
@ -207,7 +207,7 @@ function ToolFallbackContent({
)}
{...props}
>
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
<div className="mt-1 flex flex-col gap-2 pl-5">{children}</div>
</CollapsibleContent>
);
}
@ -226,7 +226,7 @@ function ToolFallbackArgs({
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args px-4", className)}
className={cn("aui-tool-fallback-args", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
@ -251,7 +251,7 @@ function ToolFallbackResult({
<div
data-slot="tool-fallback-result"
className={cn(
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
"aui-tool-fallback-result pt-2",
className,
)}
{...props}
@ -292,7 +292,7 @@ function ToolFallbackError({
return (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error px-4", className)}
className={cn("aui-tool-fallback-error", className)}
{...props}
>
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
@ -316,7 +316,7 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({
return (
<ToolFallbackRoot
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
className={cn(isCancelled && "bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>

View file

@ -26,11 +26,11 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variants: {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "",
ghost: "rounded-lg bg-muted/10 py-2",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
},
defaultVariants: { variant: "outline" },
defaultVariants: { variant: "ghost" },
});
export type ToolGroupRootProps = Omit<
@ -76,7 +76,7 @@ function ToolGroupRoot({
<Collapsible
ref={collapsibleRef}
data-slot="tool-group-root"
data-variant={variant ?? "outline"}
data-variant={variant ?? "ghost"}
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
@ -111,9 +111,10 @@ function ToolGroupTrigger({
<CollapsibleTrigger
data-slot="tool-group-trigger"
className={cn(
"aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4",
"aui-tool-group-trigger group/trigger flex w-full items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:px-4",
"group-data-[variant=ghost]/tool-group-root:px-0",
className,
)}
{...props}
@ -134,9 +135,7 @@ function ToolGroupTrigger({
<span
data-slot="tool-group-trigger-label"
className={cn(
"aui-tool-group-trigger-label-wrapper relative inline-block text-left font-medium leading-none",
"group-data-[variant=outline]/tool-group-root:grow",
"group-data-[variant=muted]/tool-group-root:grow",
"aui-tool-group-trigger-label-wrapper relative inline-block grow text-left font-medium leading-none",
)}
>
<span>{label}</span>
@ -189,6 +188,7 @@ function ToolGroupContent({
"mt-2 flex flex-col gap-2",
"group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3",
"group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3",
"group-data-[variant=ghost]/tool-group-root:mt-1 group-data-[variant=ghost]/tool-group-root:gap-1",
)}
>
{children}

View file

@ -7,7 +7,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CheckIcon, CodeIcon, CopyIcon, LoaderIcon } from "lucide-react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import {
ToolFallbackContent,
@ -28,6 +28,15 @@ function truncate(text: string): string {
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, []);
const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
@ -98,14 +107,14 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
icon={CodeIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
<div className="border-l-2 border-muted-foreground/20 pl-2">
{/* Code + copy */}
{code && (
<div className="flex justify-end">
<CopyBtn text={code} />
</div>
)}
<HighlightedCode code={code} language="python" />
{code && <HighlightedCode code={code} language="python" />}
{/* Output */}
{isRunning ? (

View file

@ -6,7 +6,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, LoaderIcon, TerminalIcon } from "lucide-react";
import { memo, useCallback, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
@ -25,6 +25,15 @@ function truncate(text: string): string {
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, []);
const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
@ -74,7 +83,7 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
icon={TerminalIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
<div className="border-l-2 border-muted-foreground/20 pl-2">
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />

View file

@ -81,29 +81,27 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 px-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching for &ldquo;{query}&rdquo;&hellip;</span>
</div>
) : sources.length > 0 ? (
<div className="flex flex-col gap-1.5 px-4">
{sources.map((source) => (
<div className="flex flex-wrap gap-1.5">
{sources.map((source, i) => (
<Source
key={source.url}
key={`${source.url}-${i}`}
href={source.url}
variant="outline"
size="default"
className="flex w-full max-w-full items-center gap-2 py-1.5"
size="sm"
className="inline-flex items-center gap-1.5"
>
<SourceIcon url={source.url} className="size-3.5" />
<SourceTitle className="max-w-none flex-1 truncate">
{source.title}
</SourceTitle>
<SourceIcon url={source.url} size={3} />
<SourceTitle>{source.title}</SourceTitle>
</Source>
))}
</div>
) : result ? (
<div className="px-4">
<div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result

View file

@ -0,0 +1,97 @@
// Adapted from LibreChat's latex.ts
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
//
// Escapes currency dollar signs so they are not misinterpreted as LaTeX math
// delimiters when singleDollarTextMath is enabled.
/**
* Matches a single $ followed by a number pattern (currency), e.g.:
* $5, $1,000, $5.99, $100K, $3.5M
*
* Does NOT match:
* $$ (display math), \$ (already escaped), $\alpha (LaTeX command)
*/
const CURRENCY_REGEX =
/(?<![\\$])\$(?!\$)(?=\d+(?:,\d{3})*(?:\.\d+)?[KMBkmb]?(?:\s|$|[^a-zA-Z\d]))/g;
/**
* Find regions inside code blocks (``` ... ``` and ` ... `) so we can skip them.
* Returns sorted array of [start, end] index pairs.
*/
function findCodeBlockRegions(content: string): Array<[number, number]> {
const regions: Array<[number, number]> = [];
// Fenced code blocks: ```...```
const fencedRe = /```[\s\S]*?```/g;
let match: RegExpExecArray | null;
while ((match = fencedRe.exec(content)) !== null) {
regions.push([match.index, match.index + match[0].length]);
}
// Inline code: `...` (but not inside fenced blocks -- we filter below)
const inlineRe = /`[^`\n]+`/g;
while ((match = inlineRe.exec(content)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Skip if this backtick span falls inside a fenced block
let inside = false;
for (const [rs, re] of regions) {
if (start >= rs && end <= re) {
inside = true;
break;
}
}
if (!inside) {
regions.push([start, end]);
}
}
// Sort by start position for binary search
regions.sort((a, b) => a[0] - b[0]);
return regions;
}
/**
* Binary search to check if a position falls inside any code region.
*/
function isInCodeBlock(
position: number,
regions: Array<[number, number]>,
): boolean {
let lo = 0;
let hi = regions.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const [start, end] = regions[mid];
if (position < start) {
hi = mid - 1;
} else if (position >= end) {
lo = mid + 1;
} else {
return true;
}
}
return false;
}
/**
* Preprocess a markdown string to escape currency dollar signs so they are not
* parsed as LaTeX math delimiters.
*
* - `$5` alone becomes `\$5` (currency, not math)
* - `$\alpha$` is untouched (real LaTeX)
* - `$$E = mc^2$$` is untouched (display math)
* - Currency inside code blocks/spans is untouched
*/
export function preprocessLaTeX(content: string): string {
if (!content.includes("$")) return content;
const codeRegions = findCodeBlockRegions(content);
return content.replace(CURRENCY_REGEX, (match, offset) => {
if (isInCodeBlock(offset, codeRegions)) {
return match;
}
return "\\" + match;
});
}