import { type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' import { Suspense, useCallback, useMemo, useRef } from 'react' import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { NotificationStack } from '@/components/notifications' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' import { $activeSessionId, $awaitingResponse, $busy, $contextSuggestions, $currentCwd, $currentModel, $currentProvider, $freshDraftReady, $gatewayState, $introPersonality, $introSeed, $messages, $selectedStoredSessionId, $sessions, sessionPinId } from '@/store/session' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' import { ChatBar, ChatBarFallback } from './composer' import { requestComposerInsert } from './composer/focus' import { droppedFileInlineRef } from './composer/inline-refs' import type { ChatBarState } from './composer/types' import type { DroppedFile } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null onToggleSelectedPin: () => void onDeleteSelectedSession: () => void onCancel: () => Promise | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void onBranchInNewChat: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void onPasteClipboardImage: () => void onPickFiles: () => void onPickFolders: () => void onPickImages: () => void onRemoveAttachment: (id: string) => void onSubmit: ( text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } ) => Promise | boolean onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onTranscribeAudio?: (audio: Blob) => Promise } interface ChatHeaderProps { activeSessionId: null | string isRoutedSessionView: boolean onDeleteSelectedSession: () => void onToggleSelectedPin: () => void selectedSessionId: null | string } function ChatHeader({ activeSessionId, isRoutedSessionView, onDeleteSelectedSession, onToggleSelectedPin, selectedSessionId }: ChatHeaderProps) { const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) const activeStoredSession = sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session' // Pins live on the durable lineage-root id, but selectedSessionId is the live // (tip) id — resolve through the loaded row so the menu reflects the pin // state after auto-compression rotates the id. const selectedIsPinned = activeStoredSession ? pinnedSessionIds.includes(sessionPinId(activeStoredSession)) : selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false // A brand-new session has no session to pin/delete/rename, so the header is // just a dead "New session" label + chevron. Drop it (and its border) // entirely until there's a real session to act on. if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) { return null } return (
) } export function ChatView({ className, gateway, onToggleSelectedPin, onDeleteSelectedSession, onCancel, onAddContextRef, onAddUrl, onAttachImageBlob, onAttachDroppedItems, onBranchInNewChat, maxVoiceRecordingSeconds, onPasteClipboardImage, onPickFiles, onPickFolders, onPickImages, onRemoveAttachment, onSubmit, onThreadMessagesChange, onEdit, onReload, onTranscribeAudio }: ChatViewProps) { const location = useLocation() const activeSessionId = useStore($activeSessionId) const awaitingResponse = useStore($awaitingResponse) const busy = useStore($busy) const contextSuggestions = useStore($contextSuggestions) const currentCwd = useStore($currentCwd) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) const messages = useStore($messages) const selectedSessionId = useStore($selectedStoredSessionId) const runtimeMessageCacheRef = useRef(new WeakMap()) const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 // Session is still loading if the route references a session we haven't // resumed yet. Once `activeSessionId` is set (runtime has resumed), the // session exists — even if it has zero messages (a brand-new routed // session). The flicker where `busy` flips true briefly during hydrate // is handled by `threadLoadingState`'s last-visible-user gate. const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) const showChatBar = !loadingSession const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ queryKey: ['model-options', activeSessionId || 'global'], queryFn: () => { if (!activeSessionId) { return getGlobalModelOptions() } if (!gateway) { throw new Error('Hermes gateway unavailable') } return gateway.request('model.options', { session_id: activeSessionId }) }, enabled: gatewayOpen }) const quickModels = useMemo( () => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel), [currentModel, currentProvider, modelOptionsQuery.data] ) const chatBarState = useMemo( () => ({ model: { model: currentModel, provider: currentProvider, canSwitch: gatewayOpen, loading: !gatewayOpen || (!currentModel && !currentProvider), quickModels }, tools: { enabled: true, label: 'Add context', suggestions: contextSuggestions }, voice: { enabled: true, active: false } }), [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] ) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] const branchParentByGroup = new Map() let visibleParentId: string | null = null let headId: string | null = null for (const message of messages) { let parentId = visibleParentId if (message.role === 'assistant' && message.branchGroupId) { if (!branchParentByGroup.has(message.branchGroupId)) { branchParentByGroup.set(message.branchGroupId, visibleParentId) } parentId = branchParentByGroup.get(message.branchGroupId) ?? null } const cachedMessage = runtimeMessageCacheRef.current.get(message) const runtimeMessage = cachedMessage ?? toRuntimeMessage(message) if (!cachedMessage) { runtimeMessageCacheRef.current.set(message, runtimeMessage) } items.push({ message: runtimeMessage, parentId }) if (!message.hidden) { visibleParentId = message.id headId = message.id } } return ExportedMessageRepository.fromBranchableArray(items, { headId }) }, [messages]) const runtime = useIncrementalExternalStoreRuntime({ messageRepository: runtimeMessageRepository, isRunning: busy, setMessages: onThreadMessagesChange, onNew: async () => { // Submission is handled explicitly by ChatBar. // Keeping this no-op avoids duplicate prompt.submit calls. }, onEdit, onCancel: async () => onCancel(), onReload }) // Drop files anywhere in the conversation area, not just on the composer // input — appending the same inline `@file:` ref chips the composer drop // produces (vs. attachment cards) so both surfaces behave identically. const onDropFiles = useCallback( (candidates: DroppedFile[]) => { const refs = candidates .map(candidate => droppedFileInlineRef(candidate, currentCwd)) .filter((ref): ref is string => Boolean(ref)) if (refs.length) { requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' }) } }, [currentCwd] ) const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles }) return (
{showChatBar && ( }> )}
) }