import { type JSX, For, createSignal, splitProps, Show } from "solid-js"; import { Tooltip } from "../ui/tooltip"; import { Copy, Check } from "lucide-solid"; import { cn } from "../utils/cn"; import { Markdown } from "./markdown"; import { Button } from "../ui/button"; import { actionIcon, BUILTIN_ACTION_LABEL } from "../ui/action-icons"; import type { ChatMessageAction, CustomAction, FeedbackVote } from "../elements/chat-types"; import { useChatConfig, textClass } from "../primitives/chat-config"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "./reasoning"; import { Tool, type ToolPart } from "./tool"; import { Attachments, Attachment, AttachmentPreview, AttachmentInfo, type AttachmentData } from "./attachments"; // --- Message --- export interface MessageProps extends JSX.HTMLAttributes { children: JSX.Element; } function Message(props: MessageProps) { const [local, rest] = splitProps(props, ["children", "class"]); return (
{local.children}
); } // --- MessageAvatar --- export interface MessageAvatarProps { src: string; alt: string; fallback?: string; class?: string; } function MessageAvatar(props: MessageAvatarProps) { return (
{props.fallback}
} > {props.alt}
); } // --- MessageContent --- export interface MessageContentProps extends JSX.HTMLAttributes { children: JSX.Element | string; markdown?: boolean; /** `::part` name(s) exposed on the content node. The body passes * `"bubble content"` so consumers can target either the rounded bubble or the * text region from outside the shadow boundary. */ part?: string; } function MessageContent(props: MessageContentProps) { const [local, rest] = splitProps(props, ["children", "markdown", "class", "part"]); const config = useChatConfig(); const classNames = () => cn( "min-w-0 rounded-lg p-2 text-foreground bg-secondary max-w-none break-words whitespace-normal", textClass(config.proseSize()), local.class, ); return ( {local.children} } > ); } // --- MessageActions --- export interface MessageActionsProps extends JSX.HTMLAttributes { children: JSX.Element; } function MessageActions(props: MessageActionsProps) { const [local, rest] = splitProps(props, ["children", "class"]); return (
{local.children}
); } // --- MessageActionBar --- export interface MessageActionBarProps { /** Built-in action names and/or custom action descriptors, in order. */ actions: (ChatMessageAction | CustomAction)[]; /** `'always'` (default) keeps the bar visible; `'hover'` reveals it on * parent `.group` hover. */ reveal?: 'always' | 'hover'; /** Fired with the built-in name or the custom action id when a button is clicked. */ onAction: (id: string) => void; /** The active feedback vote. When `'like'`/`'dislike'` is set, that button is * marked pressed (filled) and the OTHER vote button animates out. `undefined` * shows both. Pure/prop-driven so the bar survives re-renders. */ activeFeedback?: FeedbackVote; /** When true, the `copy` button shows its emerald check icon instead of the * copy glyph (cleared by the owner after ~2s). */ copied?: boolean; class?: string; } /** Normalize a bar entry (string built-in or custom object) to a uniform shape. */ function normalizeAction(a: ChatMessageAction | CustomAction) { if (typeof a === 'string') { return { id: a, label: BUILTIN_ACTION_LABEL[a], Icon: actionIcon(a) }; } return { id: a.id, label: a.label, Icon: actionIcon(a.icon) }; } /** Is this entry one of the two feedback votes? */ function feedbackVoteOf(a: ChatMessageAction | CustomAction): FeedbackVote | undefined { return a === 'like' || a === 'dislike' ? a : undefined; } /** * The shared message action toolbar. Renders one ghost icon button per entry — * built-in names pull their label+icon from the curated registry; custom * descriptors use their `label` plus `actionIcon(icon)` (label-only when the * icon is unknown or absent). `reveal="hover"` makes the bar fade in on the * parent `.group`'s hover. * * Pure/prop-driven: feedback (`activeFeedback`) and copy (`copied`) state are * owned by the parent facade and passed in — the bar holds no internal signals, * so it survives the new-array-per-chunk re-renders of a streaming thread. With * a vote active, the chosen `like`/`dislike` button is marked `aria-pressed` + * filled and the other vote button collapses its width (sliding the active thumb * into its place) via a 0fr↔1fr grid transition. The * `copy` button swaps to an emerald check while `copied`. */ function MessageActionBar(props: MessageActionBarProps) { return ( {(a) => { const item = normalizeAction(a); const tooltipText = () => (typeof a !== 'string' && a.tooltip) ? a.tooltip : item.label; const vote = feedbackVoteOf(a); // A vote button is the active one when it matches the resolved vote. const isActiveVote = () => vote !== undefined && props.activeFeedback === vote; // The COPY button reflects the copied check. const isCopy = item.id === 'copy'; const showCheck = () => isCopy && props.copied === true; // Factory (not a shared node): each Show branch gets its own Button so // the eager DOM node is never referenced from two places. const button = () => ( ); // Icon-only buttons get a tooltip; label-only buttons (text already // visible) don't. const rendered = () => ( {button()} ); // When the OTHER vote is active, this vote button collapses — its WIDTH // animates to zero via a 0fr↔1fr grid so the remaining thumb slides into // its place, while it fades out; it slides + fades back on un-vote. (Kept // mounted-but-collapsed, not unmounted, so the sibling can slide.) No // vote active → both shown; non-vote entries always render. return ( {(() => { const show = () => props.activeFeedback === undefined || props.activeFeedback === vote; return ( {rendered()} ); })()} ); }} ); } // --- MessageBody --- export interface MessageBodyProps { /** The message text/markdown. */ content: string; /** Optional collapsible reasoning block, rendered above the content. */ reasoning?: { text: string; label?: string }; /** Tool-call parts, rendered above the content. */ tools?: ToolPart[]; /** Inline attachment previews, rendered above the content. */ attachments?: AttachmentData[]; /** Whether this is a user message (right-aligned bubble) vs an assistant * message (full-width transparent). */ isUser: boolean; /** Whether the content renders as markdown. */ markdown: boolean; /** Action-bar entries — built-in names and/or custom descriptors. When empty * the bar is not rendered. */ actions?: (ChatMessageAction | CustomAction)[]; /** `'always'` (default) keeps the bar visible; `'hover'` reveals it on parent * `.group` hover. */ actionsReveal?: 'always' | 'hover'; /** Fired with the built-in name or custom id when an action is clicked. */ onAction?: (id: string) => void; /** The currently-active feedback vote, so the bar can mark like/dislike and * hide the other. */ activeFeedback?: FeedbackVote; /** When true, the copy button shows its "copied" check icon. */ copied?: boolean; /** INJECT slot projected at the TOP of the body — above the reasoning / tools / * content. A per-message header: a model-name label, a role + timestamp line. * In the `` shadow this is ``. */ beforeBody?: JSX.Element; /** INJECT slot projected at the BOTTOM of the body — below the action bar. A * citation / sources row, a token-cost / latency line. In the `` * shadow this is ``. */ afterBody?: JSX.Element; } /** * The shared message body: an optional reasoning block, tool calls, inline * attachments, the content bubble, and the action bar — in that order. This is * the single source of truth for how a message renders, consumed by `ChatThread` * (the `` over `messages`), the standalone `` facade, and (in * future) `kai-compare` for each candidate. Pure/prop-driven: all interaction * state (copied, feedback vote) is owned above and passed in. */ function MessageBody(props: MessageBodyProps) { return ( <> {/* before-body (inject): a per-message header above everything else. */} {props.beforeBody} {(r) => ( {r().label ?? 'Reasoning'} {r().text} )} {(tp) => } {(att) => ()} {props.content} 0}> props.onAction?.(id)} /> {/* after-body (inject): a citation row / cost line below the action bar. */} {props.afterBody} ); } // --- MessageAction --- export interface MessageActionProps { tooltip: string; children: JSX.Element; side?: "top" | "bottom" | "left" | "right"; class?: string; } function MessageAction(props: MessageActionProps) { return <>{props.children}; } // --- MessageCopyButton --- export interface MessageCopyButtonProps { content: string; size?: number; class?: string; } function MessageCopyButton(props: MessageCopyButtonProps) { const [copied, setCopied] = createSignal(false); const iconSize = () => props.size ?? 14; return ( ); } export { Message, MessageAvatar, MessageContent, MessageActions, MessageActionBar, MessageBody, MessageAction, MessageCopyButton };