import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { IconChevronDown, IconChevronsRight, IconX } from '@tabler/icons-react' import { canSubmitComposerAction, type ComposerAvailability, focusComposer } from '@/client/components/shared/Composer' import { useStickToBottom } from '@/client/features/chat/useStickToBottom' import { groupTurns } from '@/client/features/chat/group-turns' import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/interleave-notices' import { attachmentKey, useLive } from '@/client/features/chat/chat-store' import type { ChatPromptBubble } from '@/client/features/chat/ChatPromptBubbles' import type { ChatSendOptions } from '@/client/features/chat/chat-send' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import type { SystemNotice, Turn, ViewState } from '@/lib/types' import type { ComposerBanner } from './composer/banners/ComposerBanner' import { ChatComposer, type ComposerAnnotationControls } from './composer/ChatComposer' import { ChatEmptyState, type ChatWelcomeDestination, resolveChatEmptyState } from './ChatEmptyState' import { ChatSelector } from './ChatSelector' import { ThinkingIndicator, TurnView } from './TurnView' import { Button } from '@/client/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { cn } from '@/client/lib/cn' import { useUiStore } from '@/client/store/ui' export type ViewBuilderChatDraft = { sessionId: string builderId: string // Server-saved requirements the composer falls back to; the live draft text // is subscribed from the UI store by builder id inside ChatComposer, so // typing never re-renders this panel or the screen hosting it. initialValue: string onChange: (value: string) => void onRemoveDrawing: (localId: string) => void onSubmit: (value: string) => Promise } type ChatPanelProps = { active?: boolean focusRequest?: number docked?: boolean chatLoaded: boolean view: ViewState // The live streaming preview as a synthetic assistant turn (or null). Merged // into the transcript through the same groupTurns pipeline so a thinking-only // preview folds into the current tool group. See client/lib/preview-turn.ts. previewTurn?: Turn | null // Selected session id — used only as the scroll reset key (jump to bottom on // session switch). sessionId?: string | null processing: boolean composerBanner?: ComposerBanner composerAvailability: ComposerAvailability annotation?: ComposerAnnotationControls send: (text: string, options?: ChatSendOptions) => void stop: () => void onNavigateFromWelcome: (destination: ChatWelcomeDestination) => void // Chat on a separate tab doesn't have a close button onClose?: () => void builderDraft?: ViewBuilderChatDraft } const EMPTY_TURNS: Turn[] = [] const EMPTY_NOTICES: SystemNotice[] = [] export function ChatPanel({ active = true, focusRequest = 0, docked = false, chatLoaded, view, previewTurn, sessionId, processing, composerBanner, composerAvailability, annotation, send, stop, onNavigateFromWelcome, onClose, builderDraft }: ChatPanelProps) { const workspaceId = useWorkspaceId() const composerRef = useRef(null) const scrollRef = useRef(null) const effectiveSessionId = builderDraft?.sessionId ?? sessionId ?? null const modelSessionId = builderDraft ? null : (sessionId ?? null) const turns = builderDraft ? EMPTY_TURNS : view.turns const hasSentMessageFromMoi = useUiStore(state => state.hasSentMessageFromMoi) const isWorkspacePendingAnalysis = useUiStore(state => (state.workspaceIdsPendingAnalysis ?? []).includes(workspaceId) ) const attachmentsUploading = useLive(state => (state.attachments[attachmentKey(workspaceId, effectiveSessionId)] ?? []).some( attachment => attachment.status === 'uploading' ) ) const promptDisabled = !canSubmitComposerAction(true, attachmentsUploading, composerAvailability) // Visual grouping: fold consecutive tool-only assistant turns into one // synthetic turn so OpenAI Codex–style traces (which serialize one // assistant message per agent step) don't render with the wider // inter-turn gap between every tool call. See `dev/turn-spacing.md`. // The live preview turn is appended before grouping, so a thinking-only // preview merges into the trailing tool group exactly like its finalized form. const effectivePreviewTurn = builderDraft ? null : previewTurn const notices = builderDraft ? EMPTY_NOTICES : view.notices const groupedTurns = useMemo( () => groupTurns(effectivePreviewTurn ? [...turns, effectivePreviewTurn] : turns), [turns, effectivePreviewTurn] ) // Grouped turns plus the renderable notices (compaction, model changes) // woven in at the moment they happened. Interleaving runs AFTER grouping so // a notice never splits a tool-only run apart. const timeline = useMemo(() => interleaveNotices(groupedTurns, notices), [groupedTurns, notices]) const lastTurnId = groupedTurns.length > 0 ? groupedTurns[groupedTurns.length - 1].id : null const effectiveProcessing = builderDraft ? false : processing const showEmptyState = !!builderDraft || (chatLoaded && timeline.length === 0 && !effectiveProcessing) const emptyStateKind = resolveChatEmptyState({ isViewBuilderDraft: !!builderDraft, hasSentMessageFromMoi, isWorkspacePendingAnalysis }) // Stick to the bottom while pinned; respect scroll-up; jump on session switch. const { atBottom, scrollToBottom, scrollToTop } = useStickToBottom(scrollRef, effectiveSessionId) useLayoutEffect(() => { if (showEmptyState && emptyStateKind !== 'empty') scrollToTop() }, [showEmptyState, emptyStateKind, scrollToTop]) // The active chat surface owns initial focus. A monotonically increasing // request also refocuses an already-visible composer after intent actions. useEffect(() => { if (active) focusComposer(composerRef.current) }, [active, focusRequest]) // Sending always returns the user to the bottom, even if they'd scrolled up — // they expect to see their message and the reply. const handleSend = useCallback( async (text: string, options?: ChatSendOptions) => { if (builderDraft) await builderDraft.onSubmit(text) else send(text, options) scrollToBottom() }, [builderDraft, send, scrollToBottom] ) const handlePromptSelect = useCallback( (prompt: ChatPromptBubble) => { if (promptDisabled) return handleSend(prompt.prompt, { directives: prompt.context }) }, [handleSend, promptDisabled] ) return (
{onClose && docked && ( } /> Undock agent )} {!builderDraft && onClose && !docked && ( )}
{showEmptyState && ( )} {timeline.map(item => item.kind === 'notice' ? ( ) : ( ) )} {/* Pulsing dots only before the first token — once the preview has visible content it renders as a (possibly merged) grouped turn. */} {effectiveProcessing && !effectivePreviewTurn && }
{/* Jump to latest — shown only when scrolled up, so following the tail never yanks the user while they read history. */} {!atBottom && turns.length > 0 && ( )}
{composerBanner?.content}
) } type ChatNoticeRowProps = { notice: SystemNotice } // A quiet, centered one-liner marking a session event (context compacted, // model changed) at its place in the transcript. Kinds without designed copy // render nothing (see chatNoticeLabel). Exported for the /dev/chat-states // catalog. export function ChatNoticeRow({ notice }: ChatNoticeRowProps) { const label = chatNoticeLabel(notice) if (!label) return null return (
{label}
) }