- hasCompletedToolPhase now checks that the last assistant message in the segment has no tool_use blocks, preventing false positives during intermediate tool rounds that would suppress the trailing thinking indicator - Filter reply text from cached graph steps when a completed run falls back to the step cache, preventing the final response from appearing inside the graph when expanding after completion - Remove debug logging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
717 lines
31 KiB
TypeScript
717 lines
31 KiB
TypeScript
/**
|
|
* 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<typeof deriveTaskSteps>;
|
|
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<string, Record<string, GraphStepCacheEntry>>();
|
|
const streamingTimestampStore = new Map<string, number>();
|
|
|
|
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<Record<string, RawMessage[]>>({});
|
|
// 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<Record<string, boolean>>({});
|
|
const graphStepCache: Record<string, GraphStepCacheEntry> = 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<typeof value> => 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<number>(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<number>();
|
|
|
|
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<typeof value> => 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<number, string>();
|
|
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<string>();
|
|
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 (
|
|
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
|
{/* Toolbar */}
|
|
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
|
<ChatToolbar />
|
|
</div>
|
|
|
|
{/* Messages Area */}
|
|
<div className="min-h-0 flex-1 overflow-hidden px-4 py-4">
|
|
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
|
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
|
<div
|
|
ref={contentRef}
|
|
className={cn(
|
|
"space-y-4 transition-all duration-300",
|
|
isEmpty ? "mx-auto w-full max-w-3xl" : "max-w-4xl",
|
|
)}
|
|
>
|
|
{isEmpty ? (
|
|
<WelcomeScreen />
|
|
) : (
|
|
<>
|
|
{messages.map((msg, idx) => {
|
|
if (foldedNarrationIndices.has(idx)) return null;
|
|
const suppressToolCards = userRunCards.some((card) =>
|
|
idx > card.triggerIndex && idx <= card.segmentEnd,
|
|
);
|
|
return (
|
|
<div
|
|
key={msg.id || `msg-${idx}`}
|
|
className="space-y-3"
|
|
id={`chat-message-${idx}`}
|
|
data-testid={`chat-message-${idx}`}
|
|
>
|
|
<ChatMessage
|
|
message={msg}
|
|
textOverride={replyTextOverrides.get(idx)}
|
|
suppressToolCards={suppressToolCards}
|
|
suppressProcessAttachments={suppressToolCards}
|
|
/>
|
|
{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 (
|
|
<ExecutionGraphCard
|
|
key={`graph-${runKey}`}
|
|
agentLabel={card.agentLabel}
|
|
steps={card.steps}
|
|
active={card.active}
|
|
expanded={expanded}
|
|
onExpandedChange={(next) =>
|
|
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Streaming message */}
|
|
{shouldRenderStreaming && !hasActiveExecutionGraph && (
|
|
<ChatMessage
|
|
message={(() => {
|
|
const base = streamMsg
|
|
? {
|
|
...(streamMsg as Record<string, unknown>),
|
|
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 && (
|
|
<ActivityIndicator phase="tool_processing" />
|
|
)}
|
|
|
|
{/* Typing indicator when sending but no stream content yet */}
|
|
{sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
|
|
<TypingIndicator />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error bar */}
|
|
{error && (
|
|
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
|
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
|
<p className="text-sm text-destructive flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
{error}
|
|
</p>
|
|
<button
|
|
onClick={clearError}
|
|
className="text-xs text-destructive/60 hover:text-destructive underline"
|
|
>
|
|
{t('common:actions.dismiss')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Input Area */}
|
|
<ChatInput
|
|
onSend={sendMessage}
|
|
onStop={abortRun}
|
|
disabled={!isGatewayRunning}
|
|
sending={sending}
|
|
isEmpty={isEmpty}
|
|
/>
|
|
|
|
{/* Transparent loading overlay */}
|
|
{minLoading && !sending && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/20 backdrop-blur-[1px] rounded-xl pointer-events-auto">
|
|
<div className="bg-background shadow-lg rounded-full p-2.5 border border-border">
|
|
<LoadingSpinner size="md" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div className="flex flex-col items-center justify-center text-center h-[60vh]">
|
|
<h1 className="text-4xl md:text-5xl font-serif text-foreground/80 mb-8 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
|
|
{t('welcome.subtitle')}
|
|
</h1>
|
|
|
|
<div className="flex flex-wrap items-center justify-center gap-2.5 max-w-lg w-full">
|
|
{quickActions.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
className="px-4 py-1.5 rounded-full border border-black/10 dark:border-white/10 text-[13px] font-medium text-foreground/70 hover:bg-black/5 dark:hover:bg-white/5 transition-colors bg-black/[0.02]"
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Typing Indicator ────────────────────────────────────────────
|
|
|
|
function TypingIndicator() {
|
|
return (
|
|
<div className="flex gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
|
|
<Sparkles className="h-4 w-4" />
|
|
</div>
|
|
<div className="bg-black/5 dark:bg-white/5 text-foreground rounded-2xl px-4 py-3">
|
|
<div className="flex gap-1">
|
|
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Activity Indicator (shown between tool cycles) ─────────────
|
|
|
|
function ActivityIndicator({ phase }: { phase: 'tool_processing' }) {
|
|
void phase;
|
|
return (
|
|
<div className="flex gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
|
|
<Sparkles className="h-4 w-4" />
|
|
</div>
|
|
<div className="bg-black/5 dark:bg-white/5 text-foreground rounded-2xl px-4 py-3">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
|
<span>Processing tool results…</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Chat;
|