feat: display agent avatar from OpenClaw identity config

Fetch agent identity via agent.identity.get WS method after connect.
Display avatar in message bubbles (replacing Bot icon) and in the
header (replacing the PinchChat logo when an avatar is configured).
Falls back to default icons when no avatar is set.
This commit is contained in:
Nicolas Varrot
2026-02-12 23:29:32 +00:00
parent 8abeb4d77a
commit da2e4862dd
7 changed files with 95 additions and 13 deletions

View File

@@ -14,7 +14,7 @@ export default function App() {
const {
status, messages, sessions, activeSession, isGenerating, isLoadingHistory,
sendMessage, abort, switchSession, deleteSession,
authenticated, login, logout, connectError, isConnecting,
authenticated, login, logout, connectError, isConnecting, agentIdentity,
} = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -97,10 +97,10 @@ export default function App() {
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} />
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} />
<ConnectionBanner status={status} />
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-zinc-500"><div className="animate-pulse text-sm">Loading</div></div>}>
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} />
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} />
</Suspense>
</div>
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />

View File

@@ -16,6 +16,7 @@ interface Props {
sessionKey?: string;
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
onAbort: () => void;
agentAvatarUrl?: string;
}
function isNoReply(msg: ChatMessage): boolean {
@@ -66,7 +67,7 @@ function getDateKey(ts: number): string {
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
const SCROLL_THRESHOLD = 150;
export function Chat({ messages, isGenerating, isLoadingHistory, status, sessionKey, onSend, onAbort }: Props) {
export function Chat({ messages, isGenerating, isLoadingHistory, status, sessionKey, onSend, onAbort, agentAvatarUrl }: Props) {
const t = useT();
const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -161,7 +162,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<div className="flex-1 h-px bg-white/8" />
</div>
)}
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} />
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} />
</div>
))}
{showTyping && <TypingIndicator />}

View File

@@ -320,7 +320,7 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
);
}
export function ChatMessageComponent({ message, onRetry }: { message: ChatMessageType; onRetry?: (text: string) => void }) {
export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string }) {
useLocale(); // re-render on locale change
const isUser = message.role === 'user';
@@ -342,10 +342,12 @@ export function ChatMessageComponent({ message, onRetry }: { message: ChatMessag
return (
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40">
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40 overflow-hidden">
{isUser
? <User className="h-4 w-4 text-cyan-200" />
: <Bot className="h-4 w-4 text-cyan-200" />
: agentAvatarUrl
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
: <Bot className="h-4 w-4 text-cyan-200" />
}
</div>

View File

@@ -15,9 +15,10 @@ interface Props {
soundEnabled?: boolean;
onToggleSound?: () => void;
messages?: ChatMessage[];
agentAvatarUrl?: string;
}
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages }: Props) {
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl }: Props) {
const t = useT();
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
@@ -36,7 +37,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<Menu size={20} />
</button>
<div className="flex items-center gap-3 flex-1 min-w-0">
<img src="/logo.png" alt="PinchChat" className="h-9 w-9 rounded-2xl" />
<img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-zinc-300 text-sm tracking-wide">{t('header.title')}</span>

View File

@@ -3,7 +3,7 @@ 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';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types';
interface ChatPayloadMessage {
content?: string | Array<{ type: string; text?: string }>;
@@ -43,6 +43,7 @@ export function useGateway() {
const currentRunIdRef = useRef<string | null>(null);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
const [unreadSessions, setUnreadSessions] = useState<Set<string>>(new Set());
const [agentIdentity, setAgentIdentity] = useState<AgentIdentity | null>(null);
const handleAgentEvent = useCallback((payload: JsonPayload) => {
if (payload?.stream !== 'tool') return;
@@ -94,6 +95,22 @@ export function useGateway() {
localStorage.setItem('pinchchat-deleted-sessions', JSON.stringify([...deleted]));
}, [getDeletedSessions]);
const loadAgentIdentity = useCallback(async () => {
try {
const res = await clientRef.current?.send('agent.identity.get', {});
if (res) {
setAgentIdentity({
name: res.name as string | undefined,
emoji: res.emoji as string | undefined,
avatar: res.avatar as string | undefined,
agentId: res.agentId as string | undefined,
});
}
} catch {
// Silently ignore — identity is optional
}
}, []);
const loadSessions = useCallback(async () => {
try {
const res = await clientRef.current?.send('sessions.list', {});
@@ -228,6 +245,7 @@ export function useGateway() {
isConnectingRef.current = false;
storeCredentials(wsUrl, token);
loadSessions();
loadAgentIdentity();
loadHistory(activeSessionRef.current);
} else if (s === 'disconnected' && !client.isConnected) {
// If we never connected successfully, this is an auth/connection error
@@ -345,7 +363,7 @@ export function useGateway() {
isConnectingRef.current = true;
setConnectError(null);
client.connect();
}, [handleAgentEvent, loadHistory, loadSessions]);
}, [handleAgentEvent, loadHistory, loadSessions, loadAgentIdentity]);
// On mount: try stored credentials
const initRef = useRef(false);
@@ -461,6 +479,6 @@ export function useGateway() {
return {
status, messages, sessions: enrichedSessions, activeSession, isGenerating, isLoadingHistory,
sendMessage, abort, switchSession, loadSessions, deleteSession,
authenticated, login, logout, connectError, isConnecting,
authenticated, login, logout, connectError, isConnecting, agentIdentity,
};
}

View File

@@ -35,6 +35,13 @@ export interface Session {
lastMessagePreview?: string;
}
export interface AgentIdentity {
name?: string;
emoji?: string;
avatar?: string;
agentId?: string;
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface GatewayState {