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 { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ErrorState } from '@/components/ui/error-state' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' 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 { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, $awaitingResponse, $busy, $contextSuggestions, $currentCwd, $currentModel, $currentProvider, $freshDraftReady, $gatewayState, $introPersonality, $introSeed, $lastVisibleMessageIsUser, $messages, $messagesEmpty, $resumeExhaustedSessionId, $selectedStoredSessionId, $sessions, sessionPinId } from '@/store/session' import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus' import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs' import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' import { ScrollToBottomButton } from './scroll-to-bottom-button' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null modelMenuContent?: React.ReactNode 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: (opts?: { silent?: boolean }) => Promise | void onPickFiles: () => void onPickFolders: () => void onPickImages: () => void onRemoveAttachment: (id: string) => void onSteer: (text: string) => Promise | boolean onSubmit: ( text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } ) => Promise | boolean onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise onRetryResume: (sessionId: string) => void onTranscribeAudio?: (audio: Blob) => Promise onDismissError?: (messageId: string) => void } 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 // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) // are compact side panels — they drop the session-actions header + border // entirely. A brand-new draft has nothing to pin/delete/rename either. if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { return null } return (
) } interface ChatRuntimeBoundaryProps { busy: boolean children: React.ReactNode onCancel: () => Promise | void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void /** Route points at an unloaded session — render empty until resume swaps in * the new transcript, so the previous session's messages don't linger. */ suppressMessages: boolean } const NO_MESSAGES: ChatMessage[] = [] /** * Owns the $messages subscription and the assistant-ui external-store runtime. * * Isolated from ChatView so the per-token delta flush (which replaces the * $messages atom ~30×/s during streaming) only re-renders this component and * the runtime provider. The children (Thread, ChatBar) are created by * ChatView, whose render output is stable across flushes — so React bails out * of re-rendering them by element identity and the stream's render cost stays * confined to the streaming message's own subtree. */ function ChatRuntimeBoundary({ busy, children, onCancel, onEdit, onReload, onThreadMessagesChange, suppressMessages }: ChatRuntimeBoundaryProps) { const storeMessages = useStore($messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageCacheRef = useRef(new WeakMap()) 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 }) return {children} } export function ChatView({ className, gateway, modelMenuContent, onToggleSelectedPin, onDeleteSelectedSession, onCancel, onAddContextRef, onAddUrl, onAttachImageBlob, onAttachDroppedItems, onBranchInNewChat, maxVoiceRecordingSeconds, onPasteClipboardImage, onPickFiles, onPickFolders, onPickImages, onRemoveAttachment, onSteer, onSubmit, onThreadMessagesChange, onEdit, onReload, onRestoreToMessage, onRetryResume, onTranscribeAudio, onDismissError }: ChatViewProps) { const location = useLocation() const { t } = useI18n() 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 gatewaySwapTarget = useStore($gatewaySwapTarget) const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) // PERF: ChatView must not subscribe to $messages — the atom is replaced on // every streaming delta flush (~30×/s) and a subscription here re-renders // the entire chat shell (header, chat bar, thread wrapper) per token. The // runtime that DOES need the messages lives in ChatRuntimeBoundary below; // this component only needs streaming-stable derivations. const messagesEmpty = useStore($messagesEmpty) const lastVisibleIsUser = useStore($lastVisibleMessageIsUser) const selectedSessionId = useStore($selectedStoredSessionId) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) const routedSessionId = routeSessionId(location.pathname) const isRoutedSessionView = Boolean(routedSessionId) // The URL points at a session the store hasn't loaded yet (sidebar / cmd-K / // direct nav). Derived in render so the swap reads instantly: the same frame // the id changes we drop the old transcript and show the loader, instead of // waiting for the resume effect (which paints a frame later) to clear them. const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId // The compact new-session pop-out skips the wordmark/tagline intro — it's a // scratch window, not the full-height empty state. const showIntro = !isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty // 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. // // resumeExhausted: the bounded auto-retry in use-route-resume gave up on this // routed session (gateway RPC + REST fallback failed through every attempt). // Suppress the loader and show an explicit error + manual Retry instead of // spinning forever. Gated on the route matching so a stale latch from another // session can't blank the current one. const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId const loadingSession = !resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId)) const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) // Hide the composer in the exhausted error state too: there's no live runtime // to send to until a retry rebinds one. Watch windows are pure spectators of a // subagent run driven elsewhere — no composer, transcript is read-only. const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() 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), modelMenuContent, quickModels }, tools: { enabled: true, label: 'Add context', suggestions: contextSuggestions }, voice: { enabled: true, active: false } }), [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels] ) // Drop files anywhere in the conversation area, not just on the composer // input. In-app drags (project tree / gutter) carry workspace-relative paths // the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder // drops carry absolute local paths that don't exist on a remote gateway (and // images need byte upload for vision), so route them through the attachment // pipeline — otherwise the local path leaks into the prompt verbatim. const onDropFiles = useCallback( (candidates: DroppedFile[]) => { const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) const refs = droppedFileInlineRefs(inAppRefs, currentCwd) if (refs.length) { requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' }) } if (osDrops.length) { void onAttachDroppedItems(osDrops) } }, [currentCwd, onAttachDroppedItems] ) // Dropping a sidebar session inserts an @session link the agent can resolve // via session_search (carries the source profile, so cross-profile works). const onDropSession = useCallback((session: SessionDragPayload) => { requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' }) }, []) const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession }) return (
{resumeExhausted && routedSessionId && (
)} {showChatBar && }
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above: that wrapper is a containing block for — and clips — position:fixed descendants, so the popped-out (fixed) composer would anchor to the chat column (which shifts/resizes with the sidebars) and get clipped off-screen instead of floating against the viewport. As a sibling it anchors to the outer relative container instead: docked is absolute (identical placement), floating resolves against the viewport. Both states stay mounted here, so dock⇄float never remounts the editor. */} {showChatBar && ( }> )}
) }