import { Show, createSignal, onMount, onCleanup } from 'solid-js'; import { defineWebComponent } from './define'; import { createControllableSignal } from '../primitives/controllable'; import { readSlots, WORKSPACE_SLOTS } from './slots'; import { ChatThread, type ChatThreadContextUsage, type ChatThreadController } from '../components/chat-thread'; import { ConversationList, CollapsedRail } from '../components/conversation-list'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../ui/resizable'; import type { AttachmentData } from '../components/attachments'; import type { TriggerDef } from '../components/composer'; import type { ChatMessage } from './chat-types'; import type { ProseSize } from '../primitives/chat-config'; import type { ModelOption, ConversationGroup, ConversationSummary } from '../types'; interface Props extends Record { /** Pre-bucketed conversation groups for the sidebar. Set as a JS property. */ groups: ConversationGroup[]; /** Flat conversation list (auto-bucketed if `groups` is empty). Set as a JS property. */ conversations: ConversationSummary[]; /** Id of the open conversation, highlighted in the sidebar. */ activeId?: string; /** The active conversation's message thread, newest last. Set as a JS property. */ messages: ChatMessage[]; value?: string; placeholder?: string; loading?: boolean; suggestions?: string[]; suggestionMode?: 'submit' | 'fill'; proseSize?: ProseSize; codeTheme?: string; codeHighlight?: boolean; chatTitle?: string; models?: ModelOption[]; currentModel?: string; context?: ChatThreadContextUsage; scrollButton?: boolean; search?: boolean; voice?: boolean; /** Rich entity triggers (`/` skills, `@` agents/plugins) forwarded to the input. */ triggers?: TriggerDef[]; /** Default icon per entity kind (kind → image src) forwarded to the input. */ kindIcons?: Record; /** Sidebar default width as a percent of the workspace (default 26). */ sidebarWidth?: number; /** Sidebar min width in px (default 240). */ sidebarMinWidth?: number; /** Sidebar max width in px (default 420). */ sidebarMaxWidth?: number; /** Controlled collapsed state. Set this as a JS property (`el.sidebarCollapsed * = true`) to drive the sidebar from your app, updating it in response to the * `kai-sidebar-toggle` event. Omit for uncontrolled (the element manages it). */ sidebarCollapsed?: boolean; /** Initial collapsed state when uncontrolled (default false). Use the * `default-sidebar-collapsed` attribute to start collapsed in plain HTML. */ defaultSidebarCollapsed?: boolean; /** Auto-collapse the rail when the workspace's own width drops below this many * px, and re-expand when it grows back above. Uncontrolled only (it never * fights an app-driven `sidebarCollapsed`); omit to disable. Fires * `kai-sidebar-toggle`. Attribute: `collapse-below`. */ collapseBelow?: number; /** Render Recents as dense single-line rows (a leading dot + title, no count). */ compact?: boolean; /** Suppress the built-in ConversationList so the `sidebar-header` slot owns the * whole rail flex region (for apps that supply their own rail nav). Default * false. Attribute: `no-conversations`. */ noConversations?: boolean; } interface Events { /** A conversation was selected in the sidebar. */ 'kai-conversation-select': { id: string }; /** The "New chat" button was clicked. */ 'kai-new-chat': Record; /** The sidebar was collapsed or expanded. */ 'kai-sidebar-toggle': { collapsed: boolean }; /** User submitted a message. */ 'kai-submit': { value: string; attachments: AttachmentData[] }; /** Fired on every input change. */ 'kai-value-change': { value: string }; /** The header model switcher changed. */ 'kai-model-change': { modelId: string }; /** An action button on a message was clicked. `state` is present only for the * toggleable feedback votes: `'on'` when a like/dislike is set, `'off'` when * re-tapped to clear. */ 'kai-message-action': { messageId: string; action: string; state?: 'on' | 'off' }; /** The Search button was clicked. */ 'kai-search': Record; /** The Mic / voice button was clicked. */ 'kai-voice': Record; /** A suggestion chip was clicked (only in `suggestion-mode="fill"`). */ 'kai-suggestion-click': { value: string }; } defineWebComponent('kai-workspace', { groups: [], conversations: [], activeId: undefined, messages: [], value: undefined, placeholder: 'Send a message...', loading: false, suggestions: undefined, suggestionMode: 'submit', proseSize: 'sm', codeTheme: 'github-dark-dimmed', codeHighlight: true, chatTitle: undefined, models: undefined, currentModel: undefined, context: undefined, scrollButton: true, search: false, voice: false, triggers: undefined, kindIcons: undefined, sidebarWidth: 26, sidebarMinWidth: 240, sidebarMaxWidth: 420, sidebarCollapsed: undefined, defaultSidebarCollapsed: undefined, collapseBelow: undefined, compact: undefined, noConversations: undefined, }, (props, { dispatch, flag, expose, element }) => { // Which injection slots the consumer has filled. A bare is always a // truthy JSX node, so we render each region wrapper ONLY when readSlots reports // projected light-DOM content (re-read on childList mutation). const [slots, setSlots] = createSignal>({}); onMount(() => { const read = () => setSlots(readSlots(element, WORKSPACE_SLOTS)); read(); const observer = new MutationObserver(read); observer.observe(element, { childList: true }); onCleanup(() => observer.disconnect()); }); // Controlled/uncontrolled collapse: `sidebarCollapsed` (when set) wins; // otherwise the element manages its own state, seeded from // `defaultSidebarCollapsed`. `setCollapsedTo` always writes the internal value (a // no-op visually while controlled) and emits `kai-sidebar-toggle` so a controlling // app can update its own state. const [collapsed, setCollapsed] = createControllableSignal( () => props.sidebarCollapsed as boolean | undefined, flag('defaultSidebarCollapsed'), ); const setCollapsedTo = (next: boolean) => { setCollapsed(next); dispatch('kai-sidebar-toggle', { collapsed: next }); }; const toggle = () => setCollapsedTo(!collapsed()); // Responsive auto-collapse. When `collapseBelow` is set, a ResizeObserver on the // workspace root collapses the rail once the workspace's own width drops below // it and re-expands above it. Uncontrolled only — bail while `sidebarCollapsed` // is set so we never fight an app-driven collapse. `autoCollapsed` tracks whether // WE collapsed it, so a user's manual expand isn't auto-undone on the next tick. let rootEl!: HTMLDivElement; let autoCollapsed = false; onMount(() => { if (typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver((entries) => { const below = props.collapseBelow as number | undefined; if (below == null || props.sidebarCollapsed !== undefined) return; const width = entries[0]?.contentRect.width ?? rootEl.clientWidth; const isBelow = width < below; if (isBelow && !collapsed()) { autoCollapsed = true; setCollapsedTo(true); } else if (!isBelow && collapsed() && autoCollapsed) { autoCollapsed = false; setCollapsedTo(false); } }); ro.observe(rootEl); onCleanup(() => ro.disconnect()); }); // Imperative method API. The sidebar methods drive the same collapse path as the // in-UI toggle (named *Sidebar to avoid the `sidebarCollapsed` prop accessor). // The thread methods delegate to the embedded ChatThread's controller, captured // once below — the thread node is shared across both branches, so a single // controllerRef is stable across collapse/expand. let controller: ChatThreadController | undefined; expose({ /** Collapse/expand the conversation sidebar and fire `kai-sidebar-toggle`. */ toggleSidebar: () => toggle(), /** Force the conversation sidebar collapsed (fires `kai-sidebar-toggle`). */ collapseSidebar: () => setCollapsedTo(true), /** Force the conversation sidebar expanded (fires `kai-sidebar-toggle`). */ expandSidebar: () => setCollapsedTo(false), /** Focus the thread's composer. */ focus: (options?: FocusOptions) => controller?.focus(options), /** Clear the thread draft + staged attachments. */ clear: () => controller?.clear(), /** Submit the current thread draft programmatically (fires `kai-submit`). */ send: () => controller?.send(), /** Scroll the thread to the newest message. */ scrollToBottom: (behavior?: ScrollBehavior) => controller?.scrollToBottom(behavior), }); // Create the thread ONCE and reference the same node in both branches. // It's owned by this component root (not by a Show branch), so toggling the // sidebar moves the node between branches without disposing it — the thread's // own state (e.g. an uncontrolled draft) survives the collapse/expand. const threadEl = ( | undefined} onValueChange={(value) => dispatch('kai-value-change', { value })} onSubmit={(detail) => dispatch('kai-submit', detail)} onSuggestionClick={(value) => dispatch('kai-suggestion-click', { value })} onModelChange={(modelId) => dispatch('kai-model-change', { modelId })} onMessageAction={(detail) => dispatch('kai-message-action', detail)} onSearch={() => dispatch('kai-search', {})} onVoice={() => dispatch('kai-voice', {})} controllerRef={(c) => (controller = c)} /> ); // The main region: an optional top-placed `main-header` band above the thread. // Shared across both collapse branches (the thread node is the same instance). const mainRegion = (
); return (
{/* Collapsed: the reopen control lives in its OWN thin rail column (matching the sidebar's bg-surface) rather than floating over the content. The previous `absolute` overlay sat on top of a `main-header`'s leading title — see the t3code/codex/chatgpt apps, all of which fill that slot — so it both overlapped the header and competed with any in-header toggle. A dedicated column never overlaps and reads as a collapsed sidebar. */}
{mainRegion}
} > {/* The rail: an optional injected header above the list and footer below it (upgrade card / Design trigger / user menu). Carries a subtle, theme-aware default background (bg-surface), exposed as ::part(sidebar) so a consumer can override it. The inner list is made transparent so the part background reads uniformly. */}
{/* When `no-conversations` suppresses the built-in list, the projected header owns the whole rail flex region; otherwise it sits as a fixed band above the list. */}
dispatch('kai-conversation-select', { id })} onNewChat={() => dispatch('kai-new-chat', {})} onToggleSidebar={toggle} />
{mainRegion}
); });