Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions electron/gateway/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,27 +1146,41 @@ export class GatewayManager extends EventEmitter {
return;
}

// Emit generic message for other handlers
this.emit('message', message);
}

/**
* Handle OpenClaw protocol events
*/
private handleProtocolEvent(event: string, payload: unknown): void {
// Map OpenClaw events to our internal event types
switch (event) {
case 'tick':
// Heartbeat tick, ignore
break;
case 'chat':
this.emit('chat:message', { message: payload });
break;
case 'agent': {
// Agent events may carry chat streaming data inside payload.data,
// or be lifecycle events (phase=started/completed) with no message.
const p = payload as Record<string, unknown>;
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
const chatEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
if (chatEvent.state || chatEvent.message) {
this.emit('chat:message', { message: chatEvent });
}
this.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
this.emit('channel:status', payload as { channelId: string; status: string });
break;
default:
// Forward unknown events as generic notifications
this.emit('notification', { method: event, params: payload });
}
}
Expand Down
61 changes: 33 additions & 28 deletions src/pages/Chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* with markdown, thinking sections, images, and tool cards.
*/
import { useState, useCallback, useEffect, memo } from 'react';
import { User, Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn } from 'lucide-react';
import { User, Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { createPortal } from 'react-dom';
Expand Down Expand Up @@ -51,16 +51,15 @@ export const ChatMessage = memo(function ChatMessage({
const images = extractImages(message);
const tools = extractToolUse(message);
const visibleThinking = showThinking ? thinking : null;
const visibleTools = showThinking ? tools : [];
const visibleTools = tools;

const attachedFiles = message._attachedFiles || [];
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);

// Never render tool result messages in chat UI
if (isToolResult) return null;

// Don't render empty messages (also keep messages with streaming tool status)
const hasStreamingToolStatus = showThinking && isStreaming && streamingTools.length > 0;
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
if (!hasText && !visibleThinking && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;

return (
Expand Down Expand Up @@ -89,7 +88,7 @@ export const ChatMessage = memo(function ChatMessage({
isUser ? 'items-end' : 'items-start',
)}
>
{showThinking && isStreaming && !isUser && streamingTools.length > 0 && (
{isStreaming && !isUser && streamingTools.length > 0 && (
<ToolStatusBar tools={streamingTools} />
)}

Expand Down Expand Up @@ -266,28 +265,33 @@ function ToolStatusBar({
}>;
}) {
return (
<div className="w-full rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<div className="space-y-1">
{tools.map((tool) => {
const duration = formatDuration(tool.durationMs);
const statusLabel = tool.status === 'running' ? 'running' : (tool.status === 'error' ? 'error' : 'done');
return (
<div key={tool.toolCallId || tool.id || tool.name} className="flex flex-wrap items-center gap-2">
<span className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]',
tool.status === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-foreground/5 text-muted-foreground',
)}>
<span className="font-mono">{tool.name}</span>
<span className="opacity-70">{statusLabel}</span>
</span>
{duration && <span className="text-[11px] opacity-70">{duration}</span>}
{tool.summary && (
<span className="truncate text-[11px]">{tool.summary}</span>
)}
</div>
);
})}
</div>
<div className="w-full space-y-1">
{tools.map((tool) => {
const duration = formatDuration(tool.durationMs);
const isRunning = tool.status === 'running';
const isError = tool.status === 'error';
return (
<div
key={tool.toolCallId || tool.id || tool.name}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
isRunning && 'border-primary/30 bg-primary/5 text-foreground',
!isRunning && !isError && 'border-border/50 bg-muted/20 text-muted-foreground',
isError && 'border-destructive/30 bg-destructive/5 text-destructive',
)}
>
{isRunning && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />}
{!isRunning && !isError && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{isError && <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
<span className="font-mono text-[12px] font-medium">{tool.name}</span>
{duration && <span className="text-[11px] opacity-60">{duration}</span>}
{tool.summary && (
<span className="truncate text-[11px] opacity-70">{tool.summary}</span>
)}
</div>
);
})}
</div>
);
}
Expand Down Expand Up @@ -595,7 +599,8 @@ function ToolCard({ name, input }: { name: string; input: unknown }) {
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
<Wrench className="h-3.5 w-3.5" />
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
<span className="font-mono text-xs">{name}</span>
{expanded ? <ChevronDown className="h-3 w-3 ml-auto" /> : <ChevronRight className="h-3 w-3 ml-auto" />}
</button>
Expand Down
41 changes: 33 additions & 8 deletions src/pages/Chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* are in the toolbar; messages render with markdown + streaming.
*/
import { useEffect, useRef, useState } from 'react';
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
import { AlertCircle, Bot, Loader2, MessageSquare, Sparkles } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
Expand All @@ -28,6 +28,7 @@ export function Chat() {
const showThinking = useChatStore((s) => s.showThinking);
const streamingMessage = useChatStore((s) => s.streamingMessage);
const streamingTools = useChatStore((s) => s.streamingTools);
const pendingFinal = useChatStore((s) => s.pendingFinal);
const loadHistory = useChatStore((s) => s.loadHistory);
const loadSessions = useChatStore((s) => s.loadSessions);
const sendMessage = useChatStore((s) => s.sendMessage);
Expand All @@ -51,10 +52,10 @@ export function Chat() {
};
}, [isGatewayRunning, loadHistory, loadSessions]);

// Auto-scroll on new messages or streaming
// Auto-scroll on new messages, streaming, or activity changes
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage, sending]);
}, [messages, streamingMessage, sending, pendingFinal]);

// Update timestamp when sending starts
useEffect(() => {
Expand All @@ -79,7 +80,6 @@ export function Chat() {
);
}

// Extract streaming text for display
const streamMsg = streamingMessage && typeof streamingMessage === 'object'
? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
: null;
Expand All @@ -88,11 +88,12 @@ export function Chat() {
const streamThinking = streamMsg ? extractThinking(streamMsg) : null;
const hasStreamThinking = showThinking && !!streamThinking && streamThinking.trim().length > 0;
const streamTools = streamMsg ? extractToolUse(streamMsg) : [];
const hasStreamTools = showThinking && streamTools.length > 0;
const hasStreamTools = streamTools.length > 0;
const streamImages = streamMsg ? extractImages(streamMsg) : [];
const hasStreamImages = streamImages.length > 0;
const hasStreamToolStatus = showThinking && streamingTools.length > 0;
const hasStreamToolStatus = streamingTools.length > 0;
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;

return (
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}>
Expand Down Expand Up @@ -141,8 +142,13 @@ export function Chat() {
/>
)}

{/* Typing indicator when sending but no stream yet */}
{sending && !hasStreamText && !hasStreamThinking && !hasStreamTools && !hasStreamImages && !hasStreamToolStatus && (
{/* Activity indicator: waiting for next AI turn after tool execution */}
{sending && pendingFinal && !shouldRenderStreaming && (
<ActivityIndicator phase="tool_processing" />
)}

{/* Typing indicator when sending but no stream content yet */}
{sending && !pendingFinal && !hasAnyStreamContent && (
<TypingIndicator />
)}
</>
Expand Down Expand Up @@ -233,4 +239,23 @@ function TypingIndicator() {
);
}

// ── 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 bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
<Sparkles className="h-4 w-4" />
</div>
<div className="bg-muted 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;
Loading