Files
NianToB/src/pages/Chat/index.tsx
Haze 12d7c8ade0 fix(chat): refine tool phase detection and clean stale reply from graph cache
- 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>
2026-04-20 17:09:20 +08:00

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;