feat: add message metadata viewer on hover
Small info button appears on hover of each message bubble. Click to expand a panel showing raw message metadata (id, role, timestamp, channel, sender info, etc.) from the gateway. Useful for debugging and understanding message routing. Collapsed by default, doesn't clutter the UI. Closes feedback item #39
This commit is contained in:
11
FEEDBACK.md
11
FEEDBACK.md
@@ -479,3 +479,14 @@
|
||||
- Could use a lighter background (slightly brighter than assistant messages) or a colored left border
|
||||
- Keep it subtle but clearly distinguishable
|
||||
- Test against the zinc dark theme to make sure it's readable for keratoconus (no harsh contrast)
|
||||
|
||||
## Item #45
|
||||
- **Date:** 2026-02-12
|
||||
- **Priority:** high
|
||||
- **Status:** pending
|
||||
- **Description:** Display the agent's avatar when set in OpenClaw config
|
||||
- OpenClaw gateway can provide an avatar URL for the agent (configured in openclaw.json)
|
||||
- PinchChat should display this avatar next to assistant messages instead of the default Bot icon
|
||||
- Check the gateway WebSocket session/handshake data for avatar info
|
||||
- Fallback to the current default icon if no avatar is configured
|
||||
- Should also appear in the header next to the agent name
|
||||
|
||||
@@ -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, Zap } from 'lucide-react';
|
||||
import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap, Info } from 'lucide-react';
|
||||
import { t, getLocale } from '../lib/i18n';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
||||
@@ -240,6 +240,34 @@ function CopyButton({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataViewer({ metadata }: { metadata?: Record<string, unknown> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!metadata || Object.keys(metadata).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="h-7 w-7 rounded-lg border border-white/8 bg-zinc-800/80 backdrop-blur-sm flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:border-cyan-400/30 transition-all opacity-0 group-hover:opacity-100"
|
||||
title={t('message.metadata')}
|
||||
aria-label={t('message.metadata')}
|
||||
>
|
||||
<Info size={13} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute bottom-9 left-0 z-50 w-72 max-h-64 overflow-auto rounded-xl border border-white/10 bg-zinc-900/95 backdrop-blur-md shadow-xl p-3 text-[11px] text-zinc-400 font-mono leading-relaxed custom-scrollbar">
|
||||
{Object.entries(metadata).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2 py-0.5">
|
||||
<span className="text-cyan-400/70 shrink-0">{k}:</span>
|
||||
<span className="text-zinc-300 break-all">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract plain text from message blocks for clipboard copy */
|
||||
function getPlainText(message: ChatMessageType): string {
|
||||
if (message.blocks.length > 0) {
|
||||
@@ -305,10 +333,13 @@ export function ChatMessageComponent({ message, onRetry }: { message: ChatMessag
|
||||
? 'bg-gradient-to-b from-cyan-800/40 to-cyan-900/25 text-zinc-100 border border-cyan-400/30'
|
||||
: 'bg-zinc-800/40 text-zinc-300 border border-white/8 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
||||
}`}>
|
||||
{/* Copy button (assistant messages only) */}
|
||||
{/* Action buttons */}
|
||||
{!isUser && !message.isStreaming && getPlainText(message).trim() && (
|
||||
<CopyButton text={getPlainText(message)} />
|
||||
)}
|
||||
<div className={`absolute top-2 ${isUser ? 'left-2' : 'right-10'} flex gap-1 opacity-0 group-hover:opacity-100 transition-all`}>
|
||||
<MetadataViewer metadata={message.metadata} />
|
||||
</div>
|
||||
{/* Retry button (user messages only) */}
|
||||
{isUser && onRetry && (
|
||||
<button
|
||||
|
||||
@@ -171,12 +171,19 @@ export function useGateway() {
|
||||
}
|
||||
|
||||
const textContent = blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('');
|
||||
// Capture raw metadata (exclude heavy fields already parsed)
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (['content', 'blocks'].includes(k)) continue;
|
||||
metadata[k] = v;
|
||||
}
|
||||
return {
|
||||
id: m.id || `hist-${i}`,
|
||||
role,
|
||||
content: textContent,
|
||||
timestamp: m.timestamp || Date.now(),
|
||||
blocks,
|
||||
metadata,
|
||||
isSystemEvent: role === 'user' && isSystemEvent(textContent),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ const en = {
|
||||
'message.copy': 'Copy message',
|
||||
'message.copied': 'Copied!',
|
||||
'message.retry': 'Resend message',
|
||||
'message.metadata': 'Message details',
|
||||
|
||||
// Timestamps
|
||||
'time.yesterday': 'Yesterday',
|
||||
@@ -160,6 +161,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'message.copy': 'Copier le message',
|
||||
'message.copied': 'Copié !',
|
||||
'message.retry': 'Renvoyer le message',
|
||||
'message.metadata': 'Détails du message',
|
||||
|
||||
'time.yesterday': 'Hier',
|
||||
'time.today': "Aujourd'hui",
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ChatMessage {
|
||||
isStreaming?: boolean;
|
||||
runId?: string;
|
||||
isSystemEvent?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type MessageBlock =
|
||||
|
||||
Reference in New Issue
Block a user