diff --git a/web/src/components/ChatBlockList.tsx b/web/src/components/ChatBlockList.tsx index 35838f4e..7fee5f91 100644 --- a/web/src/components/ChatBlockList.tsx +++ b/web/src/components/ChatBlockList.tsx @@ -3,6 +3,7 @@ import type { MessageStatus } from '@/types/api' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { LazyRainbowText } from '@/components/LazyRainbowText' import { ToolCard } from '@/components/ToolCard/ToolCard' function ErrorIcon() { @@ -99,7 +100,7 @@ export function ChatBlockList(props: {
- +
{status ? (
diff --git a/web/src/components/LazyRainbowText.tsx b/web/src/components/LazyRainbowText.tsx new file mode 100644 index 00000000..d5f557f3 --- /dev/null +++ b/web/src/components/LazyRainbowText.tsx @@ -0,0 +1,104 @@ +import React, { useRef, useState, useEffect, useMemo } from 'react' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' + +const ULTRATHINK_PATTERN = /\b(ultrathink)\b/gi + +// Each letter gets a different delay for wave effect +function RainbowWord({ word, baseKey }: { word: string; baseKey: number }) { + const totalLetters = word.length + const cycleDuration = 2 // seconds for sparkle to travel across all letters + + return ( + + {word.split('').map((letter, i) => { + // Each letter has a different delay to create wave effect + const colorDelay = (i / totalLetters) * 2 // stagger rainbow colors + const sparkleDelay = (i / totalLetters) * cycleDuration // sparkle wave + + return ( + + {letter} + + ) + })} + + ) +} + +// Process text string to wrap "ultrathink" with RainbowWord +function processTextForRainbow(text: string): React.ReactNode { + ULTRATHINK_PATTERN.lastIndex = 0 + const parts: React.ReactNode[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = ULTRATHINK_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)) + } + parts.push() + lastIndex = match.index + match[0].length + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)) + } + + return <>{parts} +} + +// Process React children to apply rainbow to text nodes +function processChildrenForRainbow(children: React.ReactNode): React.ReactNode { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + return processTextForRainbow(child) + } + return child + }) +} + +export function LazyRainbowText({ text }: { text: string }) { + const ref = useRef(null) + const [hasBeenVisible, setHasBeenVisible] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setHasBeenVisible(true) + } + }, + { rootMargin: '100px' } + ) + + observer.observe(el) + return () => observer.disconnect() + }, []) + + // Quick check: if no ultrathink, just render markdown + const hasUltrathink = text.toLowerCase().includes('ultrathink') + + const rainbowComponents = useMemo(() => ({ + p: ({ children }: { children?: React.ReactNode }) => ( +

{processChildrenForRainbow(children)}

+ ), + }), []) + + return ( +
+ +
+ ) +} diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index 24fe24c6..99f9a310 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -1,4 +1,4 @@ -import ReactMarkdown from 'react-markdown' +import ReactMarkdown, { Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import { CodeBlock } from '@/components/CodeBlock' @@ -6,36 +6,42 @@ function getLanguageFromClassName(className?: string): string | null { if (!className) return null for (const token of className.split(/\s+/g)) { if (token.startsWith('language-')) { - const language = token.slice('language-'.length) - return language || null + return token.slice('language-'.length) || null } } return null } -export function MarkdownRenderer({ content }: { content: string }) { +const defaultComponents: Components = { + code({ className, children, ...props }) { + const language = getLanguageFromClassName(className) || 'text' + const code = String(children).replace(/\n$/, '') + + if (language !== 'text' || code.includes('\n')) { + return ( + + ) + } + + return {children} + }, +} + +interface MarkdownRendererProps { + content: string + components?: Components +} + +export function MarkdownRenderer({ content, components }: MarkdownRendererProps) { return (
- ) - } - - return {children} - } - }} + components={{ ...defaultComponents, ...components }} > {content} diff --git a/web/src/index.css b/web/src/index.css index ea4ca3b4..9dc0aa19 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -85,3 +85,30 @@ body { .markdown-content ul, .markdown-content ol { padding-left: 1.5rem; margin: 0.5rem 0; } .markdown-content table { border-collapse: collapse; width: 100%; } .markdown-content th, .markdown-content td { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; } + +/* Ultrathink rainbow sparkle effect - per-letter wave animation */ +@keyframes rainbow-wave { + 0% { color: #ff5555; } /* red */ + 14% { color: #ffaa00; } /* orange */ + 28% { color: #ffff55; } /* yellow */ + 42% { color: #55ff55; } /* green */ + 57% { color: #55ffff; } /* cyan */ + 71% { color: #5555ff; } /* blue */ + 85% { color: #ff55ff; } /* magenta/pink */ + 100% { color: #ff5555; } /* red */ +} + +@keyframes sparkle-wave { + 0%, 100% { + filter: brightness(1); + } + 50% { + filter: brightness(0.7); + } +} + +.rainbow-letter { + display: inline-block; + animation: rainbow-wave 2s linear infinite, sparkle-wave 2s ease-in-out infinite; + font-weight: 600; +}