Files
hermes-workspace/src/screens/chat/chat-screen.tsx
2026-06-05 16:56:08 -04:00

2973 lines
97 KiB
TypeScript

// 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/<uuid> 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<ChatMessage>,
): Array<PortableHistoryMessage> {
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<ChatMessage>
}) {
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
const status = normalizeMessageValue(raw.status)
return status === 'error'
}
const commandHelpers: ChatComposerHelpers = {
reset() {},
setValue() {},
setAttachments() {},
}
function getMessageRetryAttachments(
message: ChatMessage,
): Array<ChatAttachment> {
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<string, unknown>).status)
}
function getMessageTimestampValue(message: ChatMessage): number | null {
const raw = message as Record<string, unknown>
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<string, unknown>
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<string | null>(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<number | null>(null)
const failsafeTimerRef = useRef<number | null>(null)
const lastAssistantSignature = useRef('')
const refreshHistoryRef = useRef<() => void>(() => {})
const retriedQueuedMessageKeysRef = useRef(new Set<string>())
const hasSeenDisconnectRef = useRef(false)
const hadErrorRef = useRef(false)
const [pendingApprovals, setPendingApprovals] = useState<
Array<ApprovalRequest>
>([])
const [isCompacting, setIsCompacting] = useState(false)
const [researchResetKey, setResearchResetKey] = useState(0)
// Per-session thinking level — stored in sessionStorage keyed by session
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(() => {
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<ChatComposerHandle | null>(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<string | undefined>(undefined)
const pendingVerifySessionKeyRef = useRef<string | undefined>(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<string, unknown>) => {
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<number | null>(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<string, unknown> }
const agentSection = data?.config?.agent
if (agentSection && typeof agentSection === 'object' && !Array.isArray(agentSection)) {
const effort = (agentSection as Record<string, unknown>).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>(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<string | null>(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<string, unknown>
const bRaw = b as Record<string, unknown>
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<string>()
const seenByText = new Map<string, ChatMessage>()
const dedupedSet = new Set<ChatMessage>()
for (const msg of sortedForDedup) {
const raw = msg as Record<string, unknown>
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<ResponseWaitSnapshot | null>(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<React.CSSProperties>(() => {
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<ChatAttachment> = [],
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<attachment name="${a.name ?? 'file'}">\n${content}\n</attachment>`
})
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<SessionMeta>)
: []
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<ChatComposerAttachment>,
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<ChatAttachment> = 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<ChatRunCommandDetail>).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<ChatSubmitSelectionDetail>).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 (
<ConnectionStatusMessage
state="error"
error={serverError}
status={serverErrorStatus}
onRetry={handleRefetch}
/>
)
}, [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 (
<div
className={cn(
'relative min-w-0 flex flex-col overflow-hidden',
compact ? 'h-full flex-1 min-h-0' : 'h-full',
)}
style={{ background: 'var(--theme-bg)' }}
>
<div
className={cn(
'flex-1 min-h-0 overflow-hidden',
compact
? 'flex min-h-0 w-full flex-col'
: isMobile
? 'flex flex-col'
: 'grid grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[minmax(0,1fr)]',
)}
>
{hideUi || compact || isFocusMode ? null : isMobile ? null : (
<FileExplorerSidebar
collapsed={fileExplorerCollapsed}
onToggle={handleToggleFileExplorer}
onInsertReference={handleInsertFileReference}
/>
)}
<main
className={cn(
'flex h-full flex-1 min-h-0 min-w-0 flex-col overflow-hidden transition-[margin-bottom] duration-200',
(activeIsRealtimeStreaming || hasPendingGeneration()) &&
'chat-streaming-glow',
)}
style={{
marginBottom:
terminalPanelInset > 0 ? `${terminalPanelInset}px` : undefined,
}}
ref={mainRef}
>
{!compact && (
<ChatHeader
activeTitle={activeTitle}
onRenameTitle={handleRenameActiveSessionTitle}
renamingTitle={renamingSessionTitle}
wrapperRef={headerRef}
onOpenSessions={() => 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 && (
<div className="sticky top-0 z-20 px-4 py-2">{errorNotice}</div>
)}
{pendingApprovals.length > 0 && (
<div className="mx-4 mb-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800/50 dark:bg-amber-900/15">
<div className="space-y-2">
{pendingApprovals.map((approval) => (
<div
key={approval.id}
className="flex items-center justify-between gap-3"
>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400">
{'\uD83D\uDD10'} Approval Required -{' '}
{approval.agentName || 'Agent'}
</p>
<p className="mt-0.5 truncate text-xs text-amber-600 dark:text-amber-500">
{approval.action}
</p>
{approval.context ? (
<p className="mt-0.5 truncate text-[10px] font-mono text-amber-500 dark:text-amber-600">
{approval.context.slice(0, 100)}
</p>
) : null}
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => {
void resolvePendingApproval(approval, 'approved')
}}
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-600"
>
Approve
</button>
<button
type="button"
onClick={() => {
void resolvePendingApproval(approval, 'denied')
}}
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 dark:border-red-800/50 dark:bg-red-900/10 dark:text-red-400"
>
Deny
</button>
</div>
</div>
))}
</div>
</div>
)}
{hideUi ? null : (
<ContextBar
sessionId={
resolvedSessionKey ||
activeCanonicalKey ||
activeSession?.key ||
activeSessionKey
}
/>
)}
{hideUi ? null : (
<ChatMessageList
messages={finalDisplayMessages}
onRetryMessage={handleRetryMessage}
onRefresh={handleRefreshHistory}
loading={historyLoading}
empty={historyEmpty}
emptyState={
<ChatEmptyState
compact={compact}
onSuggestionClick={(prompt) => {
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 ? (
<ChatComposer
onSubmit={send}
onAbort={handleAbortStreaming}
isLoading={sending || waitingForResponse}
disabled={sending || hideUi}
sessionKey={
isNewChat
? undefined
: forcedSessionKey ||
resolvedSessionKey ||
activeCanonicalKey ||
activeSessionKey
}
wrapperRef={composerRef}
composerRef={composerHandleRef}
embedded={embedded}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety
focusKey={`${isNewChat ? 'new' : activeFriendlyId}:${activeCanonicalKey ?? ''}`}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={handleThinkingLevelChange}
/>
) : null}
</main>
{!compact && !isFocusMode && <AgentViewPanel />}
</div>
{!compact && !hideUi && !isMobile && !isFocusMode && <TerminalPanel />}
{suggestion && (
<ModelSuggestionToast
suggestedModel={suggestion.suggestedModel}
reason={suggestion.reason}
costImpact={suggestion.costImpact}
onSwitch={handleSwitchModel}
onDismiss={dismiss}
onDismissForSession={dismissForSession}
/>
)}
{isMobile && (
<MobileSessionsPanel
open={sessionsOpen}
onClose={() => 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' },
})
}}
/>
)}
<ContextAlertModal
open={alertOpen}
onClose={dismissAlert}
threshold={alertThreshold}
contextPercent={alertPercent}
/>
<ErrorToastContainer />
</div>
)
}