import { Check, Clock, Copy, FileText, PencilSimple, SpinnerGap, Star, User, Wrench, XCircle, } from "@phosphor-icons/react"; import { isValidElement, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import digitwhaleMarkSrc from "../../assets/dw.png"; import { cn } from "../../lib/cn"; import { Button } from "../button"; import { Modal } from "../overlay"; import { useOverlay } from "../overlay/use-overlay"; import { Markdown } from "../typography/markdown"; import type { AIInputMention } from "./ai-input"; import { UploadPreviewChip, type UploadPreviewItem } from "./shared"; import { TextArea } from "./text-area"; export type AIMessageRole = "assistant" | "system" | "tool" | "user"; export type AIMessageStatus = | "awaiting-confirmation" | "complete" | "error" | "pending" | "queued" | "rejected" | "running" | "streaming"; export type AIMessageAction = { disabled?: boolean; icon?: ReactNode; id: string; iconOnly?: boolean; label: string; onClick?: () => void; }; export type AIToolAction = { disabled?: boolean; href?: string; id: string; label: string; tone?: "destructive" | "primary" | "secondary"; }; export type AIToolPresentation = | { kind: "default" } | { description?: string; kind: "status"; label: string; tone?: "danger" | "info" | "neutral" | "success" | "warning"; } | { fields: Array<{ label: string; value: unknown }>; kind: "key-value"; title?: string; } | { items: Array<{ badge?: string; description?: string; id?: string; label: string; value?: unknown; }>; kind: "list"; title?: string; } | { columns: Array<{ key: string; label: string }>; kind: "table"; rows: Array>; title?: string; } | { description?: string; href?: string; kind: "resource"; metadata?: Array<{ label: string; value: unknown }>; resourceId: string; resourceType: string; title: string; } | { description?: string; kind: "progress"; label: string; max?: number; value?: number; } | { after: unknown; before: unknown; kind: "diff"; title?: string } | { data: unknown; kind: "custom"; rendererKey: string }; export type AIToolCall = { actions?: AIToolAction[]; args?: ReactNode | string; defaultExpanded?: boolean; error?: ReactNode | string; id: string; input?: unknown; name: string; output?: ReactNode | string | unknown; presentation?: AIToolPresentation; progress?: { label?: string; max?: number; value?: number }; status?: AIMessageStatus; summary?: ReactNode; }; export type AIMessagePart = | { id?: string; text: ReactNode; type: "text" } | { id?: string; toolCall: AIToolCall; type: "tool-call" }; export type AIMessageThought = { id?: string; text: ReactNode; }; export type AIToolCallRenderContext = { defaultExpanded: boolean; message: AIMessage; onAction?: (action: AIToolAction, toolCall: AIToolCall, message: AIMessage) => void; }; export type AIToolCallRenderer = ( toolCall: AIToolCall, context: AIToolCallRenderContext, ) => ReactNode; export type AIMessage = { actions?: AIMessageAction[]; attachments?: UploadPreviewItem[]; avatar?: ReactNode; avatarClassName?: string; content?: ReactNode; createdAt?: Date | number | string; defaultToolCallsExpanded?: boolean; feedbackDisabled?: boolean; id: string; metadata?: ReactNode; parts?: AIMessagePart[]; role: AIMessageRole; showAvatar?: boolean; status?: AIMessageStatus; thoughts?: AIMessageThought[]; title?: ReactNode; toolCalls?: AIToolCall[]; }; export type AIMessageContentRenderContext = { message: AIMessage; streaming: boolean; }; export type AIMessageEditContext = { onCancel: () => void; onChange: (value: string) => void; onSubmit: () => void; value: string; }; export type AIMessageListProps = { className?: string; defaultToolCallsExpanded?: boolean; groupToolCalls?: boolean; editOnDoubleTap?: boolean; emptyState?: ReactNode; feedbackDialogDescription?: ReactNode; feedbackDialogTitle?: ReactNode; onFeedbackSubmit?: ( message: AIMessage, feedback: { rating: number; review: string }, ) => Promise | void; messages: AIMessage[]; onCopy?: (message: AIMessage) => void; onEdit?: (message: AIMessage) => void; onEditSubmit?: (message: AIMessage, content: string) => void; onToolAction?: ( action: AIToolAction, toolCall: AIToolCall, message: AIMessage, ) => void; mentionHighlightClassName?: string; mentions?: AIInputMention[]; renderMention?: (mention: AIInputMention, token: string) => ReactNode; renderAction?: (action: AIMessageAction, message: AIMessage) => ReactNode; renderAvatar?: (message: AIMessage) => ReactNode; renderContent?: ( content: ReactNode, context: AIMessageContentRenderContext, ) => ReactNode; renderEdit?: (message: AIMessage, context: AIMessageEditContext) => ReactNode; renderMessage?: (message: AIMessage, content: ReactNode) => ReactNode; renderToolCall?: (toolCall: AIToolCall, message: AIMessage) => ReactNode; showCopyAction?: boolean; showEditAction?: boolean; showFeedbackAction?: boolean; showAvatars?: boolean; showMessageActions?: boolean; showTimestamps?: boolean; showToolCalls?: boolean; toolRenderers?: Record; variant?: "default" | "bubbles"; }; function formatTime(value?: Date | number | string) { if (!value) return ""; const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function renderMentionText( value: string, mentions: AIInputMention[], className: string, renderMention?: (mention: AIInputMention, token: string) => ReactNode, ) { const parts = value.split(/([@#/][\w-]+)/g); return parts.map((part, index) => { const match = part.match(/^([@#/])([\w-]+)$/); const mention = match ? mentions.find( (item) => (item.trigger ?? match[1]) === match[1] && (item.value ?? item.label).toLowerCase() === match[2].toLowerCase(), ) : undefined; if (!mention || !match) return part; return ( renderMention?.(mention, part) ?? ( {part} ) ); }); } function renderInlineMarkdown( value: string, mentions: AIInputMention[], mentionClassName: string, renderMention?: (mention: AIInputMention, token: string) => ReactNode, ) { const parts = value.split(/(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^\)]+\))/g); return parts.map((part, index) => { if (part.startsWith("`") && part.endsWith("`")) { return ( {renderMentionText( part.slice(1, -1), mentions, mentionClassName, renderMention, )} ); } if (part.startsWith("**") && part.endsWith("**")) { return ( {renderMentionText( part.slice(2, -2), mentions, mentionClassName, renderMention, )} ); } const link = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/); if (link) { return ( {link[1]} ); } return renderMentionText(part, mentions, mentionClassName, renderMention); }); } function DefaultMarkdown({ className, value, mentions, mentionHighlightClassName, renderMention, }: { className?: string; value: string; mentions: AIInputMention[]; mentionHighlightClassName: string; renderMention?: (mention: AIInputMention, token: string) => ReactNode; }) { return ( ); } function StatusIcon({ status }: { status?: AIMessageStatus }) { if ( status === "streaming" || status === "pending" || status === "queued" || status === "running" ) return ; if (status === "error" || status === "rejected") return ; if (status === "awaiting-confirmation") return ; return ; } function safeToolValue(value: unknown): ReactNode { if (value === undefined || value === null) return null; if (isValidElement(value)) return value; if (typeof value === "string" || typeof value === "number") return value; if (typeof value === "boolean") return value ? "Yes" : "No"; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function toolActionClassName(tone: AIToolAction["tone"]) { return cn( "inline-flex min-h-8 items-center rounded-[var(--uhuru-radius-sm)] border px-3 py-1.5 font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50", tone === "primary" ? "border-[var(--uhuru-border-accent)] bg-[var(--uhuru-accent-solid)] text-[var(--uhuru-text-inverse)] hover:opacity-90" : tone === "destructive" ? "border-[var(--uhuru-error-border)] bg-[var(--uhuru-error-bg)] text-[var(--uhuru-error-text)] hover:bg-[var(--uhuru-error-border)]/20" : "border-[var(--uhuru-border-default)] text-[var(--uhuru-text-primary)] hover:bg-[var(--uhuru-surface-muted)]", ); } function ToolPresentation({ presentation }: { presentation: AIToolPresentation }) { if (presentation.kind === "default" || presentation.kind === "custom") { return null; } if (presentation.kind === "status") { const toneClassName = { danger: "border-[var(--uhuru-error-border)] bg-[var(--uhuru-error-bg)] text-[var(--uhuru-error-text)]", info: "border-[var(--uhuru-info-border)] bg-[var(--uhuru-info-bg)] text-[var(--uhuru-info-text)]", neutral: "border-[var(--uhuru-border-subtle)] bg-[var(--uhuru-surface-default)] text-[var(--uhuru-text-secondary)]", success: "border-[var(--uhuru-success-border)] bg-[var(--uhuru-success-bg)] text-[var(--uhuru-success-text)]", warning: "border-[var(--uhuru-warning-border)] bg-[var(--uhuru-warning-bg)] text-[var(--uhuru-warning-text)]", }[presentation.tone ?? "neutral"]; return (
{presentation.label}
{presentation.description ? (
{presentation.description}
) : null}
); } if (presentation.kind === "key-value") { return (
{presentation.title ?
{presentation.title}
: null}
{presentation.fields.map((field, index) => (
{field.label}
{safeToolValue(field.value)}
))}
); } if (presentation.kind === "list") { return (
{presentation.title ?
{presentation.title}
: null}
{presentation.items.map((item, index) => (
{item.label}
{item.description ?
{item.description}
: null} {item.value !== undefined ?
{safeToolValue(item.value)}
: null}
{item.badge ? {item.badge} : null}
))}
); } if (presentation.kind === "table") { return (
{presentation.title ?
{presentation.title}
: null}
{presentation.columns.map((column) => )} {presentation.rows.map((row, rowIndex) => ( {presentation.columns.map((column) => )} ))}
{column.label}
{safeToolValue(row[column.key])}
); } if (presentation.kind === "resource") { const body = (
{presentation.resourceType}
{presentation.title}
{presentation.description ?
{presentation.description}
: null} {presentation.metadata?.length ?
{presentation.metadata.map((item, index) =>
{item.label}
{safeToolValue(item.value)}
)}
: null}
); return presentation.href ? {body} : body; } if (presentation.kind === "progress") { const max = presentation.max && presentation.max > 0 ? presentation.max : 100; const value = Math.min(max, Math.max(0, presentation.value ?? 0)); const percentage = Math.round((value / max) * 100); return (
{presentation.label}{percentage}%
{presentation.description ?
{presentation.description}
: null}
); } return (
{presentation.title ?
{presentation.title}
: null}
{safeToolValue(presentation.before)}
{safeToolValue(presentation.after)}
); } function PendingResponse() { return ( Assistant is responding {[0, 1, 2].map((index) => ( ); } function Thoughts({ thoughts }: { thoughts: AIMessageThought[] }) { const [expanded, setExpanded] = useState(false); if (!thoughts.length) return null; return (
{ event.preventDefault(); setExpanded((current) => !current); }} > Thought process ({thoughts.length}) {expanded ? (
{thoughts.map((thought, index) => (
{thought.text}
))}
) : null}
); } function DefaultToolCall({ defaultExpanded = false, message, onAction, toolCall, }: { defaultExpanded?: boolean; message: AIMessage; onAction?: AIToolCallRenderContext["onAction"]; toolCall: AIToolCall; }) { const [open, setOpen] = useState( toolCall.defaultExpanded ?? defaultExpanded, ); return (
{ event.preventDefault(); setOpen((current) => !current); }} > {toolCall.summary ?? toolCall.name} {open ? (
{toolCall.presentation ? : null} {toolCall.args || toolCall.input !== undefined ? (
              {safeToolValue(toolCall.input ?? toolCall.args)}
            
) : null} {(!toolCall.presentation || toolCall.presentation.kind === "default" || toolCall.presentation.kind === "custom") && toolCall.output !== undefined ? (
{safeToolValue(toolCall.output)}
) : null} {toolCall.error ?
{safeToolValue(toolCall.error)}
: null} {toolCall.progress ? : null} {toolCall.actions?.length ? (
{toolCall.actions.map((action) => action.href ? ( {action.label} ) : ( ))}
) : null}
) : null}
); } function toolGroupLabel(toolCalls: AIToolCall[]) { const names = toolCalls.map((toolCall) => toolCall.name.replace(/[-_]+/g, " ").trim()); if (names.some((name) => /search|scrap|read|fetch|browse|research/i.test(name))) return "Research"; if (names.some((name) => /edit|write|update|create|delete|file/i.test(name))) return "Workspace changes"; if (names.some((name) => /command|shell|terminal|process/i.test(name))) return "Commands"; return "Tool activity"; } function ToolCallGroup({ defaultExpanded = false, renderTool, toolCalls, }: { defaultExpanded?: boolean; renderTool: (toolCall: AIToolCall) => ReactNode; toolCalls: AIToolCall[]; }) { const [expanded, setExpanded] = useState(defaultExpanded); useEffect(() => { if (toolCalls.length <= 1) setExpanded(defaultExpanded); }, [defaultExpanded, toolCalls.length]); if (toolCalls.length === 0) return null; if (toolCalls.length === 1) return <>{renderTool(toolCalls[0])}; return (
{toolCalls.map((toolCall) => (
{renderTool(toolCall)}
))}
); } function roleIcon(role: AIMessageRole) { if (role === "assistant") return ( ); if (role === "tool") return ; if (role === "system") return ; return ; } export function AIMessageList({ className, defaultToolCallsExpanded = false, editOnDoubleTap = true, emptyState = (

No messages yet.

), feedbackDialogDescription = "Rate this response and leave an optional note for the team.", feedbackDialogTitle = "Share feedback", groupToolCalls = false, messages, mentionHighlightClassName = "", mentions = [], onFeedbackSubmit, onCopy, onEdit, onEditSubmit, onToolAction, renderMention, renderAction, renderAvatar, renderContent, renderEdit, renderMessage, renderToolCall, showCopyAction = true, showEditAction = false, showFeedbackAction = true, showAvatars = true, showMessageActions = true, showTimestamps = true, showToolCalls = true, toolRenderers = {}, variant = "default", }: AIMessageListProps) { const [copiedMessageId, setCopiedMessageId] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [editValue, setEditValue] = useState(""); const [editedContent, setEditedContent] = useState>( {}, ); const [feedbackMessage, setFeedbackMessage] = useState( null, ); const [feedbackRating, setFeedbackRating] = useState(0); const [feedbackHoverRating, setFeedbackHoverRating] = useState(0); const [feedbackReview, setFeedbackReview] = useState(""); const [feedbackError, setFeedbackError] = useState(""); const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); const lastTouchRef = useRef<{ id: string; time: number; x: number; y: number; } | null>(null); const feedbackModal = useOverlay(); const isInteractiveTarget = (target: EventTarget | null) => target instanceof Element && Boolean( target.closest("a, button, input, select, textarea, [role='button']"), ); const toggleMessageEdit = (message: AIMessage) => { if (message.role !== "user" || typeof message.content !== "string") return; if (editingMessageId === message.id) { setEditingMessageId(null); return; } setEditValue(String(editedContent[message.id] ?? message.content ?? "")); setEditingMessageId(message.id); onEdit?.(message); }; const handleMessageDoubleClick = ( event: ReactMouseEvent, message: AIMessage, ) => { if (!editOnDoubleTap || isInteractiveTarget(event.target)) return; event.preventDefault(); toggleMessageEdit(message); }; const handleMessagePointerUp = ( event: ReactPointerEvent, message: AIMessage, ) => { if ( !editOnDoubleTap || event.pointerType !== "touch" || isInteractiveTarget(event.target) ) return; const currentTouch = { id: message.id, time: performance.now(), x: event.clientX, y: event.clientY, }; const previousTouch = lastTouchRef.current; const isDoubleTap = previousTouch?.id === message.id && currentTouch.time - previousTouch.time < 350 && Math.hypot( currentTouch.x - previousTouch.x, currentTouch.y - previousTouch.y, ) < 24; if (isDoubleTap) { event.preventDefault(); lastTouchRef.current = null; toggleMessageEdit(message); return; } lastTouchRef.current = currentTouch; }; const feedbackEnabled = showFeedbackAction && typeof onFeedbackSubmit === "function"; const feedbackModalContent = feedbackEnabled && feedbackMessage ? (
{Array.from({ length: 5 }, (_, index) => { const rating = index + 1; const highlightedRating = feedbackHoverRating || feedbackRating; const selected = rating <= highlightedRating; return ( ); })}