// Module-level local model override — set by composer when user picks a local model // Avoids prop threading. Reset when switching back to cloud models. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { useNavigate } from '@tanstack/react-router' import { useQuery, useQueryClient } from '@tanstack/react-query' import { deriveFriendlyIdFromKey, isMissingAuth, readError, textFromMessage, } from './utils' import { advanceStickyStreamingText, createOptimisticMessage, createResponseWaitSnapshot, isTerminalActiveRunStatus, shouldClearWaitingForAssistantMessage } from './chat-screen-utils' import { appendHistoryMessage, chatQueryKeys, clearHistoryMessages, fetchStatus, updateHistoryMessageByClientId, updateHistoryMessageByClientIdEverywhere, updateSessionLastMessage, } from './chat-queries' import { ChatHeader } from './components/chat-header' import { ChatMessageList } from './components/chat-message-list' import { ChatEmptyState } from './components/chat-empty-state' import { ChatComposer } from './components/chat-composer' import { ConnectionStatusMessage } from './components/connection-status-message' import { clearPendingSendForSession, consumePendingSend, hasPendingGeneration, hasPendingSend, isRecentSession, resetPendingSend, setPendingGeneration, } from './pending-send' import { useChatMeasurements } from './hooks/use-chat-measurements' import { useChatHistory } from './hooks/use-chat-history' import { useRealtimeChatHistory } from './hooks/use-realtime-chat-history' import { snapshotOptimisticUserMessages } from './hooks/optimistic-message-reinject' import { useSmoothStreamingText } from './hooks/use-smooth-streaming-text' import { useStreamingMessage } from './hooks/use-streaming-message' import { useActiveRunCheck } from './hooks/use-active-run-check' import { useChatMobile } from './hooks/use-chat-mobile' import { useChatSessions } from './hooks/use-chat-sessions' import { useAutoSessionTitle } from './hooks/use-auto-session-title' import { useRenameSession } from './hooks/use-rename-session' import { useContextAlert } from './hooks/use-context-alert' import { ContextBar } from './components/context-bar' import { CHAT_OPEN_SETTINGS_EVENT, CHAT_PENDING_COMMAND_STORAGE_KEY, CHAT_RUN_COMMAND_EVENT, CHAT_SUBMIT_SELECTION_EVENT, } from './chat-events' import type { ChatRunCommandDetail, ChatSubmitSelectionDetail, } from './chat-events' import type {ResponseWaitSnapshot} from './chat-screen-utils'; import type { ChatComposerAttachment, ChatComposerHandle, ChatComposerHelpers, ThinkingLevel, } from './components/chat-composer' import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store' import type { ChatAttachment, ChatMessage, SessionMeta } from './types' import type {AgentActivity} from '@/stores/chat-activity-store'; import { useChatSettingsStore } from '@/hooks/use-chat-settings' import { playChatComplete } from '@/lib/sounds' import { addApproval, loadApprovals, saveApprovals, } from '@/screens/gateway/lib/approvals-store' import { stripQueuedWrapper } from '@/lib/strip-queued-wrapper' import { cn } from '@/lib/utils' import { toast } from '@/components/ui/toast' import { hapticTap } from '@/lib/haptics' import { FileExplorerSidebar } from '@/components/file-explorer' import { SEARCH_MODAL_EVENTS } from '@/hooks/use-search-modal' import { SIDEBAR_TOGGLE_EVENT } from '@/hooks/use-global-shortcuts' import { useWorkspaceStore } from '@/stores/workspace-store' import { TerminalPanel } from '@/components/terminal-panel' import { AgentViewPanel } from '@/components/agent-view/agent-view-panel' import { useTerminalPanelStore } from '@/stores/terminal-panel-store' import { useModelSuggestions } from '@/hooks/use-model-suggestions' import { ModelSuggestionToast } from '@/components/model-suggestion-toast' import { MobileSessionsPanel } from '@/components/mobile-sessions-panel' import { ContextAlertModal } from '@/components/usage-meter/context-alert-modal' import { ErrorToastContainer, showErrorToast } from '@/components/error-toast' // ContextMeter removed — ContextBar (PR #32) replaces it import { persistRecoveryMessage, useChatStore } from '@/stores/chat-store' import { useSessionModelStore } from '@/stores/session-model-store' import { useResearchCard } from '@/hooks/use-research-card' // MOBILE_TAB_BAR_OFFSET removed — tab bar always hidden in chat import { useTapDebug } from '@/hooks/use-tap-debug' import { useChatMode } from '@/hooks/use-chat-mode' import { useChatActivityStore } from '@/stores/chat-activity-store' export let _localModelOverride = '' export function setLocalModelOverride(model: string) { _localModelOverride = model } type ChatScreenProps = { activeFriendlyId: string isNewChat?: boolean onSessionResolved?: (payload: { sessionKey: string friendlyId: string }) => void forcedSessionKey?: string /** Hide header + file explorer + terminal for panel mode */ compact?: boolean /** * Disables internal `navigate()` side effects so the chat can be embedded * in other routes (e.g. Operations orchestrator card) without yanking the * user out to /chat/ on mount, refresh, or after send. */ embedded?: boolean } type PortableHistoryMessage = { role: 'user' | 'assistant' | 'system' content: string } function normalizeMimeType(value: unknown): string { if (typeof value !== 'string') return '' return value.trim().toLowerCase() } function isImageMimeType(value: unknown): boolean { const normalized = normalizeMimeType(value) return normalized.startsWith('image/') } function readDataUrlMimeType(value: unknown): string { if (typeof value !== 'string') return '' const match = /^data:([^;,]+)[^,]*,/i.exec(value.trim()) return match?.[1]?.trim().toLowerCase() || '' } function stripDataUrlPrefix(value: unknown): string { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' const commaIndex = trimmed.indexOf(',') if (trimmed.toLowerCase().startsWith('data:') && commaIndex >= 0) { return trimmed.slice(commaIndex + 1).trim() } return trimmed } function normalizeMessageValue(value: unknown): string { if (typeof value !== 'string') return '' const trimmed = value.trim() return trimmed.length > 0 ? trimmed : '' } function getPortableHistoryContent(message: ChatMessage): string { const text = textFromMessage(message).trim() if (text) return text if ( message.role === 'user' && Array.isArray(message.attachments) && message.attachments.length > 0 ) { return 'Please review the attached content.' } return '' } function buildPortableHistory( messages: Array, ): Array { return messages .filter( ( message, ): message is ChatMessage & { role: 'user' | 'assistant' | 'system' } => message.role === 'user' || message.role === 'assistant' || message.role === 'system', ) .filter((message) => (message as any).__streamingStatus !== 'streaming') .map((message) => { const content = getPortableHistoryContent(message) if (!content) return null return { role: message.role, content, } }) .filter((message): message is PortableHistoryMessage => message !== null) .slice(-20) } function sanitizeExportToken(value: string): string { return value .trim() .replace(/[^a-z0-9-_]+/gi, '-') .replace(/^-+|-+$/g, '') } function exportConversationTranscript(payload: { sessionLabel: string messages: Array }) { if (typeof document === 'undefined') return false const sessionToken = sanitizeExportToken(payload.sessionLabel) || 'conversation' const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const body = payload.messages .map((message) => { const role = typeof message.role === 'string' && message.role.trim() ? message.role.trim().toUpperCase() : 'MESSAGE' const text = textFromMessage(message).trim() const attachments = Array.isArray(message.attachments) ? message.attachments .map((attachment) => attachment?.name?.trim()) .filter((value): value is string => Boolean(value)) : [] const lines = [`## ${role}`] if (text) lines.push(text) if (attachments.length > 0) { lines.push('', 'Attachments:') for (const attachment of attachments) { lines.push(`- ${attachment}`) } } return lines.join('\n') }) .join('\n\n') .trim() const content = `# Hermes Conversation Export\n\nSession: ${payload.sessionLabel}\nExported: ${new Date().toISOString()}\n\n${body || '_No messages in this conversation._'}\n` const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `${sessionToken}-${timestamp}.md` document.body.appendChild(link) link.click() link.remove() window.setTimeout(() => URL.revokeObjectURL(url), 0) return true } function messageFallbackSignature(message: ChatMessage): string { const raw = message as Record const timestamp = normalizeMessageValue( typeof raw.timestamp === 'number' ? String(raw.timestamp) : raw.timestamp, ) const contentParts = Array.isArray(message.content) ? message.content .map((part: any) => { if (part.type === 'text') { return `t:${typeof part.text === 'string' ? part.text.trim() : ''}` } if (part.type === 'thinking') { return `th:${typeof part.thinking === 'string' ? part.thinking : ''}` } if (part.type === 'toolCall') { const toolPart = part return `tc:${toolPart.id ?? ''}:${toolPart.name ?? ''}` } return `p:${part.type ?? ''}` }) .join('|') : '' const attachments = Array.isArray(message.attachments) ? message.attachments .map((attachment) => { const name = typeof attachment?.name === 'string' ? attachment.name : '' const size = typeof attachment?.size === 'number' ? String(attachment.size) : '' const type = typeof attachment?.contentType === 'string' ? attachment.contentType : '' return `${name}:${size}:${type}` }) .join('|') : '' return `${message.role ?? 'unknown'}:${timestamp}:${contentParts}:${attachments}` } function getMessageClientId(message: ChatMessage): string { const raw = message as Record const directClientId = normalizeMessageValue(raw.clientId) if (directClientId) return directClientId const alternateClientId = normalizeMessageValue(raw.client_id) if (alternateClientId) return alternateClientId const optimisticId = normalizeMessageValue(raw.__optimisticId) if (optimisticId.startsWith('opt-')) { return optimisticId.slice(4) } return '' } function getRetryMessageKey(message: ChatMessage): string { const clientId = getMessageClientId(message) if (clientId) return `client:${clientId}` const raw = message as Record const optimisticId = normalizeMessageValue(raw.__optimisticId) if (optimisticId) return `optimistic:${optimisticId}` const messageId = normalizeMessageValue(raw.id) if (messageId) return `id:${messageId}` const timestamp = normalizeMessageValue( typeof raw.timestamp === 'number' ? String(raw.timestamp) : raw.timestamp, ) const messageText = textFromMessage(message).trim() return `fallback:${message.role ?? 'unknown'}:${timestamp}:${messageText}` } function isRetryableQueuedMessage(message: ChatMessage): boolean { if ((message.role || '') !== 'user') return false const raw = message as Record const status = normalizeMessageValue(raw.status) return status === 'error' } const commandHelpers: ChatComposerHelpers = { reset() {}, setValue() {}, setAttachments() {}, } function getMessageRetryAttachments( message: ChatMessage, ): Array { if (!Array.isArray(message.attachments)) return [] return message.attachments.filter((attachment) => { return Boolean(attachment) && typeof attachment === 'object' }) } function getMessageStatusValue(message: ChatMessage): string { return normalizeMessageValue((message as Record).status) } function getMessageTimestampValue(message: ChatMessage): number | null { const raw = message as Record const candidates = [ raw.timestamp, raw.__createdAt, raw.createdAt, raw.created_at, ] for (const candidate of candidates) { if (typeof candidate === 'number' && Number.isFinite(candidate)) { return candidate < 1_000_000_000_000 ? candidate * 1000 : candidate } if (typeof candidate === 'string') { const parsed = Date.parse(candidate) if (!Number.isNaN(parsed)) return parsed } } return null } function getMessageAttachmentSignature(message: ChatMessage): string { if (!Array.isArray(message.attachments) || message.attachments.length === 0) { return '' } return message.attachments .map((attachment) => { const name = typeof attachment?.name === 'string' ? attachment.name : '' const size = typeof attachment?.size === 'number' ? String(attachment.size) : '' const type = typeof attachment?.contentType === 'string' ? attachment.contentType : '' return `${name}:${size}:${type}` }) .sort() .join('|') } function isOptimisticUserMessage(message: ChatMessage): boolean { const raw = message as Record return ( normalizeMessageValue(raw.__optimisticId).length > 0 || ['sending', 'sent', 'done'].includes(getMessageStatusValue(message)) ) } function shouldCollapseTextDuplicate( existing: ChatMessage, candidate: ChatMessage, ): boolean { if (existing.role !== candidate.role) return false if (candidate.role === 'assistant') { return true } if (candidate.role !== 'user') return false const existingTs = getMessageTimestampValue(existing) const candidateTs = getMessageTimestampValue(candidate) if (existingTs !== null && candidateTs !== null) { if (Math.abs(existingTs - candidateTs) > 15_000) return false } // Collapse same-turn user duplicates even after the optimistic marker has been // cleared. The send path can leave us with an optimistic local message plus a // confirmed/history copy after completion; requiring one side to still look // optimistic misses that handoff and leaves both visible. const existingSig = getMessageAttachmentSignature(existing) const candidateSig = getMessageAttachmentSignature(candidate) if (existingSig && candidateSig) { return existingSig === candidateSig } return true } function stripQueuedWrapperFromUserMessage(message: ChatMessage): ChatMessage { if (message.role !== 'user') return message const text = textFromMessage(message) const cleanedText = stripQueuedWrapper(text) if (cleanedText === text) return message return { ...message, content: [{ type: 'text', text: cleanedText }], text: cleanedText, body: cleanedText, message: cleanedText, } } export function ChatScreen({ activeFriendlyId, isNewChat = false, onSessionResolved, forcedSessionKey, compact = false, embedded = false, }: ChatScreenProps) { const navigate = useNavigate() const chatFocusMode = useWorkspaceStore((s) => s.chatFocusMode) const setChatFocusMode = useWorkspaceStore((s) => s.setChatFocusMode) const queryClient = useQueryClient() const [sending, setSending] = useState(false) const [_creatingSession, setCreatingSession] = useState(false) const [sessionsOpen, setSessionsOpen] = useState(false) const [error, setError] = useState(null) const [isRedirecting, setIsRedirecting] = useState(false) const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } = useChatMeasurements() useTapDebug(mainRef, { label: 'chat-main' }) const chatMode = useChatMode() const isPortableMode = chatMode === 'portable' const portableChatFriendlyId = isPortableMode ? 'main' : activeFriendlyId // --- Issue #43 fix: lift waitingForResponse into persistent Zustand store --- // The store survives component unmount, so navigating away mid-stream const [liveToolActivity, setLiveToolActivity] = useState< Array<{ name: string; timestamp: number }> >([]) const streamTimer = useRef(null) const failsafeTimerRef = useRef(null) const lastAssistantSignature = useRef('') const refreshHistoryRef = useRef<() => void>(() => {}) const retriedQueuedMessageKeysRef = useRef(new Set()) const hasSeenDisconnectRef = useRef(false) const hadErrorRef = useRef(false) const [pendingApprovals, setPendingApprovals] = useState< Array >([]) const [isCompacting, setIsCompacting] = useState(false) const [researchResetKey, setResearchResetKey] = useState(0) // Per-session thinking level — stored in sessionStorage keyed by session const [thinkingLevel, setThinkingLevel] = useState(() => { if (typeof window === 'undefined') return 'low' const key = `claude-thinking-${activeFriendlyId || 'new'}` const stored = window.sessionStorage.getItem(key) if (stored === 'off' || stored === 'low' || stored === 'medium' || stored === 'high' || stored === 'adaptive') return stored return 'low' }) // Tracks whether the user has explicitly picked a thinking level for this session. // A missing/absent sessionStorage key means we should fall back to the Hermes config default. const thinkingInitializedByUserRef = useRef(false) useEffect(() => { if (typeof window === 'undefined') return const key = `claude-thinking-${activeFriendlyId || 'new'}` thinkingInitializedByUserRef.current = window.sessionStorage.getItem(key) !== null }, [activeFriendlyId]) const { alertOpen, alertThreshold, alertPercent, dismissAlert } = useContextAlert() const pendingStartRef = useRef(false) const composerHandleRef = useRef(null) // Idempotency guard prevents duplicate sends on paste/attach double-fire. const lastSendKeyRef = useRef('') const lastSendAtRef = useRef(0) const activeSendRef = useRef<{ sessionKey: string friendlyId: string clientId: string } | null>(null) const [fileExplorerCollapsed, setFileExplorerCollapsed] = useState(() => { if (typeof window === 'undefined') return true const stored = localStorage.getItem('claude-file-explorer-collapsed') return stored === null ? true : stored === 'true' }) const { isMobile } = useChatMobile(queryClient) const mobileKeyboardInset = useWorkspaceStore((s) => s.mobileKeyboardInset) const mobileComposerFocused = useWorkspaceStore( (s) => s.mobileComposerFocused, ) const mobileKeyboardActive = mobileKeyboardInset > 0 || mobileComposerFocused void mobileKeyboardActive // kept for future use const isTerminalPanelOpen = useTerminalPanelStore( (state) => state.isPanelOpen, ) const terminalPanelHeight = useTerminalPanelStore( (state) => state.panelHeight, ) const { renameSession, renaming: renamingSessionTitle } = useRenameSession() const sseConnectionState = useChatStore((s) => s.connectionState) const { sessionsQuery, sessions, activeSession, activeExists, activeSessionKey, activeTitle, sessionsError, sessionsLoading: _sessionsLoading, sessionsFetching: _sessionsFetching, refetchSessions: _refetchSessions, } = useChatSessions({ activeFriendlyId, isNewChat, forcedSessionKey }) const { historyQuery, historyMessages, messageCount, historyError, resolvedSessionKey, activeCanonicalKey, sessionKeyForHistory, } = useChatHistory({ activeFriendlyId: portableChatFriendlyId, activeSessionKey, forcedSessionKey, isNewChat, isRedirecting, activeExists, sessionsReady: sessionsQuery.isSuccess, queryClient, historyRefetchInterval: sseConnectionState === 'connected' ? 30_000 : 5_000, portableMode: isPortableMode, }) // --- Waiting state management (Issue #43 + #449) --- // resolvedSessionKey is now available (defined above from useChatHistory). const storeWaiting = useChatStore((s) => s.waitingSessionKeys) const sessionKeyForWaiting = useRef(undefined) const pendingVerifySessionKeyRef = useRef(undefined) // Keep the waiting-state ref in sync with the resolved session key sessionKeyForWaiting.current = resolvedSessionKey // Synchronously detect stale waiting state from sessionStorage. // This runs during render (not in an effect) so the guard in // waitingForResponse is active on the very first render, preventing // a flash of the "Thinking" indicator when reopening an old session. const needsStaleCheck = resolvedSessionKey && !isNewChat && storeWaiting.has(resolvedSessionKey) && pendingVerifySessionKeyRef.current !== resolvedSessionKey if (needsStaleCheck) { pendingVerifySessionKeyRef.current = resolvedSessionKey } // Track whether the active-run API check has completed. // Initialize to false when we detect stale state (needs verification), // true otherwise. This prevents showing "Thinking" until the API confirms. const [activeRunCheckDone, setActiveRunCheckDone] = useState(!needsStaleCheck) const waitingForResponse = useMemo(() => { const key = sessionKeyForWaiting.current if (!key) return hasPendingSend() || hasPendingGeneration() // If we restored waiting state from sessionStorage but haven't verified // with the API yet, don't show thinking — it might be stale (Issue #449). if ( storeWaiting.has(key) && pendingVerifySessionKeyRef.current === key && !activeRunCheckDone ) { return false } return storeWaiting.has(key) }, [storeWaiting, activeRunCheckDone]) const setWaitingForResponse = useCallback((waiting: boolean) => { const store = useChatStore.getState() const key = sessionKeyForWaiting.current if (!key) return if (waiting) { store.setSessionWaiting(key) } else { store.clearSessionWaiting(key) } }, []) // verification before showing thinking (Issue #449). useEffect(() => { const currentSessionKey = resolvedSessionKey if (!currentSessionKey || isNewChat) return const store = useChatStore.getState() if (store.isSessionWaiting(currentSessionKey)) { pendingVerifySessionKeyRef.current = currentSessionKey setActiveRunCheckDone(false) } else { // No restored waiting state — no need to verify pendingVerifySessionKeyRef.current = undefined setActiveRunCheckDone(true) } }, [resolvedSessionKey, isNewChat]) // On remount, check if the server still has an active run for this session. // If so, re-set waitingForResponse in the store so the UI shows the spinner. useActiveRunCheck({ sessionKey: resolvedSessionKey ?? '', enabled: !isNewChat && Boolean(resolvedSessionKey) && historyQuery.isSuccess, onCheckComplete: useCallback(() => { setActiveRunCheckDone(true) }, []), }) // Wire SSE realtime stream for instant message delivery const { messages: realtimeMessages, lastCompletedRunAt, connectionState, isRealtimeStreaming, realtimeStreamingText, realtimeStreamingThinking, realtimeLifecycleEvents, completedStreamingText, completedStreamingThinking, clearCompletedStreaming, streamingRunId, activeToolCalls, } = useRealtimeChatHistory({ sessionKey: isPortableMode ? 'main' : isNewChat ? 'new' : resolvedSessionKey || sessionKeyForHistory || activeCanonicalKey || 'main', friendlyId: portableChatFriendlyId, historyMessages, portableMode: isPortableMode, enabled: // Always enable for new chats in portable mode (no sessions API to resolve). // In enhanced mode, wait for session resolution before subscribing. ((isPortableMode && isNewChat) || (!isNewChat && Boolean( resolvedSessionKey || sessionKeyForHistory || activeCanonicalKey, ))) && !isRedirecting, onUserMessage: useCallback(() => { // External message arrived (e.g. from Telegram) — show thinking indicator setWaitingForResponse(true) setPendingGeneration(true) }, []), onApprovalRequest: useCallback((payload: Record) => { const approvalId = typeof payload.id === 'string' ? payload.id : typeof payload.approvalId === 'string' ? payload.approvalId : typeof payload.approvalId === 'string' ? payload.approvalId : '' const currentApprovals = loadApprovals() if ( approvalId && currentApprovals.some((entry) => { return entry.status === 'pending' && entry.gatewayApprovalId === approvalId }) ) { setPendingApprovals( currentApprovals.filter((entry) => entry.status === 'pending'), ) return } const actionValue = payload.action ?? payload.tool ?? payload.command const action = typeof actionValue === 'string' ? actionValue : actionValue ? JSON.stringify(actionValue) : 'Tool call requires approval' const contextValue = payload.context ?? payload.input ?? payload.args const context = typeof contextValue === 'string' ? contextValue : contextValue ? JSON.stringify(contextValue) : '' const agentNameValue = payload.agentName ?? payload.agent ?? payload.source const agentName = typeof agentNameValue === 'string' && agentNameValue.trim().length > 0 ? agentNameValue : 'Agent' const agentIdValue = payload.agentId ?? payload.sessionKey ?? payload.source const agentId = typeof agentIdValue === 'string' && agentIdValue.trim().length > 0 ? agentIdValue : 'claude' addApproval({ agentId, agentName, action, context, source: 'agent', gatewayApprovalId: approvalId || undefined, }) setPendingApprovals( loadApprovals().filter((entry) => entry.status === 'pending'), ) }, []), onCompactionStart: useCallback(() => { setIsCompacting(true) }, []), onCompactionEnd: useCallback(() => { setIsCompacting(false) }, []), }) // Keep activity stream open persistently — opens on mount so it's ready // before the first tool call fires (avoids connection latency gap). const waitingForResponseRef = useRef(waitingForResponse) useEffect(() => { waitingForResponseRef.current = waitingForResponse }, [waitingForResponse]) useEffect(() => { const events = new EventSource('/api/events') const onActivity = (event: MessageEvent) => { // Only populate pills while waiting — but connection stays warm always if (!waitingForResponseRef.current) return try { const payload = JSON.parse(event.data) as { type?: unknown title?: unknown } if (payload.type !== 'tool' || typeof payload.title !== 'string') { return } const name = payload.title.replace(/^Tool activity:\s*/i, '').trim() if (!name) return setLiveToolActivity((prev) => { const filtered = prev.filter((entry) => entry.name !== name) return [{ name, timestamp: Date.now() }, ...filtered].slice(0, 5) }) } catch { // Ignore malformed activity events. } } events.addEventListener('activity', onActivity) return () => { events.removeEventListener('activity', onActivity) events.close() } }, []) // mount only — stays open for session lifetime // Clear tool pills after response arrives (with brief delay so last pill is visible) useEffect(() => { if (waitingForResponse) return const timer = window.setTimeout(() => setLiveToolActivity([]), 800) return () => window.clearTimeout(timer) }, [waitingForResponse]) useEffect(() => { if (!waitingForResponse) return clearCompletedStreaming() }, [clearCompletedStreaming, waitingForResponse]) useEffect(() => { function checkApprovals() { const all = loadApprovals() setPendingApprovals(all.filter((entry) => entry.status === 'pending')) } checkApprovals() const id = window.setInterval(checkApprovals, 2000) return () => window.clearInterval(id) }, []) const resolvePendingApproval = useCallback( async (approval: ApprovalRequest, status: 'approved' | 'denied') => { const nextApprovals = loadApprovals().map((entry) => { if (entry.id !== approval.id) return entry return { ...entry, status, resolvedAt: Date.now(), } }) saveApprovals(nextApprovals) setPendingApprovals( nextApprovals.filter((entry) => entry.status === 'pending'), ) if (!approval.gatewayApprovalId) return const endpoint = status === 'approved' ? `/api/approvals/${approval.gatewayApprovalId}/approve` : `/api/approvals/${approval.gatewayApprovalId}/deny` try { await fetch(endpoint, { method: 'POST' }) } catch { // Local resolution still succeeds when API endpoint is unavailable. } }, [], ) // --- Stream management --- const streamStop = useCallback(() => { if (streamTimer.current) { window.clearTimeout(streamTimer.current) streamTimer.current = null } }, []) useEffect(() => { return () => { streamStop() if (failsafeTimerRef.current) { window.clearTimeout(failsafeTimerRef.current) failsafeTimerRef.current = null } } }, [streamStop]) const streamFinish = useCallback(() => { streamStop() if (failsafeTimerRef.current) { window.clearTimeout(failsafeTimerRef.current) failsafeTimerRef.current = null } setPendingGeneration(false) setWaitingForResponse(false) }, [streamStop]) const streamStart = useCallback(() => { if (!activeFriendlyId || isNewChat) return // No aggressive delayed refetch here — it wipes optimistic user messages // from the cache before the server has echoed them, causing the user's // message to disappear until the agent completes. The existing failsafes // (5s + 10s timeouts at lines below, active-run polling) handle the case // where SSE misses the done event. void activeFriendlyId // keep dep for eslint }, [activeFriendlyId, isNewChat]) refreshHistoryRef.current = function refreshHistory() { if (historyQuery.isFetching) return // Snapshot any unconfirmed optimistic user messages BEFORE refetch. // The refetch replaces the query cache with server data — if the server // hasn't processed the user's POST yet, the optimistic message vanishes. const historySessionKey = isPortableMode ? 'main' : activeSessionKey || sessionKeyForHistory || resolvedSessionKey || 'main' const reInjectOptimistic = snapshotOptimisticUserMessages( queryClient, portableChatFriendlyId, historySessionKey, ) void historyQuery.refetch().then(() => { // Re-inject optimistic messages that weren't in the server response reInjectOptimistic() }) } const clearTimerRef = useRef(null) // Failsafe: clear after done event + 10s if response never shows in display useEffect(() => { if (lastCompletedRunAt && waitingForResponse) { const timer = window.setTimeout(() => streamFinish(), 10000) return () => window.clearTimeout(timer) } }, [lastCompletedRunAt, waitingForResponse, streamFinish]) // Hard failsafe: if waiting for 5s+ and SSE missed the done event, refetch history useEffect(() => { if (!waitingForResponse) return const fallback = window.setTimeout(() => { if (activeRealtimeStreamingRef.current) return refreshHistoryRef.current() }, 5000) return () => window.clearTimeout(fallback) }, [waitingForResponse]) // Issue #43 polling fallback: when waiting but SSE hasn't reconnected, // poll the active-run endpoint every 5s to detect completion. useEffect(() => { if (!waitingForResponse || !resolvedSessionKey) return if (sseConnectionState === 'connected') return // SSE will deliver the event const interval = window.setInterval(async () => { try { const res = await fetch( `/api/sessions/${encodeURIComponent(resolvedSessionKey)}/active-run`, ) if (!res.ok) return const data = await res.json() if (!data.ok) return // Run not yet registered (gateway lag during silent processing) → keep waiting if (!data.run) return // Treat unknown / transient statuses as still-active to avoid premature teardown if (isTerminalActiveRunStatus(data.run.status)) { streamFinish() refreshHistoryRef.current() } } catch { // ignore network errors } }, 5000) return () => window.clearInterval(interval) }, [waitingForResponse, resolvedSessionKey, sseConnectionState, streamFinish]) useAutoSessionTitle({ friendlyId: activeFriendlyId, sessionKey: resolvedSessionKey, activeSession, messages: historyMessages, messageCount, enabled: !isNewChat && Boolean(resolvedSessionKey) && historyQuery.isSuccess, }) // Phase 4.1: Smart Model Suggestions const modelsQuery = useQuery({ queryKey: ['models'], queryFn: async () => { const res = await fetch('/api/models') if (!res.ok) return { models: [] } const data = await res.json() return data }, staleTime: 5 * 60 * 1000, // 5 minutes }) const currentModelQuery = useQuery({ queryKey: [ 'claude', 'session-status-model', resolvedSessionKey || activeFriendlyId || 'main', ], queryFn: async () => { try { const statusSessionKey = resolvedSessionKey || activeFriendlyId || 'main' const query = statusSessionKey ? `?sessionKey=${encodeURIComponent(statusSessionKey)}` : '' const res = await fetch(`/api/session-status${query}`) if (!res.ok) return '' const data = await res.json() const payload = data.payload ?? data // Same logic as chat-composer: read model from status payload if (payload.model) return String(payload.model) if (payload.currentModel) return String(payload.currentModel) if (payload.modelAlias) return String(payload.modelAlias) if (payload.resolved?.modelProvider && payload.resolved?.model) { return `${payload.resolved.modelProvider}/${payload.resolved.model}` } return '' } catch { return '' } }, refetchInterval: 30_000, retry: false, }) // Fetch the configured reasoning effort so the Chat Controls default matches // what Hermes actually uses instead of hardcoding 'low'. const reasoningEffortQuery = useQuery({ queryKey: ['hermes-config', 'reasoning-effort'], queryFn: async () => { try { const res = await fetch('/api/hermes-config') if (!res.ok) return 'low' const data = await res.json() as { config?: Record } const agentSection = data?.config?.agent if (agentSection && typeof agentSection === 'object' && !Array.isArray(agentSection)) { const effort = (agentSection as Record).reasoning_effort if (effort === 'off' || effort === 'low' || effort === 'medium' || effort === 'high') return effort } return 'low' } catch { return 'low' } }, staleTime: 10 * 60 * 1000, retry: false, }) const availableModelIds = useMemo(() => { const models = modelsQuery.data?.models || [] return models.map((m: any) => m.id).filter((id: string) => id) }, [modelsQuery.data]) const gatewayModel = currentModelQuery.data || '' const currentModel = _localModelOverride || gatewayModel // Ref so sendMessage can always read latest thinkingLevel without being in deps const thinkingLevelRef = useRef(thinkingLevel) useEffect(() => { thinkingLevelRef.current = thinkingLevel }, [thinkingLevel]) // Auto-upgrade thinking to adaptive for Claude 4.6 when session first loads const thinkingInitializedRef = useRef(false) useEffect(() => { if (!currentModel) return if (thinkingInitializedRef.current) return thinkingInitializedRef.current = true const is46 = currentModel.toLowerCase().includes('4-6') || currentModel.toLowerCase().includes('claude-4.6') if (is46) { const key = `claude-thinking-${activeFriendlyId || 'new'}` const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(key) : null // Only auto-set if not explicitly configured if (!stored) { setThinkingLevel('adaptive') } } }, [currentModel, activeFriendlyId]) // If no per-session thinking level override exists, inherit from Hermes config useEffect(() => { if (thinkingInitializedByUserRef.current) return const configEffort = reasoningEffortQuery.data if (!configEffort) return if (configEffort === 'off' || configEffort === 'low' || configEffort === 'medium' || configEffort === 'high') { setThinkingLevel(configEffort) } }, [reasoningEffortQuery.data]) // Persist thinking level changes to sessionStorage const handleThinkingLevelChange = useCallback( (level: ThinkingLevel) => { setThinkingLevel(level) if (typeof window !== 'undefined') { const key = `claude-thinking-${activeFriendlyId || 'new'}` window.sessionStorage.setItem(key, level) } }, [activeFriendlyId], ) const { suggestion, dismiss, dismissForSession } = useModelSuggestions({ currentModel, // Real model from session-status (fail closed if empty) sessionKey: resolvedSessionKey || 'main', messages: historyMessages.map((m) => ({ role: m.role as 'user' | 'assistant', content: textFromMessage(m), })) as any, availableModels: availableModelIds, }) const { isStreaming: localIsStreaming, streamingText: localStreamingText, streamingMessageId: localStreamingMessageId, startStreaming, cancelStreaming, } = useStreamingMessage({ pinMainSession: activeFriendlyId === 'main' && (resolvedSessionKey || activeFriendlyId || 'main') === 'main', onSessionResolved: useCallback( ({ sessionKey, friendlyId, }: { sessionKey: string friendlyId: string }) => { const activeSend = activeSendRef.current if (activeSend) { activeSendRef.current = { ...activeSend, sessionKey, friendlyId, } } if ( sessionKey === activeFriendlyId && friendlyId === activeFriendlyId ) { return } onSessionResolved?.({ sessionKey, friendlyId }) }, [activeFriendlyId, onSessionResolved], ), onStarted: useCallback( ({ runId }: { runId: string | null }) => { const activeSend = activeSendRef.current if (!activeSend?.clientId) return updateHistoryMessageByClientIdEverywhere( queryClient, activeSend.clientId, (message) => ({ ...message, status: 'sent', // Clear __optimisticId so isOptimisticUserMessage returns false. // Without this the message keeps being treated as pending and // gets re-persisted, causing transcript duplication. Fixes #506. __optimisticId: undefined, runId: runId ?? message.runId, }), ) setSending(false) }, [queryClient], ), onComplete: useCallback((message: ChatMessage) => { const activeSend = activeSendRef.current if (activeSend?.clientId) { updateHistoryMessageByClientIdEverywhere( queryClient, activeSend.clientId, (message) => ({ ...message, status: 'done', }), ) } if (activeSend?.sessionKey) { persistRecoveryMessage(activeSend.sessionKey, message) clearPendingSendForSession( activeSend.sessionKey, activeSend.friendlyId, ) } activeSendRef.current = null refreshHistoryRef.current() setSending(false) // Clear waitingForResponse so ThinkingBubble hides and message renders streamFinish() // Play notification sound if the user opted in (Settings → Chat). // Read directly from the store to avoid re-creating this callback on every settings change. if (useChatSettingsStore.getState().settings.soundOnChatComplete) { playChatComplete() } }, [queryClient, streamFinish]), onError: useCallback( (messageText: string) => { const activeSend = activeSendRef.current if (activeSend?.clientId && !isMissingAuth(messageText)) { updateHistoryMessageByClientIdEverywhere( queryClient, activeSend.clientId, (message) => ({ ...message, status: 'error', }), ) } activeSendRef.current = null setSending(false) if (isMissingAuth(messageText)) { if (!embedded) { try { navigate({ to: '/', replace: true }) } catch { /* router not ready */ } } return } const errorMessage = `Failed to send message. ${messageText}` setError(errorMessage) toast('Failed to send message', { type: 'error' }) showErrorToast(messageText) setPendingGeneration(false) setWaitingForResponse(false) }, [navigate, queryClient], ), onMessageAccepted: useCallback( (_sessionKey: string, friendlyId: string, clientId: string) => { // HTTP 200 received — server accepted the message. Clear "sending" // status immediately so the Retry timer never fires. This is the // primary confirmation path since the server does NOT echo user // messages back via SSE. updateHistoryMessageByClientId( queryClient, friendlyId, _sessionKey, clientId, (message) => ({ ...message, status: 'queued', }), ) updateHistoryMessageByClientIdEverywhere( queryClient, clientId, (message) => ({ ...message, status: 'queued', }), ) }, [queryClient], ), onAbort: useCallback(() => { activeSendRef.current = null setSending(false) setPendingGeneration(false) setWaitingForResponse(false) }, [setWaitingForResponse]), acceptedTimeoutMs: modelsQuery.data?.streamAcceptedTimeoutMs, handoffTimeoutMs: modelsQuery.data?.streamHandoffTimeoutMs, }) // Cancel any in-flight stream when the user navigates between sessions or // starts a new chat. Without this, an SSE stream from session A keeps // running after the user navigates away — and any chunks it had already // buffered before our abort takes effect could land in session B (the // newly active session). See #297 (cross-session response contamination). // Note: useStreamingMessage also has its own generation-token guard for // the buffered-chunk race, but cancelling here is the cleaner contract // (an in-flight response that the user navigated away from is no longer // wanted in either session). const navCancelKeyRef = useRef(null) useEffect(() => { const navKey = `${activeCanonicalKey ?? ''}::${isNewChat ? 'new' : activeFriendlyId}` if (navCancelKeyRef.current === null) { navCancelKeyRef.current = navKey return } if (navCancelKeyRef.current !== navKey) { navCancelKeyRef.current = navKey cancelStreaming() } }, [activeCanonicalKey, activeFriendlyId, isNewChat, cancelStreaming]) const activeIsRealtimeStreaming = isPortableMode ? localIsStreaming : isRealtimeStreaming const activeRealtimeStreamingText = isPortableMode ? localStreamingText : realtimeStreamingText const smoothActiveStreamingText = useSmoothStreamingText( activeRealtimeStreamingText, activeIsRealtimeStreaming, ) const stickyStreamingTextRef = useRef<{ runId: string | null; text: string }>({ runId: null, text: '', }) stickyStreamingTextRef.current = advanceStickyStreamingText({ isStreaming: activeIsRealtimeStreaming, runId: streamingRunId ?? null, rawText: activeRealtimeStreamingText, smoothedText: smoothActiveStreamingText, previousState: stickyStreamingTextRef.current, }) const stableActiveStreamingText = activeIsRealtimeStreaming ? smoothActiveStreamingText || activeRealtimeStreamingText || stickyStreamingTextRef.current.text : '' // Use realtime-merged messages for display (SSE + history) // Re-apply display filter to realtime messages const finalDisplayMessages = useMemo(() => { const filtered = realtimeMessages.filter((msg) => { if (msg.role === 'user') { const text = stripQueuedWrapper(textFromMessage(msg)) if (text.startsWith('A subagent task')) return false return true } if (msg.role === 'assistant') { if (msg.__streamingStatus === 'streaming') return true if ((msg as any).__optimisticId && !msg.content?.length) return true if (textFromMessage(msg).trim().length > 0) return true const content = Array.isArray(msg.content) ? msg.content : [] const hasToolCalls = content.some((part) => part.type === 'toolCall') const hasStreamToolCalls = Array.isArray((msg as any).__streamToolCalls) && (msg as any).__streamToolCalls.length > 0 return hasToolCalls || hasStreamToolCalls } return false }) const sortedForDedup = [...filtered].sort((a, b) => { const aRaw = a as Record const bRaw = b as Record const aIsOptimistic = normalizeMessageValue(aRaw.__optimisticId).startsWith('opt-') && !normalizeMessageValue(aRaw.id) const bIsOptimistic = normalizeMessageValue(bRaw.__optimisticId).startsWith('opt-') && !normalizeMessageValue(bRaw.id) if (aIsOptimistic && !bIsOptimistic) return 1 if (!aIsOptimistic && bIsOptimistic) return -1 return 0 }) const seen = new Set() const seenByText = new Map() const dedupedSet = new Set() for (const msg of sortedForDedup) { const raw = msg as Record const rawOptimisticId = normalizeMessageValue(raw.__optimisticId) const bareOptimisticUuid = rawOptimisticId.startsWith('opt-') ? rawOptimisticId.slice(4) : '' const idCandidates = [ normalizeMessageValue(raw.id), normalizeMessageValue(raw.messageId), normalizeMessageValue(raw.clientId), normalizeMessageValue(raw.client_id), normalizeMessageValue(raw.nonce), normalizeMessageValue(raw.idempotencyKey), bareOptimisticUuid, rawOptimisticId, ].filter(Boolean) const primaryKey = idCandidates.length > 0 ? `${msg.role}:id:${idCandidates[0]}` : `${msg.role}:fallback:${messageFallbackSignature(msg)}` if (seen.has(primaryKey)) continue const text = stripQueuedWrapper(textFromMessage(msg)).trim() if (text.length > 0) { const normalizedText = text.replace(/\s+/g, ' ') const textKey = `${msg.role}:text:${normalizedText}` const existingTextMatch = seenByText.get(textKey) if ( existingTextMatch && shouldCollapseTextDuplicate(existingTextMatch, msg) ) { continue } if (!existingTextMatch) { seenByText.set(textKey, msg) } } seen.add(primaryKey) for (const candidate of idCandidates.slice(1)) { seen.add(`${msg.role}:id:${candidate}`) } dedupedSet.add(msg) } const deduped = filtered .filter((msg) => dedupedSet.has(msg)) .map((msg) => stripQueuedWrapperFromUserMessage(msg)) if (!activeIsRealtimeStreaming) { return deduped } let nextMessages = [...deduped] const streamToolCalls = activeToolCalls.map((toolCall) => ({ ...toolCall, phase: toolCall.phase, })) const streamingMsg = { role: 'assistant', content: [], __optimisticId: 'streaming-current', __streamingStatus: 'streaming', __streamingText: stableActiveStreamingText, __streamingThinking: realtimeStreamingThinking, __streamToolCalls: streamToolCalls, } as ChatMessage // Check if the server has already returned a completed assistant message // that overlaps with the streaming text. If so, drop the streaming // placeholder to avoid showing the same response twice. const streamingText = stableActiveStreamingText.trim() const hasServerAssistantVersion = nextMessages.some((msg) => { if (msg.role !== 'assistant') return false if (msg.__streamingStatus === 'streaming') return false // Any non-streaming assistant message that appears after the last user // message is potentially the same response — match by text overlap if (streamingText.length > 0) { const msgText = textFromMessage(msg).trim() if (msgText.length > 0 && ( msgText === streamingText || msgText.startsWith(streamingText) || streamingText.startsWith(msgText) )) { return true } } // Also match by tool calls: if the server message has the same tool // calls as the streaming placeholder, it's the same response if (streamToolCalls.length > 0) { const msgContent = Array.isArray(msg.content) ? msg.content : [] const msgToolCalls = msgContent.filter((p: any) => p.type === 'toolCall') if (msgToolCalls.length > 0 && msgToolCalls.length === streamToolCalls.length) { return streamToolCalls.every((stc: any) => msgToolCalls.some((mtc: any) => mtc.name === stc.name) ) } } return false }) if (hasServerAssistantVersion) { return nextMessages } const existingStreamIdx = nextMessages.findIndex( (message) => message.__streamingStatus === 'streaming', ) if (existingStreamIdx >= 0) { nextMessages[existingStreamIdx] = { ...nextMessages[existingStreamIdx], ...streamingMsg, } // Remove any other streaming messages (e.g. from mergeHistoryMessages // appending a realtime message after finalDisplayMessages already // injected a placeholder). Keep only one streaming placeholder. const keepIdx = existingStreamIdx nextMessages = nextMessages.filter( (m, i) => i === keepIdx || m.__streamingStatus !== 'streaming', ) return nextMessages } const lastUserIdx = nextMessages.reduce( (lastIdx, msg, idx) => (msg.role === 'user' ? idx : lastIdx), -1, ) if (lastUserIdx >= 0 && lastUserIdx === nextMessages.length - 1) { nextMessages.push(streamingMsg) } else if (lastUserIdx >= 0) { nextMessages.splice(lastUserIdx + 1, 0, streamingMsg) } else { nextMessages.push(streamingMsg) } return nextMessages }, [ activeToolCalls, activeIsRealtimeStreaming, activeRealtimeStreamingText, realtimeMessages, realtimeStreamingThinking, ]) const derivedStreamingInfo = useMemo(() => { if (activeIsRealtimeStreaming) { const last = finalDisplayMessages[finalDisplayMessages.length - 1] const id = isPortableMode ? localStreamingMessageId : last?.role === 'assistant' ? (last as any).__optimisticId || (last as any).id || null : null return { isStreaming: true, streamingMessageId: id } } if (waitingForResponse && finalDisplayMessages.length > 0) { const last = finalDisplayMessages[finalDisplayMessages.length - 1] if (last && last.role === 'assistant') { const isStreamingPlaceholder = (last as any).__streamingStatus === 'streaming' if (!isStreamingPlaceholder) { return { isStreaming: false, streamingMessageId: null as string | null, } } const id = (last as any).__optimisticId || (last as any).id || null return { isStreaming: true, streamingMessageId: id } } } return { isStreaming: false, streamingMessageId: null as string | null } }, [ waitingForResponse, finalDisplayMessages, activeIsRealtimeStreaming, isPortableMode, localStreamingMessageId, ]) const responseWaitSnapshotRef = useRef(null) const prevIsRealtimeStreamingRef = useRef(activeIsRealtimeStreaming) const activeRealtimeStreamingRef = useRef(activeIsRealtimeStreaming) useEffect(() => { activeRealtimeStreamingRef.current = activeIsRealtimeStreaming }, [activeIsRealtimeStreaming]) useEffect(() => { if (!waitingForResponse) { responseWaitSnapshotRef.current = null return } if (responseWaitSnapshotRef.current) return responseWaitSnapshotRef.current = createResponseWaitSnapshot(finalDisplayMessages) }, [waitingForResponse, finalDisplayMessages]) useEffect(() => { if (!waitingForResponse) { if (clearTimerRef.current) { window.clearTimeout(clearTimerRef.current) clearTimerRef.current = null } return } const snapshot = responseWaitSnapshotRef.current if (!snapshot) return if (shouldClearWaitingForAssistantMessage(finalDisplayMessages, snapshot)) { if (clearTimerRef.current) return clearTimerRef.current = window.setTimeout(() => { clearTimerRef.current = null streamFinish() }, 50) } }, [finalDisplayMessages, waitingForResponse, streamFinish]) useEffect(() => { const wasStreaming = prevIsRealtimeStreamingRef.current prevIsRealtimeStreamingRef.current = activeIsRealtimeStreaming if (wasStreaming && !activeIsRealtimeStreaming && waitingForResponse) { if (clearTimerRef.current) return clearTimerRef.current = window.setTimeout(() => { clearTimerRef.current = null streamFinish() }, 100) } }, [activeIsRealtimeStreaming, waitingForResponse, streamFinish]) const handleSwitchModel = useCallback(async () => { if (!suggestion) return try { const res = await fetch('/api/model-switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionKey: resolvedSessionKey || 'main', model: suggestion.suggestedModel, }), }) if (res.ok) { dismiss() // Optionally show success toast or update UI } } catch (err) { setError( `Failed to switch model. ${err instanceof Error ? err.message : String(err)}`, ) } }, [suggestion, resolvedSessionKey, dismiss]) // Sync chat activity to global store for sidebar orchestrator avatar const setLocalActivity = useChatActivityStore( (s) => s.setLocalActivity, ) as (next: AgentActivity) => void useEffect(() => { if (liveToolActivity.length > 0) { setLocalActivity('tool-use') } else if (activeIsRealtimeStreaming) { setLocalActivity('responding') } else if (waitingForResponse) { setLocalActivity('thinking') } else { setLocalActivity('idle') } }, [ waitingForResponse, activeIsRealtimeStreaming, liveToolActivity, setLocalActivity, ]) const statusQuery = useQuery({ queryKey: ['claude', 'status'], queryFn: fetchStatus, retry: 2, retryDelay: 1000, refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, staleTime: 30_000, refetchInterval: 60_000, // Re-check every 60s to clear stale errors }) // Don't show errors for new chats or when SSE is connected const statusError = !isNewChat && connectionState !== 'connected' ? statusQuery.error instanceof Error ? { message: statusQuery.error.message, status: (statusQuery.error as Error & { status?: number }).status, } : statusQuery.data && !statusQuery.data.ok ? { message: statusQuery.data.error || 'Hermes Agent unavailable', status: statusQuery.data.status, } : null : null const serverError = statusError?.message ?? sessionsError ?? historyError const serverErrorStatus = statusError?.status const showErrorNotice = Boolean(serverError) && !isNewChat const handleRefetch = useCallback(() => { void statusQuery.refetch() void sessionsQuery.refetch() void historyQuery.refetch() }, [statusQuery, sessionsQuery, historyQuery]) const handleRefreshHistory = useCallback(() => { void historyQuery.refetch() }, [historyQuery]) useEffect(() => { const handleRefreshRequest = () => { void historyQuery.refetch() } window.addEventListener('claude:chat-refresh', handleRefreshRequest) return () => { window.removeEventListener('claude:chat-refresh', handleRefreshRequest) } }, [historyQuery]) useEffect(() => { function handleVisibility() { if (document.visibilityState === 'visible') { void historyQuery.refetch() } } document.addEventListener('visibilitychange', handleVisibility) return () => document.removeEventListener('visibilitychange', handleVisibility) }, [historyQuery]) // Re-mount catch-up: when navigating back to chat from another tab (Skills, // Memory, etc.), the component re-mounts. If a response finished while we // were away, the initial refetch may hit stale data. A delayed re-refetch // ensures we pick up responses that were persisted shortly after the first // fetch. See: https://github.com/outsourc-e/hermes-workspace/issues/43 useEffect(() => { const timer = window.setTimeout(() => { void historyQuery.refetch() }, 2000) return () => window.clearTimeout(timer) }, []) // eslint-disable-line react-hooks/exhaustive-deps -- mount-only useEffect(() => { function handleSSEDrop() { void historyQuery.refetch() } window.addEventListener('claude:sse-dropped', handleSSEDrop) return () => { window.removeEventListener('claude:sse-dropped', handleSSEDrop) } }, [historyQuery]) const terminalPanelInset = !isMobile && isTerminalPanelOpen && !chatFocusMode ? terminalPanelHeight : 0 // --chat-composer-height is the measured offsetHeight of the composer wrapper, // which already includes its own paddingBottom (tab bar + safe area). // So content just needs composer-height + a small breathing gap. const mobileScrollBottomOffset = useMemo(() => { if (!isMobile) return 0 return 'var(--chat-composer-height, 56px)' }, [isMobile]) // Keep message list clear of composer, keyboard, and desktop terminal panel. const stableContentStyle = useMemo(() => { if (isMobile) { return { paddingBottom: 'calc(var(--chat-composer-height, 56px) + 8px)', } } return { paddingBottom: terminalPanelInset > 0 ? `${terminalPanelInset + 16}px` : '16px', } }, [isMobile, terminalPanelInset]) const shouldRedirectToNew = !isNewChat && !forcedSessionKey && !isRecentSession(activeFriendlyId) && sessionsQuery.isSuccess && sessions.length > 0 && !sessions.some((session) => session.friendlyId === activeFriendlyId) && !historyQuery.isFetching && !historyQuery.isSuccess useEffect(() => { if (isRedirecting) { if (error) setError(null) return } if (shouldRedirectToNew) { if (error) setError(null) return } if ( sessionsQuery.isSuccess && !activeExists && !sessionsError && !historyError ) { if (error) setError(null) return } const messageText = sessionsError ?? historyError ?? statusError?.message if (!messageText) { if (error?.startsWith('Failed to load')) { setError(null) } return } if (isMissingAuth(messageText) && !embedded) { navigate({ to: '/', replace: true }) } const message = sessionsError ? `Failed to load sessions. ${sessionsError}` : historyError ? `Failed to load history. ${historyError}` : statusError ? `Hermes Agent unavailable. ${statusError.message}` : null if (message) setError(message) }, [ activeExists, error, statusError, historyError, isRedirecting, navigate, sessionsError, sessionsQuery.isSuccess, shouldRedirectToNew, ]) useEffect(() => { if (!isRedirecting) return if (isNewChat) { setIsRedirecting(false) return } if (!shouldRedirectToNew && sessionsQuery.isSuccess) { setIsRedirecting(false) } }, [isNewChat, isRedirecting, sessionsQuery.isSuccess, shouldRedirectToNew]) useEffect(() => { if (embedded) return if (isNewChat) return if (!sessionsQuery.isSuccess) return if (sessions.length === 0) return if (!shouldRedirectToNew) return resetPendingSend() clearHistoryMessages(queryClient, activeFriendlyId, sessionKeyForHistory) const latestSession = sessions[0]?.friendlyId ?? 'new' navigate({ to: '/chat/$sessionKey', params: { sessionKey: latestSession }, replace: true, }) }, [ activeFriendlyId, historyQuery.isFetching, historyQuery.isSuccess, isNewChat, navigate, queryClient, sessionKeyForHistory, sessions, sessionsQuery.isSuccess, shouldRedirectToNew, embedded, ]) const hideUi = shouldRedirectToNew || isRedirecting const isFocusMode = !compact && chatFocusMode const showComposer = !isRedirecting const handleToggleFocusMode = useCallback(() => { if (compact) return setChatFocusMode(!chatFocusMode) }, [chatFocusMode, compact, setChatFocusMode]) useEffect(() => { if (compact && chatFocusMode) { setChatFocusMode(false) } }, [chatFocusMode, compact, setChatFocusMode]) useEffect(() => { if (!chatFocusMode) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.defaultPrevented) return setChatFocusMode(false) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [chatFocusMode, setChatFocusMode]) // ⌘. (Mac) / Ctrl+. (Win) to toggle focus mode useEffect(() => { if (compact) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== '.' || !(event.metaKey || event.ctrlKey)) return event.preventDefault() setChatFocusMode(!chatFocusMode) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [compact, chatFocusMode, setChatFocusMode]) useEffect(() => { return () => { useWorkspaceStore.getState().setChatFocusMode(false) } }, []) // Reset state when session changes useEffect(() => { const resetKey = isNewChat ? 'new' : activeFriendlyId if (!resetKey) return retriedQueuedMessageKeysRef.current.clear() if (pendingStartRef.current) { pendingStartRef.current = false return } if (hasPendingSend() || hasPendingGeneration()) { setWaitingForResponse(true) return } streamStop() lastAssistantSignature.current = '' setWaitingForResponse(false) }, [activeFriendlyId, isNewChat, streamStop]) /** * Simplified sendMessage - fire and forget. * Response arrives via SSE stream, not via this function. */ const sendMessage = useCallback( function sendMessage( sessionKey: string, friendlyId: string, body: string, attachments: Array = [], fastMode = false, skipOptimistic = false, existingClientId = '', ) { // Read from ref so we always get the latest value without capturing it in deps const currentThinkingLevel = thinkingLevelRef.current setLocalActivity('reading') const normalizedAttachments = attachments.map((attachment) => ({ ...attachment, id: attachment.id ?? crypto.randomUUID(), })) // Inject text/file attachment content directly into the message body. // Servers reliably forward text in the message body; file attachments // may be silently dropped for non-image types. const textBlocks = normalizedAttachments .filter((a) => { const mime = normalizeMimeType(a.contentType ?? '') || readDataUrlMimeType(a.dataUrl ?? '') return !isImageMimeType(mime) && (a.dataUrl ?? '').length > 0 }) .map((a) => { const raw = a.dataUrl ?? '' const content = raw.startsWith('data:') ? atob(raw.split(',')[1] ?? '') : raw return `\n\n\n${content}\n` }) const enrichedBody = body + textBlocks.join('') let optimisticClientId = existingClientId setResearchResetKey((current) => current + 1) if (!skipOptimistic) { const { clientId, optimisticMessage } = createOptimisticMessage( body, normalizedAttachments, ) optimisticClientId = clientId appendHistoryMessage( queryClient, friendlyId, sessionKey, optimisticMessage, ) updateSessionLastMessage( queryClient, sessionKey, friendlyId, optimisticMessage, ) } setPendingGeneration(true) setSending(true) setError(null) clearCompletedStreaming() setWaitingForResponse(true) activeSendRef.current = { sessionKey, friendlyId, clientId: optimisticClientId, } // Failsafe: clear waitingForResponse after 120s no matter what // Prevents infinite spinner if SSE/idle detection both fail if (failsafeTimerRef.current) { window.clearTimeout(failsafeTimerRef.current) } failsafeTimerRef.current = window.setTimeout(() => { streamFinish() }, 120_000) // Send a compatibility shape for attachment parsing. // Different server/channel versions read different keys. const payloadAttachments = normalizedAttachments.map((attachment) => { const mimeType = normalizeMimeType(attachment.contentType) || readDataUrlMimeType(attachment.dataUrl) const isImage = isImageMimeType(mimeType) // For text/file attachments, dataUrl holds raw text (not a base64 data URL). // We must base64-encode it so the server can build a valid data: URI. const rawDataUrl = attachment.dataUrl ?? '' let encodedContent: string let finalDataUrl: string if (!isImage && !rawDataUrl.startsWith('data:')) { encodedContent = btoa(unescape(encodeURIComponent(rawDataUrl))) finalDataUrl = mimeType ? `data:${mimeType};base64,${encodedContent}` : `data:text/plain;base64,${encodedContent}` } else { encodedContent = stripDataUrlPrefix(rawDataUrl) finalDataUrl = rawDataUrl } return { id: attachment.id, name: attachment.name, fileName: attachment.name, contentType: mimeType || undefined, mimeType: mimeType || undefined, mediaType: mimeType || undefined, type: isImage ? 'image' : 'file', content: encodedContent, data: encodedContent, base64: encodedContent, dataUrl: finalDataUrl, size: attachment.size, } }) const history = buildPortableHistory(finalDisplayMessages) try { streamStart() } catch (e) { if (import.meta.env.DEV) { console.warn('[chat] streamStart error (non-fatal):', e) } } void startStreaming({ sessionKey, friendlyId, message: enrichedBody, history, attachments: payloadAttachments.length > 0 ? payloadAttachments : undefined, thinking: currentThinkingLevel === 'off' ? undefined : currentThinkingLevel, fastMode, model: currentModel || undefined, idempotencyKey: optimisticClientId || crypto.randomUUID(), }).catch((err: unknown) => { const messageText = err instanceof Error ? err.message : String(err) if (import.meta.env.DEV) { console.warn('[chat] send-stream failed', messageText) } }) }, [ finalDisplayMessages, clearCompletedStreaming, queryClient, setLocalActivity, startStreaming, streamFinish, streamStart, currentModel, ], ) useLayoutEffect(() => { if (isNewChat) return const pending = consumePendingSend( isPortableMode ? 'main' : forcedSessionKey || resolvedSessionKey || activeSessionKey, portableChatFriendlyId, ) if (!pending) return pendingStartRef.current = true const historyKey = chatQueryKeys.history( pending.friendlyId, pending.sessionKey, ) const cached = queryClient.getQueryData(historyKey) const cachedMessages = Array.isArray((cached as any)?.messages) ? (cached as any).messages : [] const alreadyHasOptimistic = cachedMessages.some((message: any) => { if (pending.optimisticMessage.clientId) { if (message.clientId === pending.optimisticMessage.clientId) return true if (message.__optimisticId === pending.optimisticMessage.clientId) return true } if (pending.optimisticMessage.__optimisticId) { if (message.__optimisticId === pending.optimisticMessage.__optimisticId) return true } return false }) if (!alreadyHasOptimistic) { appendHistoryMessage( queryClient, pending.friendlyId, pending.sessionKey, pending.optimisticMessage, ) } setWaitingForResponse(true) sendMessage( pending.sessionKey, pending.friendlyId, pending.message, pending.attachments, false, true, typeof pending.optimisticMessage.clientId === 'string' ? pending.optimisticMessage.clientId : '', ) }, [ activeSessionKey, forcedSessionKey, isNewChat, isPortableMode, portableChatFriendlyId, queryClient, resolvedSessionKey, sendMessage, ]) const retryQueuedMessage = useCallback( function retryQueuedMessage(message: ChatMessage, mode: 'manual' | 'auto') { if (!isRetryableQueuedMessage(message)) return false const body = textFromMessage(message).trim() const attachments = getMessageRetryAttachments(message) if (body.length === 0 && attachments.length === 0) return false const retryKey = getRetryMessageKey(message) if ( mode === 'auto' && retriedQueuedMessageKeysRef.current.has(retryKey) ) { return false } const sessionKeyForSend = isPortableMode ? 'main' : forcedSessionKey || resolvedSessionKey || activeSessionKey || 'main' const sessionKeyForMessage = sessionKeyForHistory || sessionKeyForSend const existingClientId = getMessageClientId(message) if (existingClientId) { updateHistoryMessageByClientId( queryClient, portableChatFriendlyId, sessionKeyForMessage, existingClientId, function markSending(currentMessage) { return { ...currentMessage, status: 'sending' } }, ) updateHistoryMessageByClientIdEverywhere( queryClient, existingClientId, function markSendingEverywhere(currentMessage) { return { ...currentMessage, status: 'sending' } }, ) } if (mode === 'auto') { retriedQueuedMessageKeysRef.current.add(retryKey) } sendMessage( sessionKeyForSend, portableChatFriendlyId, body, attachments, false, true, existingClientId, ) return true }, [ activeSessionKey, forcedSessionKey, isPortableMode, portableChatFriendlyId, queryClient, resolvedSessionKey, sessionKeyForHistory, sendMessage, ], ) const flushRetryableMessages = useCallback( function flushRetryableMessages() { for (const message of finalDisplayMessages) { retryQueuedMessage(message, 'auto') } }, [finalDisplayMessages, retryQueuedMessage], ) const handleRetryMessage = useCallback( function handleRetryMessage(message: ChatMessage) { const retryKey = getRetryMessageKey(message) retriedQueuedMessageKeysRef.current.delete(retryKey) retryQueuedMessage(message, 'manual') }, [retryQueuedMessage], ) useEffect(() => { if (false) { // Server connection checks removed — Hermes Agent uses direct API hasSeenDisconnectRef.current = true retriedQueuedMessageKeysRef.current.clear() return } if (connectionState === 'connected' && hasSeenDisconnectRef.current) { hasSeenDisconnectRef.current = false flushRetryableMessages() } }, [connectionState, flushRetryableMessages]) useEffect(() => { if (statusError) { hadErrorRef.current = true retriedQueuedMessageKeysRef.current.clear() return } const isHealthy = statusQuery.data?.ok === true if (isHealthy && hadErrorRef.current) { hadErrorRef.current = false flushRetryableMessages() } }, [flushRetryableMessages, statusError, statusQuery.data]) useEffect(() => { function handleHealthRestored() { retriedQueuedMessageKeysRef.current.clear() hadErrorRef.current = false flushRetryableMessages() handleRefetch() } window.addEventListener('claude:health-restored', handleHealthRestored) return () => { window.removeEventListener('claude:health-restored', handleHealthRestored) } }, [flushRetryableMessages, handleRefetch]) const createSessionForMessage = useCallback( async (preferredFriendlyId?: string) => { setCreatingSession(true) try { const res = await fetch('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify( preferredFriendlyId && preferredFriendlyId.trim().length > 0 ? { friendlyId: preferredFriendlyId } : {}, ), }) if (!res.ok) throw new Error(await readError(res)) const data = (await res.json()) as { sessionKey?: string friendlyId?: string } const sessionKey = typeof data.sessionKey === 'string' ? data.sessionKey : '' const friendlyId = typeof data.friendlyId === 'string' && data.friendlyId.trim().length > 0 ? data.friendlyId.trim() : (preferredFriendlyId?.trim() ?? '') || deriveFriendlyIdFromKey(sessionKey) if (!sessionKey || !friendlyId) { throw new Error('Invalid session response') } queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions }) return { sessionKey, friendlyId } } finally { setCreatingSession(false) } }, [queryClient], ) const upsertSessionInCache = useCallback( (friendlyId: string, lastMessage: ChatMessage) => { if (!friendlyId) return queryClient.setQueryData( chatQueryKeys.sessions, function upsert(existing: unknown) { const sessions = Array.isArray(existing) ? (existing as Array) : [] const now = Date.now() const existingIndex = sessions.findIndex((session) => { return ( session.friendlyId === friendlyId || session.key === friendlyId ) }) if (existingIndex === -1) { return [ { key: friendlyId, friendlyId, updatedAt: now, lastMessage, titleStatus: 'idle', }, ...sessions, ] } return sessions.map((session, index) => { if (index !== existingIndex) return session return { ...session, updatedAt: now, lastMessage, } }) }, ) }, [queryClient], ) const scrollChatToBottom = useCallback( (behavior: ScrollBehavior = 'smooth') => { const viewport = document.querySelector('[data-chat-scroll-viewport]') if (viewport) { viewport.scrollTo({ top: viewport.scrollHeight, behavior }) } }, [], ) const handleUiSlashCommand = useCallback( (command: string) => { const trimmedCommand = command.trim() if (!trimmedCommand.startsWith('/')) return false if (trimmedCommand === '/new') { // Use the explicit 'new' session sentinel rather than '/chat' alone. // The /chat index route redirects to the last-active session via // localStorage, so navigating to '/chat' would land in the previous // chat instead of opening a fresh one. See #300. navigate({ to: '/chat/$sessionKey', params: { sessionKey: 'new' } }) return true } if (trimmedCommand === '/clear') { const sessionKey = forcedSessionKey || resolvedSessionKey || activeSessionKey || activeFriendlyId clearHistoryMessages(queryClient, activeFriendlyId, sessionKey) toast('Chat cleared', { type: 'success' }) return true } if (trimmedCommand === '/model' || trimmedCommand === '/skin') { window.dispatchEvent( new CustomEvent(CHAT_OPEN_SETTINGS_EVENT, { detail: { section: trimmedCommand === '/skin' ? 'appearance' : 'claude', }, }), ) return true } if (trimmedCommand === '/skills') { navigate({ to: '/skills' }) return true } if (trimmedCommand === '/save') { const exported = exportConversationTranscript({ sessionLabel: activeFriendlyId || 'conversation', messages: finalDisplayMessages, }) if (exported) { toast('Conversation exported', { type: 'success' }) } return true } return false }, [ activeFriendlyId, activeSessionKey, finalDisplayMessages, forcedSessionKey, navigate, queryClient, resolvedSessionKey, ], ) const send = useCallback( ( body: string, attachments: Array, fastMode: boolean, helpers: ChatComposerHelpers, ) => { const trimmedBody = body.trim() if (trimmedBody.length === 0 && attachments.length === 0) return if (attachments.length === 0 && handleUiSlashCommand(trimmedBody)) return // Deduplicate sends with identical content within a 500ms window. // This prevents double-fire from paste events that trigger multiple send paths. const sendKey = `${trimmedBody}|${attachments.map((a) => `${a.name}:${a.size}`).join(',')}` const now = Date.now() if ( sendKey === lastSendKeyRef.current && now - lastSendAtRef.current < 500 ) return lastSendKeyRef.current = sendKey lastSendAtRef.current = now // Haptic feedback on mobile when message is sent if (isMobile) hapticTap() helpers.reset() // Scroll to bottom immediately so user sees their message + incoming response requestAnimationFrame(() => scrollChatToBottom('smooth')) const attachmentPayload: Array = attachments.map( (attachment) => ({ ...attachment, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety id: attachment.id ?? crypto.randomUUID(), }), ) if (isNewChat) { // In portable mode, use 'main' — no server-side sessions exist. // In enhanced mode, create a UUID thread for the sessions API. const threadId = isPortableMode ? 'main' : crypto.randomUUID() const { optimisticMessage } = createOptimisticMessage( trimmedBody, attachmentPayload, ) appendHistoryMessage(queryClient, threadId, threadId, optimisticMessage) upsertSessionInCache(threadId, optimisticMessage) setPendingGeneration(true) setSending(true) setWaitingForResponse(true) if (!isPortableMode) { void createSessionForMessage(threadId).catch((err: unknown) => { if (import.meta.env.DEV) { console.warn('[chat] failed to register new thread', err) } void queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions, }) }) } sendMessage( threadId, threadId, trimmedBody, attachmentPayload, fastMode, true, typeof optimisticMessage.clientId === 'string' ? optimisticMessage.clientId : '', ) // In portable mode, navigate to /chat/main instead of UUID if (!embedded) { navigate({ to: '/chat/$sessionKey', params: { sessionKey: threadId }, replace: true, }) } return } const sessionKeyForSend = isPortableMode ? 'main' : forcedSessionKey || resolvedSessionKey || activeSessionKey || 'main' sendMessage( sessionKeyForSend, isPortableMode ? 'main' : activeFriendlyId, trimmedBody, attachmentPayload, fastMode, ) }, [ activeFriendlyId, activeSessionKey, createSessionForMessage, forcedSessionKey, isNewChat, navigate, onSessionResolved, scrollChatToBottom, sendMessage, upsertSessionInCache, queryClient, resolvedSessionKey, handleUiSlashCommand, ], ) const handleAbortStreaming = useCallback(() => { const activeSend = activeSendRef.current if (activeSend?.clientId) { updateHistoryMessageByClientIdEverywhere( queryClient, activeSend.clientId, (message) => ({ ...message, status: 'sent', }), ) } activeSendRef.current = null cancelStreaming() setSending(false) setPendingGeneration(false) setWaitingForResponse(false) }, [cancelStreaming, queryClient]) const runPaletteSlashCommand = useCallback( (command: string) => { const trimmedCommand = command.trim() if (!trimmedCommand.startsWith('/')) return if (handleUiSlashCommand(trimmedCommand)) return send(trimmedCommand, [], false, commandHelpers) }, [commandHelpers, handleUiSlashCommand, send], ) useEffect(() => { function handleRunCommand(event: Event) { const detail = (event as CustomEvent).detail if (!detail?.command) return runPaletteSlashCommand(detail.command) } window.addEventListener(CHAT_RUN_COMMAND_EVENT, handleRunCommand) return () => { window.removeEventListener(CHAT_RUN_COMMAND_EVENT, handleRunCommand) } }, [runPaletteSlashCommand]) useEffect(() => { function handleSubmitSelection(event: Event) { const detail = (event as CustomEvent).detail const text = detail?.text?.trim() if (!text) return send(text, [], false, commandHelpers) } window.addEventListener(CHAT_SUBMIT_SELECTION_EVENT, handleSubmitSelection) return () => { window.removeEventListener( CHAT_SUBMIT_SELECTION_EVENT, handleSubmitSelection, ) } }, [commandHelpers, send]) useEffect(() => { const pendingCommand = window.sessionStorage.getItem( CHAT_PENDING_COMMAND_STORAGE_KEY, ) if (!pendingCommand) return window.sessionStorage.removeItem(CHAT_PENDING_COMMAND_STORAGE_KEY) runPaletteSlashCommand(pendingCommand) }, [runPaletteSlashCommand]) const toggleSidebar = useWorkspaceStore((s) => s.toggleSidebar) const handleToggleSidebarCollapse = useCallback(() => { toggleSidebar() }, [toggleSidebar]) const handleToggleFileExplorer = useCallback(() => { setFileExplorerCollapsed((prev) => { const next = !prev if (typeof window !== 'undefined') { localStorage.setItem('claude-file-explorer-collapsed', String(next)) } return next }) }, []) useEffect(() => { function handleToggleFileExplorerFromSearch() { handleToggleFileExplorer() } window.addEventListener( SEARCH_MODAL_EVENTS.TOGGLE_FILE_EXPLORER, handleToggleFileExplorerFromSearch, ) window.addEventListener(SIDEBAR_TOGGLE_EVENT, handleToggleSidebarCollapse) return () => { window.removeEventListener( SEARCH_MODAL_EVENTS.TOGGLE_FILE_EXPLORER, handleToggleFileExplorerFromSearch, ) window.removeEventListener( SIDEBAR_TOGGLE_EVENT, handleToggleSidebarCollapse, ) } }, [handleToggleFileExplorer, handleToggleSidebarCollapse]) const handleInsertFileReference = useCallback((reference: string) => { composerHandleRef.current?.insertText(reference) }, []) const historyLoading = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety (historyQuery.isLoading && !historyQuery.data) || isRedirecting const historyEmpty = !historyLoading && finalDisplayMessages.length === 0 const errorNotice = useMemo(() => { if (!showErrorNotice) return null if (!serverError) return null return ( ) }, [serverError, serverErrorStatus, handleRefetch, showErrorNotice]) const mobileHeaderStatus: 'connected' | 'connecting' | 'disconnected' = connectionState === 'connected' ? 'connected' : statusQuery.data?.ok === false || statusQuery.isError ? 'disconnected' : 'connecting' const activeHeaderToolName = liveToolActivity[0]?.name || activeToolCalls[0]?.name || undefined const headerStatusMode: 'idle' | 'sending' | 'streaming' | 'tool' = activeHeaderToolName ? 'tool' : derivedStreamingInfo.isStreaming ? 'streaming' : sending || waitingForResponse ? 'sending' : 'idle' const researchCard = useResearchCard({ sessionKey: resolvedSessionKey || activeCanonicalKey, isStreaming: derivedStreamingInfo.isStreaming, resetKey: `${resolvedSessionKey || activeCanonicalKey || 'main'}:${researchResetKey}`, }) // Pull-to-refresh offset removed const handleOpenAgentDetails = useCallback(() => { // agent view panel removed }, []) const handleRenameActiveSessionTitle = useCallback( async (nextTitle: string) => { const sessionKey = resolvedSessionKey || activeSession?.key || activeSessionKey || '' if (!sessionKey) return await renameSession( sessionKey, activeSession?.friendlyId ?? null, nextTitle, ) }, [ activeSession?.friendlyId, activeSession?.key, activeSessionKey, renameSession, resolvedSessionKey, ], ) // Listen for mobile header agent-details tap useEffect(() => { const handler = () => { /* agent view removed */ } window.addEventListener('claude:chat-agent-details', handler) return () => window.removeEventListener('claude:chat-agent-details', handler) }, []) return (
{hideUi || compact || isFocusMode ? null : isMobile ? null : ( )}
0 ? `${terminalPanelInset}px` : undefined, }} ref={mainRef} > {!compact && ( setSessionsOpen(true)} sessions={sessions ?? []} activeFriendlyId={activeFriendlyId} onSelectSession={(key) => void navigate({ to: '/chat/$sessionKey', params: { sessionKey: key }, }) } showFileExplorerButton={!isMobile && !isFocusMode} fileExplorerCollapsed={fileExplorerCollapsed} onToggleFileExplorer={handleToggleFileExplorer} dataUpdatedAt={historyQuery.dataUpdatedAt} onRefresh={handleRefreshHistory} agentModel={currentModel} agentConnected={mobileHeaderStatus === 'connected'} onOpenAgentDetails={handleOpenAgentDetails} pullOffset={0} statusMode={headerStatusMode} activeToolName={activeHeaderToolName} thinkingLevel={thinkingLevel} isFocusMode={isFocusMode} onToggleFocusMode={handleToggleFocusMode} onUndo={undefined} onClear={undefined} /> )} {errorNotice && (
{errorNotice}
)} {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => (

{'\uD83D\uDD10'} Approval Required -{' '} {approval.agentName || 'Agent'}

{approval.action}

{approval.context ? (

{approval.context.slice(0, 100)}

) : null}
))}
)} {hideUi ? null : ( )} {hideUi ? null : ( { composerHandleRef.current?.setValue(prompt + ' ') }} /> } notice={null} noticePosition="end" waitingForResponse={waitingForResponse} sessionKey={activeCanonicalKey} pinToTop={false} pinGroupMinHeight={pinGroupMinHeight} headerHeight={headerHeight} contentStyle={stableContentStyle} bottomOffset={ isMobile ? mobileScrollBottomOffset : terminalPanelInset } isStreaming={derivedStreamingInfo.isStreaming} streamingMessageId={derivedStreamingInfo.streamingMessageId} streamingText={ stableActiveStreamingText || completedStreamingText.current || undefined } streamingThinking={ realtimeStreamingThinking || completedStreamingThinking.current || undefined } lifecycleEvents={realtimeLifecycleEvents} hideSystemMessages activeToolCalls={activeToolCalls} liveToolActivity={liveToolActivity} researchCard={researchCard} isCompacting={isCompacting} sending={sending} /> )} {showComposer ? ( ) : null}
{!compact && !isFocusMode && }
{!compact && !hideUi && !isMobile && !isFocusMode && } {suggestion && ( )} {isMobile && ( setSessionsOpen(false)} sessions={sessions} activeFriendlyId={activeFriendlyId} onSelectSession={(friendlyId) => { setSessionsOpen(false) void navigate({ to: '/chat/$sessionKey', params: { sessionKey: friendlyId }, }) }} onNewChat={() => { setSessionsOpen(false) void navigate({ to: '/chat/$sessionKey', params: { sessionKey: 'new' }, }) }} /> )}
) }