/** * Chat Page * Native React implementation communicating with OpenClaw Gateway * via gateway:rpc IPC. Session selector, thinking toggle, and refresh * are in the toolbar; messages render with markdown + streaming. */ import { useEffect, useMemo, useState } from 'react'; import { AlertCircle, Loader2, Sparkles } from 'lucide-react'; import { useChatStore, type RawMessage } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; import { useAgentsStore } from '@/stores/agents'; import { hostApiFetch } from '@/lib/host-api'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { ChatMessage } from './ChatMessage'; import { ChatInput } from './ChatInput'; import { ExecutionGraphCard } from './ExecutionGraphCard'; import { ChatToolbar } from './ChatToolbar'; import { extractImages, extractText, extractThinking, extractToolUse, stripProcessMessagePrefix } from './message-utils'; import { deriveTaskSteps, findReplyMessageIndex, parseSubagentCompletionInfo, type TaskStep } from './task-visualization'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant'; import { useMinLoading } from '@/hooks/use-min-loading'; type GraphStepCacheEntry = { steps: ReturnType; agentLabel: string; sessionLabel: string; segmentEnd: number; replyIndex: number | null; triggerIndex: number; }; type UserRunCard = { triggerIndex: number; replyIndex: number | null; active: boolean; agentLabel: string; sessionLabel: string; segmentEnd: number; steps: TaskStep[]; messageStepTexts: string[]; streamingReplyText: string | null; }; function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] { return steps .filter((step) => step.kind === 'message' && step.parentId === 'agent-run' && !!step.detail) .map((step) => step.detail!); } // Keep the last non-empty execution-graph snapshot per session/run outside // React state so `loadHistory` refreshes can still fall back to the previous // steps without tripping React's set-state-in-effect lint rule. const graphStepCacheStore = new Map>(); const streamingTimestampStore = new Map(); export function Chat() { const { t } = useTranslation('chat'); const gatewayStatus = useGatewayStore((s) => s.status); const isGatewayRunning = gatewayStatus.state === 'running'; const messages = useChatStore((s) => s.messages); const currentSessionKey = useChatStore((s) => s.currentSessionKey); const currentAgentId = useChatStore((s) => s.currentAgentId); const sessionLabels = useChatStore((s) => s.sessionLabels); const loading = useChatStore((s) => s.loading); const sending = useChatStore((s) => s.sending); const error = useChatStore((s) => s.error); const streamingMessage = useChatStore((s) => s.streamingMessage); const streamingTools = useChatStore((s) => s.streamingTools); const pendingFinal = useChatStore((s) => s.pendingFinal); const sendMessage = useChatStore((s) => s.sendMessage); const abortRun = useChatStore((s) => s.abortRun); const clearError = useChatStore((s) => s.clearError); const fetchAgents = useAgentsStore((s) => s.fetchAgents); const agents = useAgentsStore((s) => s.agents); const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession); const [childTranscripts, setChildTranscripts] = useState>({}); // Persistent per-run override for the Execution Graph's expanded/collapsed // state. Keyed by a stable run id (trigger message id, or a fallback of // `${sessionKey}:${triggerIdx}`) so user toggles survive the `loadHistory` // refresh that runs after every final event — otherwise the card would // remount and reset. `undefined` values mean "user hasn't toggled, let the // card pick a default from its own `active` prop." const [graphExpandedOverrides, setGraphExpandedOverrides] = useState>({}); const graphStepCache: Record = graphStepCacheStore.get(currentSessionKey) ?? {}; const minLoading = useMinLoading(loading && messages.length > 0); const { contentRef, scrollRef } = useStickToBottomInstant(currentSessionKey); // Load data when gateway is running. // When the store already holds messages for this session (i.e. the user // is navigating *back* to Chat), use quiet mode so the existing messages // stay visible while fresh data loads in the background. This avoids // an unnecessary messages → spinner → messages flicker. useEffect(() => { return () => { // If the user navigates away without sending any messages, remove the // empty session so it doesn't linger as a ghost entry in the sidebar. cleanupEmptySession(); }; }, [cleanupEmptySession]); useEffect(() => { void fetchAgents(); }, [fetchAgents]); useEffect(() => { const completions = messages .map((message) => parseSubagentCompletionInfo(message)) .filter((value): value is NonNullable => value != null); const missing = completions.filter((completion) => !childTranscripts[completion.sessionId]); if (missing.length === 0) return; let cancelled = false; void Promise.all( missing.map(async (completion) => { try { const result = await hostApiFetch<{ success: boolean; messages?: RawMessage[] }>( `/api/sessions/transcript?agentId=${encodeURIComponent(completion.agentId)}&sessionId=${encodeURIComponent(completion.sessionId)}`, ); if (!result.success) { console.warn('Failed to load child transcript:', { agentId: completion.agentId, sessionId: completion.sessionId, result, }); return null; } return { sessionId: completion.sessionId, messages: result.messages || [] }; } catch (error) { console.warn('Failed to load child transcript:', { agentId: completion.agentId, sessionId: completion.sessionId, error, }); return null; } }), ).then((results) => { if (cancelled) return; setChildTranscripts((current) => { const next = { ...current }; for (const result of results) { if (!result) continue; next[result.sessionId] = result.messages; } return next; }); }); return () => { cancelled = true; }; }, [messages, childTranscripts]); const streamMsg = streamingMessage && typeof streamingMessage === 'object' ? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number } : null; const streamTimestamp = typeof streamMsg?.timestamp === 'number' ? streamMsg.timestamp : 0; useEffect(() => { if (!sending) { streamingTimestampStore.delete(currentSessionKey); return; } if (!streamingTimestampStore.has(currentSessionKey)) { streamingTimestampStore.set(currentSessionKey, streamTimestamp || Date.now() / 1000); } }, [currentSessionKey, sending, streamTimestamp]); const streamingTimestamp = sending ? (streamingTimestampStore.get(currentSessionKey) ?? streamTimestamp) : 0; const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : ''); const hasStreamText = streamText.trim().length > 0; const streamThinking = streamMsg ? extractThinking(streamMsg) : null; const hasStreamThinking = !!streamThinking && streamThinking.trim().length > 0; const streamTools = streamMsg ? extractToolUse(streamMsg) : []; const hasStreamTools = streamTools.length > 0; const streamImages = streamMsg ? extractImages(streamMsg) : []; const hasStreamImages = streamImages.length > 0; const hasStreamToolStatus = streamingTools.length > 0; const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running'); const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus); const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus; const isEmpty = messages.length === 0 && !sending; const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message)); // Build an index of the *next* real user message after each position. // Gateway history may contain `role: 'user'` messages that are actually // tool-result wrappers (Anthropic API format). These must NOT split // the run into multiple segments — only genuine user-authored messages // should act as run boundaries. const isRealUserMessage = (msg: RawMessage): boolean => { if (msg.role !== 'user') return false; const content = msg.content; if (!Array.isArray(content)) return true; // If every block in the content is a tool_result, this is a Gateway // tool-result wrapper, not a real user message. const blocks = content as Array<{ type?: string }>; return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result'); }; const nextUserMessageIndexes = new Array(messages.length).fill(-1); let nextUserMessageIndex = -1; for (let idx = messages.length - 1; idx >= 0; idx -= 1) { nextUserMessageIndexes[idx] = nextUserMessageIndex; if (isRealUserMessage(messages[idx]) && !subagentCompletionInfos[idx]) { nextUserMessageIndex = idx; } } // Indices of intermediate assistant process messages that are represented // in the ExecutionGraphCard (narration text and/or thinking). We suppress // them from the chat stream so they don't appear duplicated below the graph. const foldedNarrationIndices = new Set(); const userRunCards: UserRunCard[] = messages.flatMap((message, idx) => { if (!isRealUserMessage(message) || subagentCompletionInfos[idx]) return []; const runKey = message.id ? `msg-${message.id}` : `${currentSessionKey}:trigger-${idx}`; const nextUserIndex = nextUserMessageIndexes[idx]; const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex; const segmentMessages = messages.slice(idx + 1, segmentEnd); const completionInfos = subagentCompletionInfos .slice(idx + 1, segmentEnd) .filter((value): value is NonNullable => value != null); const isLatestOpenRun = nextUserIndex === -1 && (sending || pendingFinal || hasAnyStreamContent); const replyIndexOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun); const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset; const buildSteps = (omitLastStreamingMessageSegment: boolean): TaskStep[] => { let builtSteps = deriveTaskSteps({ messages: segmentMessages, streamingMessage: isLatestOpenRun ? streamingMessage : null, streamingTools: isLatestOpenRun ? streamingTools : [], omitLastStreamingMessageSegment: isLatestOpenRun ? omitLastStreamingMessageSegment : false, }); for (const completion of completionInfos) { const childMessages = childTranscripts[completion.sessionId]; if (!childMessages || childMessages.length === 0) continue; const branchRootId = `subagent:${completion.sessionId}`; const childSteps = deriveTaskSteps({ messages: childMessages, streamingMessage: null, streamingTools: [], }).map((step) => ({ ...step, id: `${completion.sessionId}:${step.id}`, depth: step.depth + 1, parentId: branchRootId, })); builtSteps = [ ...builtSteps, { id: branchRootId, label: `${completion.agentId} subagent`, status: 'completed', kind: 'system' as const, detail: completion.sessionKey, depth: 1, parentId: 'agent-run', }, ...childSteps, ]; } return builtSteps; }; // Show the streaming response as a separate bubble (not inside the // execution graph) once all tool calls have finished. // // Three signals indicate "tools finished, now streaming the reply": // 1. `pendingFinal` — set by tool-result final events // 2. `allToolsCompleted` — all entries in streamingTools are completed // 3. `hasCompletedToolPhase` — the Gateway executed tools server-side // (no streaming tool events) and the tool phase is over. Detected // by finding tool_use in historical messages AND the last assistant // message in the segment having no tool_use blocks (meaning the // model has moved past tool calls into the reply phase). const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus; const lastAssistantInSegment = [...segmentMessages].reverse().find((m) => m.role === 'assistant'); const segmentHasTools = segmentMessages.some((msg) => msg.role === 'assistant' && extractToolUse(msg).length > 0, ); const lastAssistantHasNoTools = lastAssistantInSegment != null && extractToolUse(lastAssistantInSegment).length === 0; const hasCompletedToolPhase = segmentHasTools && lastAssistantHasNoTools; const rawStreamingReplyCandidate = isLatestOpenRun && (pendingFinal || allToolsCompleted || hasCompletedToolPhase) && (hasStreamText || hasStreamImages) && streamTools.length === 0 && !hasRunningStreamToolStatus; let steps = buildSteps(rawStreamingReplyCandidate); let streamingReplyText: string | null = null; if (rawStreamingReplyCandidate) { const trimmedReplyText = stripProcessMessagePrefix(streamText, getPrimaryMessageStepTexts(steps)); const hasReplyText = trimmedReplyText.trim().length > 0; if (hasReplyText || hasStreamImages) { streamingReplyText = trimmedReplyText; } else { steps = buildSteps(false); } } const segmentAgentId = currentAgentId; const segmentAgentLabel = agents.find((agent) => agent.id === segmentAgentId)?.name || segmentAgentId; const segmentSessionLabel = sessionLabels[currentSessionKey] || currentSessionKey; if (steps.length === 0) { if (isLatestOpenRun && streamingReplyText == null) { return [{ triggerIndex: idx, replyIndex, active: true, agentLabel: segmentAgentLabel, sessionLabel: segmentSessionLabel, segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1, steps: [], messageStepTexts: [], streamingReplyText: null, }]; } const cached = graphStepCache[runKey]; if (!cached) return []; // When using cached steps for a completed run, filter out message // steps whose text matches the final reply. The cache was captured // during streaming when all text was narration; now that the run is // complete the reply should not appear inside the graph. const cachedReplyIdx = cached.replyIndex; const replyMsg = cachedReplyIdx != null ? messages[cachedReplyIdx] : null; const replyText = replyMsg ? extractText(replyMsg).trim() : ''; const cleanedSteps = replyText ? cached.steps.filter((s) => !(s.kind === 'message' && s.detail?.trim() === replyText)) : cached.steps; return [{ triggerIndex: idx, replyIndex: cached.replyIndex, active: false, agentLabel: cached.agentLabel, sessionLabel: cached.sessionLabel, segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1, steps: cleanedSteps, messageStepTexts: getPrimaryMessageStepTexts(cleanedSteps), streamingReplyText: null, }]; } // Mark intermediate assistant messages whose process output should be folded into // the ExecutionGraphCard. We fold the text regardless of whether the // message ALSO carries tool calls (mixed `text + toolCall` messages are // common — e.g. "waiting for the page to load…" followed by a `wait` // tool call). This prevents orphan narration bubbles from leaking into // the chat stream once the graph is collapsed. // // When the run is still streaming (`isLatestOpenRun`) the final reply is // not yet part of `segmentMessages`, so every assistant message in the // segment counts as intermediate. For completed runs, we preserve the // final reply bubble by skipping the message that `findReplyMessageIndex` // identifies as the answer. const segmentReplyOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun); for (let offset = 0; offset < segmentMessages.length; offset += 1) { if (offset === segmentReplyOffset) continue; const candidate = segmentMessages[offset]; if (!candidate || candidate.role !== 'assistant') continue; const hasNarrationText = extractText(candidate).trim().length > 0; const hasThinking = !!extractThinking(candidate); if (!hasNarrationText && !hasThinking) continue; foldedNarrationIndices.add(idx + 1 + offset); } const cardActive = isLatestOpenRun && streamingReplyText == null; return [{ triggerIndex: idx, replyIndex, active: cardActive, agentLabel: segmentAgentLabel, sessionLabel: segmentSessionLabel, segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1, steps, messageStepTexts: getPrimaryMessageStepTexts(steps), streamingReplyText, }]; }); const hasActiveExecutionGraph = userRunCards.some((card) => card.active); const replyTextOverrides = new Map(); for (const card of userRunCards) { if (card.replyIndex == null) continue; const replyMessage = messages[card.replyIndex]; if (!replyMessage || replyMessage.role !== 'assistant') continue; const fullReplyText = extractText(replyMessage); const trimmedReplyText = stripProcessMessagePrefix(fullReplyText, card.messageStepTexts); if (trimmedReplyText !== fullReplyText) { replyTextOverrides.set(card.replyIndex, trimmedReplyText); } } const streamingReplyText = userRunCards.find((card) => card.streamingReplyText != null)?.streamingReplyText ?? null; // Derive the set of run keys that should be auto-collapsed (run finished // streaming or has a reply override) during render instead of in an effect, // so we don't violate react-hooks/set-state-in-effect. Explicit user toggles // still win via `graphExpandedOverrides` and are merged in at the call site. const autoCollapsedRunKeys = useMemo(() => { const keys = new Set(); for (const card of userRunCards) { // Only auto-collapse after the run is fully complete — not while // the reply is still streaming, otherwise the graph jumps to a // collapsed summary mid-stream. const isStillStreaming = card.streamingReplyText != null; const shouldCollapse = !isStillStreaming && (card.replyIndex != null && replyTextOverrides.has(card.replyIndex)); if (!shouldCollapse) continue; const triggerMsg = messages[card.triggerIndex]; const runKey = triggerMsg?.id ? `msg-${triggerMsg.id}` : `${currentSessionKey}:trigger-${card.triggerIndex}`; keys.add(runKey); } return keys; }, [currentSessionKey, messages, replyTextOverrides, userRunCards]); useEffect(() => { if (userRunCards.length === 0) return; const current = graphStepCacheStore.get(currentSessionKey) ?? {}; let changed = false; const next = { ...current }; for (const card of userRunCards) { if (card.steps.length === 0) continue; const triggerMsg = messages[card.triggerIndex]; const runKey = triggerMsg?.id ? `msg-${triggerMsg.id}` : `${currentSessionKey}:trigger-${card.triggerIndex}`; const existing = current[runKey]; const sameSteps = !!existing && existing.steps.length === card.steps.length && existing.steps.every((step, index) => { const nextStep = card.steps[index]; return nextStep && step.id === nextStep.id && step.label === nextStep.label && step.status === nextStep.status && step.kind === nextStep.kind && step.detail === nextStep.detail && step.depth === nextStep.depth && step.parentId === nextStep.parentId; }); if ( sameSteps && existing?.agentLabel === card.agentLabel && existing?.sessionLabel === card.sessionLabel && existing?.segmentEnd === card.segmentEnd && existing?.replyIndex === card.replyIndex && existing?.triggerIndex === card.triggerIndex ) { continue; } next[runKey] = { steps: card.steps, agentLabel: card.agentLabel, sessionLabel: card.sessionLabel, segmentEnd: card.segmentEnd, replyIndex: card.replyIndex, triggerIndex: card.triggerIndex, }; changed = true; } if (changed) { graphStepCacheStore.set(currentSessionKey, next); } }, [userRunCards, messages, currentSessionKey]); return (
{/* Toolbar */}
{/* Messages Area */}
{isEmpty ? ( ) : ( <> {messages.map((msg, idx) => { if (foldedNarrationIndices.has(idx)) return null; const suppressToolCards = userRunCards.some((card) => idx > card.triggerIndex && idx <= card.segmentEnd, ); return (
{userRunCards .filter((card) => card.triggerIndex === idx) .map((card) => { const triggerMsg = messages[card.triggerIndex]; const runKey = triggerMsg?.id ? `msg-${triggerMsg.id}` : `${currentSessionKey}:trigger-${card.triggerIndex}`; const userOverride = graphExpandedOverrides[runKey]; // Keep the graph expanded while the streaming reply // renders — `active` flips to false once the reply // bubble appears, which would trigger auto-collapse // inside ExecutionGraphCard's uncontrolled path. const isStreamingReply = card.streamingReplyText != null; const expanded = userOverride != null ? userOverride : autoCollapsedRunKeys.has(runKey) ? false : isStreamingReply ? true : undefined; return ( setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next })) } /> ); })}
); })} {/* Streaming message */} {shouldRenderStreaming && !hasActiveExecutionGraph && ( { const base = streamMsg ? { ...(streamMsg as Record), role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'], content: streamMsg.content ?? streamText, timestamp: streamMsg.timestamp ?? streamingTimestamp, } : { role: 'assistant' as const, content: streamText, timestamp: streamingTimestamp, }; // When the reply renders as a separate bubble, strip // thinking blocks from the message — they belong to // the execution phase and are already omitted from // the graph via omitLastStreamingMessageSegment. if (streamingReplyText != null && Array.isArray(base.content)) { return { ...base, content: (base.content as Array<{ type?: string }>).filter( (block) => block.type !== 'thinking', ), } as RawMessage; } return base as RawMessage; })()} textOverride={streamingReplyText ?? undefined} isStreaming streamingTools={streamingReplyText != null ? [] : streamingTools} /> )} {/* Activity indicator: waiting for next AI turn after tool execution */} {sending && pendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && ( )} {/* Typing indicator when sending but no stream content yet */} {sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && ( )} )}
{/* Error bar */} {error && (

{error}

)} {/* Input Area */} {/* Transparent loading overlay */} {minLoading && !sending && (
)}
); } // ── Welcome Screen ────────────────────────────────────────────── function WelcomeScreen() { const { t } = useTranslation('chat'); const quickActions = [ { key: 'askQuestions', label: t('welcome.askQuestions') }, { key: 'creativeTasks', label: t('welcome.creativeTasks') }, { key: 'brainstorming', label: t('welcome.brainstorming') }, ]; return (

{t('welcome.subtitle')}

{quickActions.map(({ key, label }) => ( ))}
); } // ── Typing Indicator ──────────────────────────────────────────── function TypingIndicator() { return (
); } // ── Activity Indicator (shown between tool cycles) ───────────── function ActivityIndicator({ phase }: { phase: 'tool_processing' }) { void phase; return (
Processing tool results…
); } export default Chat;