diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index eb747ad..0fff875 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock'; import { ToolCall } from './ToolCall'; import { ImageBlock } from './ImageBlock'; import { buildImageSrc } from '../lib/image'; -import { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react'; +import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap } from 'lucide-react'; import { t, getLocale } from '../lib/i18n'; import { useLocale } from '../hooks/useLocale'; // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage @@ -244,10 +244,36 @@ function getPlainText(message: ChatMessageType): string { return message.content; } +/** System event displayed as a subtle inline notification */ +function SystemEventMessage({ message }: { message: ChatMessageType }) { + const text = message.content || getTextBlocks(message.blocks).map(b => (b as Extract).text).join(' '); + // Trim leading brackets like [cron:xxx] or [EVENT] for cleaner display + const display = text.replace(/^\[.*?\]\s*/, '').trim() || text.trim(); + const label = text.match(/^\[([^\]]+)\]/)?.[1] || 'system'; + + return ( +
+
+ + {label} + {display} + {message.timestamp && ( + {formatTimestamp(message.timestamp)} + )} +
+
+ ); +} + export function ChatMessageComponent({ message, onRetry }: { message: ChatMessageType; onRetry?: (text: string) => void }) { useLocale(); // re-render on locale change const isUser = message.role === 'user'; + // System events render as subtle inline notifications + if (message.isSystemEvent) { + return ; + } + // Assistant message with no text content — only tool calls / thinking if (!isUser && message.blocks.length > 0) { const textBlocks = getTextBlocks(message.blocks); diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index ab6aca7..6c1b745 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { GatewayClient, type JsonPayload } from '../lib/gateway'; import { genIdempotencyKey } from '../lib/utils'; import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials'; +import { isSystemEvent } from '../lib/systemEvent'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types'; interface ChatPayloadMessage { @@ -152,12 +153,14 @@ export function useGateway() { }; } + const textContent = blocks.filter((b): b is Extract => b.type === 'text').map(b => b.text).join(''); return { id: m.id || `hist-${i}`, role, - content: blocks.filter((b): b is Extract => b.type === 'text').map(b => b.text).join(''), + content: textContent, timestamp: m.timestamp || Date.now(), blocks, + isSystemEvent: role === 'user' && isSystemEvent(textContent), }; }); const merged: ChatMessage[] = []; diff --git a/src/lib/systemEvent.ts b/src/lib/systemEvent.ts new file mode 100644 index 0000000..5e47224 --- /dev/null +++ b/src/lib/systemEvent.ts @@ -0,0 +1,29 @@ +/** + * Detect whether a user-role message is actually a system event + * (heartbeat, webhook, cron, channel event, etc.) rather than + * a real human message. + */ + +const SYSTEM_PATTERNS: RegExp[] = [ + // Explicit markers + /^\[EVENT\b/i, + /\[from:\s*[^\]]*\(system\)\]/i, + /^\[HEARTBEAT\b/i, + /^\[cron:/i, + /^\[hook:/i, + /^\[webhook:/i, + /^\[sms-inbound\b/i, + /^\[teamspeak\b/i, + + // Heartbeat prompt pattern (the standard OpenClaw heartbeat) + /^Read HEARTBEAT\.md if it exists/, + + // System event envelope: [source:xxx] + /^\[source:\s*\w+\]/i, +]; + +export function isSystemEvent(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + return SYSTEM_PATTERNS.some(pat => pat.test(trimmed)); +} diff --git a/src/types/index.ts b/src/types/index.ts index 42d64dd..1d5779e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export interface ChatMessage { blocks: MessageBlock[]; isStreaming?: boolean; runId?: string; + isSystemEvent?: boolean; } export type MessageBlock =