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 . */}
} >
{/* 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 — accessory row above the composer (extra actions/toolbar). */}
{/* 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?.()} /> } >
{/* INJECT — footer row below the composer. */}
); }