import { splitProps, For, Show, createSignal, createMemo, onMount, type JSX } from 'solid-js'; import { PanelLeftOpen } from 'lucide-solid'; import { cn } from '../utils/cn'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; import { Button } from '../ui/button'; import { Badge } from '../ui/badge'; import { ScrollArea } from '../ui/scroll-area'; import { ConversationItem } from './conversation-item'; import type { ConversationSummary, ConversationGroup } from '../types'; /** * The collapsed-rail fallback: a floating reopen button shown when the * conversation sidebar is collapsed. Shared by `kai-workspace` (its collapsed * branch) and the standalone `kai-conversations` (collapsed mode), so the rail * collapses identically in both. Renders only the button; the host owns the * surrounding region (the workspace puts the thread beside it, the standalone * element stands alone). `onExpand` reopens the rail. */ export function CollapsedRail(props: { onExpand: () => void; class?: string }) { return ( ); } export interface ConversationListProps { groups: ConversationGroup[]; conversations: ConversationSummary[]; activeId?: string; onSelect: (id: string) => void; onNewChat: () => void; onToggleSidebar?: () => void; /** Replaces the built-in title bar (toggle / "Chats" / New chat). */ header?: JSX.Element; /** A row below the list (e.g. account / settings / usage). */ footer?: JSX.Element; /** Replaces the built-in "no conversations yet" state. */ empty?: JSX.Element; /** Dense single-line rows (a leading dot + title, no message count). */ compact?: boolean; /** Fired whenever the built-in search box query changes (typing or a * programmatic `clear()`). Lets the facade surface a `kai-search` event. */ onSearchChange?: (query: string) => void; /** Receive the imperative controller once mounted. The `kai-conversations` * facade uses it to focus / clear the internal search input. */ controllerRef?: (controller: ConversationListController) => void; class?: string; } /** Imperative handle exposed via `controllerRef` — surfaces the internal search * box to the `kai-conversations` facade (the searchQuery signal lives here). */ export interface ConversationListController { /** Focus the built-in search ``. */ focus(options?: FocusOptions): void; /** Clear the internal search query (resets the list filter). */ clearSearch(): void; } export function ConversationList(props: ConversationListProps) { const [local] = splitProps(props, ['groups', 'conversations', 'activeId', 'onSelect', 'onNewChat', 'onToggleSidebar', 'header', 'footer', 'empty', 'compact', 'onSearchChange', 'controllerRef', 'class']); const [searchQuery, setSearchQuery] = createSignal(''); const isEmpty = createMemo(() => local.conversations.length === 0); // The search query is owned here; setQuery is the single mutation point so both // typing and the imperative clearSearch() notify the facade (→ kai-search). let searchInput: HTMLInputElement | undefined; const setQuery = (q: string) => { setSearchQuery(q); local.onSearchChange?.(q); }; // Hand the imperative controller (focus / clear the search box) to the facade. onMount(() => { local.controllerRef?.({ focus: (options) => searchInput?.focus(options), clearSearch: () => setQuery(''), }); }); const filteredConversations = createMemo(() => { const q = searchQuery().toLowerCase(); if (!q) return local.conversations; return local.conversations.filter((c) => c.title.toLowerCase().includes(q)); }); const groupedConversations = createMemo(() => { const grouped = new Map(); for (const conv of filteredConversations()) { const key = conv.groupId; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(conv); } return grouped; }); const ungrouped = createMemo(() => groupedConversations().get(undefined) ?? []); return (
{/* header (replace): the consumer's own title bar, else the built-in one. */}
Chats
} > {local.header}
setQuery(e.currentTarget.value)} placeholder="Search chats..." aria-label="Search chats" class="bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring w-full" />
{/* list, or the empty state (replace) when there are no conversations. */} No conversations yet } > {local.empty} } > {(group) => { const convs = createMemo(() => groupedConversations().get(group.id) ?? []); return ( 0}> ); }} 0}> } >
{(conv) => }
{/* footer (inject): a row below the list (account / settings / …). */}
{local.footer}
); } function GroupSection(props: { name: string; count: number; conversations: ConversationSummary[]; activeId?: string; onSelect: (id: string) => void; compact?: boolean }) { const [open, setOpen] = createSignal(true); return ( {props.name} {props.count}
{(conv) => }
); }