import { createSignal, createEffect, For, Show, onMount } from 'solid-js'; import { ChatConfig, useChatConfig } from '../primitives/chat-config'; import { type ComposerDoc, normalizeValue, serializeToText } from '../primitives/composer-model'; import { ChatContainer, ChatContainerContent, ChatContainerScrollAnchor } from './chat-container'; import { Message, MessageAvatar, MessageBody } from './message'; import { type AttachmentData } from './attachments'; import { createMessageFeedback, type MessageActionDetail } from '../primitives/message-feedback'; import { ModelSwitcher } from './model-switcher'; import { ScrollButton } from './scroll-button'; import { Context, ContextTrigger, ContextContent, ContextContentHeader, ContextContentBody, ContextContentFooter, ContextInputUsage, ContextOutputUsage, } from './context'; import { DefaultPromptInput } from '../elements/default-input'; import type { TriggerDef } from './composer'; import type { ChatMessage } from '../elements/chat-types'; import type { ProseSize } from '../primitives/chat-config'; import type { ModelOption } from '../types'; export interface ChatThreadContextUsage { usedTokens: number; maxTokens: number; inputTokens?: number; outputTokens?: number; estimatedCost?: number; } export interface ChatThreadProps { /** Extra classes for the thread root (e.g. `h-full`). */ class?: string; /** The full message thread to render, newest last. Each entry carries its role, * content, and optional reasoning/tools/attachments/actions. Set as a JS * property (`el.messages = [...]`). */ messages: ChatMessage[]; /** Value of the input. A **string** is controlled (the host owns the text and * updates it on `kai-value-change`). A **ComposerDoc** is a one-time seed that * pre-populates pills; the user then edits freely. Leave unset for uncontrolled. */ value?: string | ComposerDoc; /** Placeholder text shown in the empty input. */ placeholder?: string; /** When true, shows the loading/streaming state and disables submit (use while * awaiting the assistant's reply). */ loading?: boolean; /** Starter prompts shown above the input when the thread is empty. Clicking one * follows `suggestionMode`. Set as a JS property. */ suggestions?: string[]; /** What clicking a suggestion does: `'submit'` (default) sends it immediately * as if typed and submitted; `'fill'` just places it in the input. */ suggestionMode?: 'submit' | 'fill'; /** Keep suggestions visible after the conversation starts. By default * suggestions are conversation starters and hide once `messages` is * non-empty; set this to keep them always shown. Default false. */ persistSuggestions?: boolean; /** Body/prose font scale for rendered markdown (`'xs' | 'sm' | 'base' | 'lg'`). * Defaults to `'sm'`. */ proseSize?: ProseSize; /** Shiki theme name for syntax-highlighted code blocks (e.g. * `'github-dark-dimmed'`). */ codeTheme?: string; /** Enable Shiki syntax highlighting in code blocks. Turn off to render plain * `
` blocks (lighter, no highlighter load). Default true. */ codeHighlight?: boolean; /** Optional header title shown on the left of the header. */ chatTitle?: string; /** Optional model list. When set (>1 model) a ModelSwitcher is shown in the * header and a `kai-model-change` event fires on selection. */ models?: ModelOption[]; /** The currently selected model id (pairs with `models`). */ currentModel?: string; /** Optional context-window token usage. When set, a Context token meter is * shown in the header. */ context?: ChatThreadContextUsage; /** Show the scroll-to-bottom button inside the scroll area. Default true. */ scrollButton?: boolean; /** Whether the host has `slot="header-start"` content (left of the title) — * set by the `` facade so a custom control forces the header open. */ headerStart?: boolean; /** Whether the host has `slot="header-end"` content (right of the controls). */ headerEnd?: boolean; // ── Composition slots ───────────────────────────────────────────────────── // Each flag below is set by the ` ` facade when matching light-DOM // `slot="…"` content is projected, and gates one composition slot. Two kinds: // • INJECT — additive: project YOUR markup into a region (sidebar, footer, // composer-actions, header-start/-end). // • REPLACE — substitutive: your markup stands in for a whole region // (header, empty, composer). A replaced region's projected // content owns its own data/events — a slotted (light-DOM) node // can't read this component's reactive state. That boundary is // the whole reason `messages` stays a data prop, not a slot. /** REPLACE — full custom header in place of the built-in title/model/context bar. */ headerFull?: boolean; /** INJECT — left sidebar column (e.g. a conversation list / your own nav). */ sidebar?: boolean; /** REPLACE — custom zero-state rendered in the message area while the thread is empty (replaces the empty message list only; the composer and its suggestions still render). */ empty?: boolean; /** REPLACE — full custom composer in place of the built-in prompt input. The * projected content wires its own submit (the data-flow boundary). */ composer?: boolean; /** INJECT — accessory row just above the composer (e.g. extra actions). */ composerActions?: boolean; /** INJECT — footer row below the composer (disclaimers, token meter, …). */ footer?: boolean; /** Show a Search (Globe) button in the input toolbar; fires a `search` event. */ search?: boolean; /** Show a Voice (Mic) button in the input toolbar; fires a `voice` event. */ voice?: boolean; /** Rich entity triggers — each `{ char, kind, items }` opens a caret-anchored * menu that inserts an atomic pill (`/` skills, `@` agents/plugins). Set as a * JS property; forwarded to the input. */ triggers?: TriggerDef[]; /** Default icon per entity kind (kind → image src) for pills/menu items. */ kindIcons?: Record ; /** Whether each message's action bar is always visible (`'always'`, default) * or only revealed on hover of that message row (`'hover'`). */ actionsReveal?: 'always' | 'hover'; // callbacks (the facade maps these to dispatch()) onValueChange?: (value: string) => void; onSubmit?: (detail: { value: string; attachments: AttachmentData[] }) => void; onAttachmentsChange?: (attachments: AttachmentData[]) => void; onSuggestionClick?: (value: string) => void; onModelChange?: (modelId: string) => void; onMessageAction?: (detail: MessageActionDetail) => void; onSearch?: () => void; onVoice?: () => void; /** Receive the imperative controller once mounted. The kai-chat facade forwards * these as element methods (focus/clear/send/scrollToBottom). */ controllerRef?: (controller: ChatThreadController) => void; } /** Imperative handle exposed via `controllerRef` — the input half of the chat's * interaction surface, forwarded onto ` ` as instance methods. */ export interface ChatThreadController { focus(options?: FocusOptions): void; clear(): void; send(): void; scrollToBottom(behavior?: ScrollBehavior): void; } export function ChatThread(props: ChatThreadProps) { const outer = useChatConfig(); const reveal = () => (props.actionsReveal === 'hover' ? 'hover' : 'always'); // Feedback (copy + vote) state lives ABOVE the per-message , so streaming // re-renders (a fresh `messages` array ref per chunk) don't wipe it. // The copy/feedback toasts scope to the chat (this thread's root) so they appear // in-chat rather than at the page top. let rootEl: HTMLElement | undefined; const feedback = createMessageFeedback({ emit: (detail) => props.onMessageAction?.(detail), target: () => rootEl, }); const [internal, setInternal] = createSignal (props.value ?? ''); const [attachments, setAttachments] = createSignal ([]); // A string `value` is controlled; a ComposerDoc `value` is a one-time seed that // lives in `internal` so the user's (string) edits replace it without a fight. const current = (): string | ComposerDoc => typeof props.value === 'string' ? props.value : internal(); createEffect(() => { const v = props.value; if (v != null && typeof v !== 'string') setInternal(v); }); const handleChange = (v: string) => { setInternal(v); props.onValueChange?.(v); }; // After a send, reset the composer. Clear the internal draft ONLY when the value is // uncontrolled (props.value === undefined) — a controlled host owns its own value and // clears it itself. This lets the batteries-included hooks (useKaiChat/createKaiChat), // whose `bind` does not control `value`, get a clean composer after each submit. const afterSubmit = () => { setAttachments([]); if (props.value === undefined) setInternal(''); }; const handleSubmit = () => { props.onSubmit?.({ value: serializeToText(normalizeValue(current())), attachments: attachments() }); afterSubmit(); }; const handleSuggestionClick = (v: string) => { if ((props.suggestionMode ?? 'submit') === 'fill') { handleChange(v); props.onSuggestionClick?.(v); } else { props.onSubmit?.({ value: v, attachments: attachments() }); afterSubmit(); } }; const showHeader = () => !!(props.chatTitle || props.models || props.context || props.headerStart || props.headerEnd); // Suggestions are conversation starters: show only on an empty thread unless // the host opts into persisting them. const visibleSuggestions = () => props.persistSuggestions || props.messages.length === 0 ? props.suggestions : undefined; const showScrollButton = () => props.scrollButton !== false; // Hand the imperative controller to the facade once mounted (rootEl is set). onMount(() => { props.controllerRef?.({ focus: (options) => rootEl ?.querySelector ('[contenteditable]:not([contenteditable="false"]), textarea') ?.focus(options), clear: () => { setInternal(''); setAttachments([]); props.onValueChange?.(''); }, send: () => handleSubmit(), scrollToBottom: (behavior) => { const vp = rootEl?.querySelector ('.overflow-y-auto'); vp?.scrollTo({ top: vp.scrollHeight, behavior: behavior ?? 'smooth' }); }, }); }); return ( {/* The root is a ROW so a `sidebar` slot can sit beside the main column. With no sidebar projected it collapses to the original column. */} ); }(rootEl = e as HTMLElement)} class={`flex h-full bg-background ${props.class ?? ''}`}> {/* Header: a full `header` slot REPLACES the built-in bar; otherwise the built-in header renders, itself carrying the header-start/header-end INJECT slots. */}} > {/* Consumer-injected leading controls (sidebar-toggle, compose, a popover title-button). Projects light-DOM `slot="header-start"` children of; inert outside a shadow root. */} {props.chatTitle}props.onModelChange?.(modelId)} /> {/* Consumer-injected trailing controls (share, settings, …). Projects light-DOM `slot="header-end"` children of . */} {/* INJECT — accessory row above the composer (extra actions/toolbar). */}{/* REPLACE — custom empty-state slot, shown only while the thread is empty. The component still owns WHEN it shows (data state); the consumer owns WHAT it looks like. */} {(m) => { const body = ( feedback.handleAction(m, action)} /> ); const rowGroup = reveal() === 'hover' ? 'group ' : ''; return ( {body} } > {(av) => ( ); }})} {body} {/* INJECT — footer row below the composer. */}{/* REPLACE — a full `composer` slot stands in for the built-in input. The slotted content owns its own submit/loading wiring. */}{ setAttachments(a); props.onAttachmentsChange?.(a); }} onSearch={() => props.onSearch?.()} onVoice={() => props.onVoice?.()} /> } >