diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index fb0fd25ac..8c533846d 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -12,23 +12,27 @@ import { TooltipTrigger, } from "@/components/ai-ui/tooltip"; import { cn } from "@/lib/utils"; -import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; -import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import { createContext, + lazy, memo, + Suspense, useCallback, useContext, useEffect, useMemo, useState, } from "react"; -import { Streamdown } from "streamdown"; +import type { Streamdown } from "streamdown"; + +const StreamdownRenderer = lazy(() => + import("./streamdown-renderer").then((module) => ({ + default: module.StreamdownRenderer, + })) +); export type MessageProps = HTMLAttributes & { from: UIMessage["role"]; @@ -321,18 +325,25 @@ export const MessageBranchPage = ({ export type MessageResponseProps = ComponentProps; -const streamdownPlugins = { cjk, code, math, mermaid }; - export const MessageResponse = memo( - ({ className, ...props }: MessageResponseProps) => ( - *:first-child]:mt-0 [&>*:last-child]:mb-0", - className - )} - plugins={streamdownPlugins} - {...props} - /> + ({ children, className, ...props }: MessageResponseProps) => ( + + {children} + + } + > + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + > + {children} + + ), (prevProps, nextProps) => prevProps.children === nextProps.children && diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index 6178686fd..341c0a307 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -7,15 +7,13 @@ import { CollapsibleTrigger, } from "@/components/ai-ui/collapsible"; import { cn } from "@/lib/utils"; -import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; -import { mermaid } from "@streamdown/mermaid"; import { BrainIcon, ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, + lazy, memo, + Suspense, useCallback, useContext, useEffect, @@ -23,10 +21,15 @@ import { useRef, useState, } from "react"; -import { Streamdown } from "streamdown"; import { Shimmer } from "./shimmer"; +const ReasoningStreamdown = lazy(() => + import("./streamdown-renderer").then((module) => ({ + default: module.StreamdownRenderer, + })) +); + interface ReasoningContextValue { isStreaming: boolean; isOpen: boolean; @@ -204,8 +207,6 @@ export type ReasoningContentProps = ComponentProps< children: string; }; -const streamdownPlugins = { cjk, code, math, mermaid }; - export const ReasoningContent = memo( ({ className, children, ...props }: ReasoningContentProps) => ( - {children} + + {children} + + } + > + {children} + ) ); diff --git a/src/components/ai-elements/streamdown-renderer.tsx b/src/components/ai-elements/streamdown-renderer.tsx new file mode 100644 index 000000000..8b7ff894e --- /dev/null +++ b/src/components/ai-elements/streamdown-renderer.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { cjk } from "@streamdown/cjk"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; +import type { ComponentProps } from "react"; +import { Streamdown } from "streamdown"; + +const streamdownPlugins = { cjk, code, math, mermaid }; + +export type StreamdownRendererProps = ComponentProps; + +export const StreamdownRenderer = (props: StreamdownRendererProps) => ( + +); diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx index 99c5d57e8..bc071e64b 100644 --- a/src/components/ai-elements/tool.tsx +++ b/src/components/ai-elements/tool.tsx @@ -157,18 +157,21 @@ export const ToolOutput = ({ errorText, ...props }: ToolOutputProps) => { - if (!(output || errorText)) { + const hasOutput = output !== undefined && output !== null; + if (!hasOutput && !errorText) { return null; } - let Output =
{output as ReactNode}
; + let Output = hasOutput ?
{output as ReactNode}
: null; - if (typeof output === "object" && !isValidElement(output)) { + if (hasOutput && typeof output === "object" && !isValidElement(output)) { Output = ( ); - } else if (typeof output === "string") { + } else if (hasOutput && typeof output === "string") { Output = ; + } else if (hasOutput) { + Output = ; } return ( diff --git a/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx b/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx index 56532210a..086791f94 100644 --- a/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx +++ b/src/features/agents/components/FastAgentPanel/FastAgentPanel.UIMessageBubble.tsx @@ -36,10 +36,32 @@ function useRehypeKatex(text: string) { import { LazySyntaxHighlighter } from './LazySyntaxHighlighter'; import { User, Bot, Wrench, Image as ImageIcon, AlertCircle, Loader2, RefreshCw, Trash2, ChevronDown, ChevronRight, CheckCircle2, XCircle, Clock, Copy, Check, BrainCircuit, Zap, ExternalLink, Globe, Calendar, Eye, ThumbsUp, ThumbsDown, Pencil, Bookmark, Volume2, VolumeX, Pin, Share2, Search } from 'lucide-react'; import { useVoiceOutput } from '@/hooks/useVoiceOutput'; +import { useReducedMotion } from '@/hooks/useReducedMotion'; import { useSmoothText, type UIMessage } from '@convex-dev/agent/react'; import { cn } from '@/lib/utils'; import { TokenUsageBadge } from './TokenUsageBadge'; -import type { FileUIPart, ToolUIPart } from 'ai'; +import { + Message as AIMessage, + MessageContent as AIMessageContent, +} from '@/components/ai-elements/message'; +import { + Reasoning as AIReasoning, + ReasoningContent as AIReasoningContent, + ReasoningTrigger as AIReasoningTrigger, +} from '@/components/ai-elements/reasoning'; +import { + Tool as AITool, + ToolContent as AIToolContent, + ToolHeader as AIToolHeader, + ToolInput as AIToolInput, + ToolOutput as AIToolOutput, +} from '@/components/ai-elements/tool'; +import { + Source as AISource, + Sources as AISources, + SourcesContent as AISourcesContent, + SourcesTrigger as AISourcesTrigger, +} from '@/components/ai-elements/sources'; // Type imports (static) import { type YouTubeVideo, type SECDocument } from './MediaGallery'; import { type FileViewerFile } from './FileViewer'; @@ -52,9 +74,19 @@ import { PeopleSelectionCard, type PersonOption } from './PeopleSelectionCard'; import { EventSelectionCard, type EventOption } from './EventSelectionCard'; import { NewsSelectionCard, type NewsArticleOption } from './NewsSelectionCard'; import { RichMediaSection } from './RichMediaSection'; -import { extractMediaFromText, removeMediaMarkersFromText } from './utils/mediaExtractor'; +import { + extractMediaFromText, + hasMedia, + removeMediaMarkersFromText, + type ExtractedMedia, +} from './utils/mediaExtractor'; import { GoalCard, type TaskStatusItem } from './FastAgentPanel.GoalCard'; -import { DocumentActionGrid, extractDocumentActions, removeDocumentActionMarkers } from './DocumentActionCard'; +import { + DocumentActionGrid, + extractDocumentActions, + removeDocumentActionMarkers, + type DocumentAction, +} from './DocumentActionCard'; import { ArbitrageCitation, StatusBadge, @@ -86,6 +118,12 @@ import type { EntityType } from '@/features/research/types/entitySchema'; import { makeWebSourceCitationId } from '../../../../../shared/citations/webSourceCitations'; import { formatBriefDateTime } from '@/lib/briefDate'; import { useMessageHandlers } from './MessageHandlersContext'; +import { + convexToUIParts, + type ConvexUIRenderPart, + type DomainCategory, + type NormalizedToolPart, +} from './adapters/convexToUIParts'; interface FastAgentUIMessageBubbleProps { message: UIMessage; @@ -722,6 +760,7 @@ const ThinkingAccordion = React.memo(function ThinkingAccordion({ const [userToggled, setUserToggled] = useState(false); const [userWantsOpen, setUserWantsOpen] = useState(false); const prevStreamingRef = useRef(isStreaming); + const reducedMotion = useReducedMotion(); // Auto-expand during streaming, auto-collapse when done (Claude pattern) const isExpanded = userToggled ? userWantsOpen : isStreaming; @@ -735,45 +774,42 @@ const ThinkingAccordion = React.memo(function ThinkingAccordion({ prevStreamingRef.current = isStreaming; }, [isStreaming]); - const handleToggle = () => { - setUserToggled(true); - setUserWantsOpen(!isExpanded); - }; - if (!reasoning) return null; const wordCount = reasoning.split(/\s+/).filter(Boolean).length; return ( -
- - - {isExpanded && ( -
-
- {reasoning} -
-
- )} -
+ + + {reasoning} + + ); }); @@ -1359,6 +1395,174 @@ function parseSearchResponsePayload( /** * ToolStep - Renders a single tool call as a structured step with timeline */ +type ToolRenderPart = Extract; +type TextRenderPart = Extract; +type ReasoningRenderPart = Extract; +type DomainRenderPart = Extract; +type ArbitrageReportData = React.ComponentProps['data']; +type ToolOwnerRoute = + | 'goal-card' + | 'fused-search' + | 'memory-pill' + | 'convex-transparency' + | 'grouped-custom' + | 'tool-step'; + +interface RoutedToolOwner { + entry: ToolRenderPart; + route: ToolOwnerRoute; + fusedSearch?: ReturnType; +} + +function isToolRenderPart(part: ConvexUIRenderPart): part is ToolRenderPart { + return part.kind === 'tool' || part.kind === 'domain-tool'; +} + +function getDomainPayload(part: DomainRenderPart['part']): unknown { + const record = part as Record; + return record.output ?? record.result ?? record.data ?? record.value ?? record; +} + +function getStandaloneStructuredOutput(entry: DomainRenderPart): unknown { + const payload = getDomainPayload(entry.part); + if (tryParseStructuredOutput(payload)) return payload; + + if (!payload || typeof payload !== 'object') { + return payload; + } + + const data = payload as Record; + const normalizedType = entry.part.type.toLowerCase().replace(/[-_]/g, ''); + const kind = entry.categories.includes('selection') + ? Array.isArray(data.companies) || normalizedType.includes('companyselection') + ? 'company_selection' + : Array.isArray(data.people) || normalizedType.includes('peopleselection') + ? 'people_selection' + : Array.isArray(data.events) || normalizedType.includes('eventselection') + ? 'event_selection' + : Array.isArray(data.articles) || normalizedType.includes('newsselection') + ? 'news_selection' + : null + : entry.categories.includes('media') + ? Array.isArray(data.videos) + ? 'youtube_search_results' + : Array.isArray(data.documents) + ? 'sec_filing_results' + : null + : null; + + if (!kind) return payload; + return { + kind, + version: 1, + summary: typeof data.summary === 'string' ? data.summary : '', + data, + }; +} + +function distributeVisibleParts< + T extends TextRenderPart | ReasoningRenderPart, +>( + visible: string | undefined, + entries: T[], + separator = '', +): Map { + const distributed = new Map(); + let cursor = 0; + const materialized = visible ?? ''; + + entries.forEach((entry, index) => { + const rawLength = entry.part.text.length; + const isLast = index === entries.length - 1; + const end = isLast ? materialized.length : Math.min(cursor + rawLength, materialized.length); + distributed.set(entry.originalIndex, materialized.slice(cursor, end)); + cursor = isLast + ? end + : Math.min(end + separator.length, materialized.length); + }); + + return distributed; +} + +function getNormalizedToolName(part: NormalizedToolPart): string { + return part.type === 'dynamic-tool' + ? part.toolName + : part.type.replace(/^tool-/, ''); +} + +function isMemoryPlanningToolName(toolName: string): boolean { + return ['createPlan', 'updatePlanStep', 'writeMemory', 'logEpisodic'].some((name) => + toolName.includes(name) + ); +} + +function getAvailableToolOutput(part: NormalizedToolPart): unknown { + return part.state === 'output-available' ? part.output : undefined; +} + +function getToolOutputText(part: NormalizedToolPart): string { + const output = getAvailableToolOutput(part); + if (typeof output === 'string') return output; + return output === undefined ? '' : JSON.stringify(output); +} + +function getToolMedia(part: NormalizedToolPart): ExtractedMedia { + return extractMediaFromText(getToolOutputText(part)); +} + +function getToolDocuments(part: NormalizedToolPart): DocumentAction[] { + return extractDocumentActions(getToolOutputText(part)); +} + +function getArbitrageReportData(part: NormalizedToolPart): ArbitrageReportData | null { + const output = getAvailableToolOutput(part); + if (output === undefined) return null; + + try { + const parsed = typeof output === 'string' ? JSON.parse(output) : output; + if (!parsed || typeof parsed !== 'object') return null; + const record = parsed as Record; + if ( + !('contradictions' in record) && + !('rankedSources' in record) && + !('deltas' in record) && + !('healthResults' in record) + ) { + return null; + } + return record as ArbitrageReportData; + } catch { + return null; + } +} + +function hasGroupedDomainContent(entry: ToolRenderPart): boolean { + if (entry.kind !== 'domain-tool') return false; + + return ( + (entry.categories.includes('arbitrage') && !!getArbitrageReportData(entry.part)) || + (entry.categories.includes('documentAction') && getToolDocuments(entry.part).length > 0) || + (entry.categories.includes('media') && hasMedia(getToolMedia(entry.part))) + ); +} + +function usesNodeBenchDomainRenderer(output: unknown): boolean { + const structured = tryParseStructuredOutput(output); + if (structured) { + return [ + 'youtube_search_results', + 'sec_filing_results', + 'company_selection', + 'people_selection', + 'event_selection', + 'news_selection', + ].includes(structured.kind); + } + + return typeof output === 'string' && + /`, + }, + ])} + />, + ); + + expect( + container.querySelector('[data-grouped-domain-owners="1"]'), + ).toBeInTheDocument(); + expect(container.querySelector("[data-step-number]")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Used 1 tool" }), + ).not.toBeInTheDocument(); + expect(screen.getAllByText("Grouped domain video sentinel")).toHaveLength( + 1, + ); + }); + + it("renders adapter-routed URL sources once through the Sources primitive", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Used 1 sources" })); + const links = screen.getAllByRole("link", { + name: "Evidence source sentinel", + }); + expect(links).toHaveLength(1); + expect(links[0]).toHaveAttribute("href", "https://example.com/evidence"); + }); + + it("renders canonical standard owners in their original interleaved order", () => { + const { container } = render( + , + ); + + expect( + Array.from( + container.querySelectorAll("[data-render-part-kind]"), + ).map((node) => [ + node.dataset.renderPartKind, + node.dataset.originalIndex, + ]), + ).toEqual([ + ["text", "0"], + ["tool", "1"], + ["source", "2"], + ["file", "3"], + ["reasoning", "4"], + ["text", "5"], + ]); + }); + + it("keeps three reasoning owners aligned across interleaved render parts", () => { + const { container } = render( + , + ); + + expect( + Array.from( + container.querySelectorAll("[data-render-part-kind]"), + ).map((node) => [ + node.dataset.renderPartKind, + node.dataset.originalIndex, + ]), + ).toEqual([ + ["reasoning", "0"], + ["text", "1"], + ["reasoning", "2"], + ["source", "3"], + ["reasoning", "4"], + ]); + + const reasoningOwners = Array.from( + container.querySelectorAll( + '[data-render-part-kind="reasoning"]', + ), + ); + expect(reasoningOwners).toHaveLength(3); + expect(reasoningOwners[0]).toHaveTextContent("alpha"); + expect(reasoningOwners[0]).not.toHaveTextContent("beta"); + expect(reasoningOwners[1]).toHaveTextContent("beta"); + expect(reasoningOwners[1]).not.toHaveTextContent("gamma"); + expect(reasoningOwners[2]).toHaveTextContent("gamma"); + }); + + it("passes standalone and id-less legacy domain owners through custom renderers", () => { + const onCompanySelect = vi.fn(); + const { container } = render( + , + ); + + expect( + Array.from( + container.querySelectorAll("[data-render-part-kind]"), + ).map((node) => node.dataset.renderPartKind), + ).toEqual(["text", "domain", "domain", "text"]); + expect(screen.getByText("Choose the standalone company")).toBeInTheDocument(); + expect(screen.getByText("Standalone NodeBench")).toBeInTheDocument(); + expect(screen.getByText(/Id-less legacy receipt sentinel/)).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Select This Company" }), + ); + expect(onCompanySelect).toHaveBeenCalledTimes(1); + expect(onCompanySelect).toHaveBeenCalledWith( + expect.objectContaining({ cik: "0000777777", ticker: "STND" }), + ); + }); + + it("anchors grouped domain-tool owners once at their earliest original index", () => { + const { container } = render( + `, + }, + { type: "text", text: "Between grouped owners" }, + { + type: "tool-createDocument", + toolCallId: "group-document-1", + state: "output-available", + input: { title: "Anchor document" }, + output: ``, + }, + { type: "text", text: "After grouped owners" }, + { + type: "tool-contradictionDetection", + toolCallId: "group-arbitrage-1", + state: "output-available", + input: {}, + output: { + healthResults: [], + summary: "Anchored arbitrage sentinel", + }, + }, + ], + { text: undefined }, + )} + />, + ); + + const grouped = container.querySelector( + "[data-grouped-domain-anchor]", + ); + expect(grouped).toHaveAttribute("data-grouped-domain-anchor", "0"); + expect(grouped).toHaveAttribute("data-grouped-domain-owners", "3"); + expect(container.querySelectorAll("[data-grouped-domain-anchor]")).toHaveLength(1); + expect(container.querySelector("[data-step-number]")).not.toBeInTheDocument(); + expect(screen.getAllByText("Anchored video sentinel")).toHaveLength(1); + expect(screen.getAllByText("Anchored document sentinel")).toHaveLength(1); + expect(screen.getAllByText("Anchored arbitrage sentinel")).toHaveLength(1); + + expect( + Array.from( + container.querySelectorAll("[data-render-part-kind]"), + ).map((node) => [ + node.dataset.renderPartKind, + node.dataset.originalIndex, + ]), + ).toEqual([ + ["grouped-domain", "0"], + ["text", "1"], + ["text", "3"], + ]); + }); + + it("keeps smooth text status-gated for non-streaming messages", () => { + render( + , + ); + + expect(smoothTextMock).toHaveBeenCalledWith("Settled answer", { + startStreaming: false, + }); + expect(smoothTextMock).toHaveBeenCalledWith("Settled reasoning", { + startStreaming: false, + }); + }); +});