From 926e74509d16a85d6ef8f149da5cc894dce096d0 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Wed, 25 Mar 2026 10:06:03 +0100 Subject: [PATCH] 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 --- .../components/assistant-ui/markdown-text.tsx | 28 +++++- .../src/components/assistant-ui/sources.tsx | 3 +- .../src/components/assistant-ui/thread.tsx | 14 ++- .../components/assistant-ui/tool-fallback.tsx | 14 +-- .../components/assistant-ui/tool-group.tsx | 18 ++-- .../assistant-ui/tool-ui-python.tsx | 15 ++- .../assistant-ui/tool-ui-terminal.tsx | 13 ++- .../assistant-ui/tool-ui-web-search.tsx | 20 ++-- studio/frontend/src/lib/latex.ts | 97 +++++++++++++++++++ 9 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 studio/frontend/src/lib/latex.ts diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 0bbbe94fdc..5e84b9175e 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -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">) => ( + + {children} + + ), +}; 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 = //; 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} ); diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx index 18ee03cad0..da97ff66b5 100644 --- a/studio/frontend/src/components/assistant-ui/sources.tsx +++ b/studio/frontend/src/components/assistant-ui/sources.tsx @@ -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 = { 3: "size-3", 4: "size-4", 5: "size-5" }; + const sizeClass = SIZE_CLASSES[size] ?? "size-3"; if (hasError) { return ( diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 91cba5b02b..e5528f7ee0 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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 }> = ({ - !thread.isEmpty}> {!hideComposer && } @@ -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 ( + Generating... + ); +}; + const AssistantMessage: FC = () => { return ( { data-role="assistant" >
+ -
{children}
+
{children}
); } @@ -226,7 +226,7 @@ function ToolFallbackArgs({ return (
@@ -251,7 +251,7 @@ function ToolFallbackResult({
     

@@ -316,7 +316,7 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({ return ( diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index f29adb510f..bf7a6a9a25 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -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({ {label} @@ -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} diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index 28468a10ad..a510ed0d9e 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -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 | 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} /> -

+
{/* Code + copy */} {code && (
)} - + {code && } {/* Output */} {isRunning ? ( diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx index 1b65b3c081..f233f951d3 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx @@ -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 | 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} /> -
+
{isRunning ? (
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx index 0635a83ec4..d3a86846de 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx @@ -81,29 +81,27 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ /> {isRunning ? ( -
+
Searching for “{query}”…
) : sources.length > 0 ? ( -
- {sources.map((source) => ( +
+ {sources.map((source, i) => ( - - - {source.title} - + + {source.title} ))}
) : result ? ( -
+
               {typeof result === "string"
                 ? result
diff --git a/studio/frontend/src/lib/latex.ts b/studio/frontend/src/lib/latex.ts
new file mode 100644
index 0000000000..954d0f7bc6
--- /dev/null
+++ b/studio/frontend/src/lib/latex.ts
@@ -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 =
+  /(? {
+  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;
+  });
+}