From 598d74541025912d039c3863dbe2834e0faca6e9 Mon Sep 17 00:00:00 2001 From: tengju Date: Mon, 22 Jun 2026 19:28:38 +0800 Subject: [PATCH] fix: show the active profile's avatar in chat (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat always rendered the Hermes mark next to agent turns and on the empty state, regardless of the active profile — so a profile's custom avatar (or a named profile's coloured initial) never appeared in chat, even though the Profiles page rendered it correctly. Closes #679. - Add AgentAvatarContext and render the chat agent avatar via the shared ProfileAvatar (custom image / coloured initial) in message rows, history rows, the typing indicator and the empty state, matching the Profiles page. The default profile keeps the animated Hermes mark. Profile appearance is plumbed Layout -> Chat -> the avatar context. - Pin a not-yet-started chat to the active profile so launch-time restore of the persisted profile also routes a fresh chat to the selected agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/screens/Chat/AgentAvatarContext.tsx | 20 ++++++++++ src/renderer/src/screens/Chat/Chat.tsx | 37 +++++++++++++------ .../src/screens/Chat/ChatEmptyState.tsx | 34 ++++++++++++----- src/renderer/src/screens/Chat/MessageRow.tsx | 18 ++++++++- src/renderer/src/screens/Layout/Layout.tsx | 19 ++++++++++ 5 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/screens/Chat/AgentAvatarContext.tsx diff --git a/src/renderer/src/screens/Chat/AgentAvatarContext.tsx b/src/renderer/src/screens/Chat/AgentAvatarContext.tsx new file mode 100644 index 000000000..52b7d7923 --- /dev/null +++ b/src/renderer/src/screens/Chat/AgentAvatarContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; + +/** Appearance of the profile/agent that owns the current chat, so the avatar + * shown next to agent turns matches the one on the Profiles page. */ +export interface AgentAppearance { + /** Profile name — drives the letter/default colour fallback. */ + name?: string; + /** Accent colour for the letter fallback. */ + color?: string | null; + /** Custom avatar image as a data URL; when set it wins over the logo. */ + avatar?: string | null; +} + +const AgentAvatarContext = createContext({}); + +export const AgentAvatarProvider = AgentAvatarContext.Provider; + +export function useAgentAppearance(): AgentAppearance { + return useContext(AgentAvatarContext); +} diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index e5a842f86..6972c323f 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -4,6 +4,7 @@ import { Zap, Globe } from "lucide-react"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatEmptyState } from "./ChatEmptyState"; import { MessageList } from "./MessageList"; +import { AgentAvatarProvider } from "./AgentAvatarContext"; import { ModelPicker } from "./ModelPicker"; import { ReasoningEffortPicker } from "./ReasoningEffortPicker"; import { ContextFolderChip } from "./ContextFolderChip"; @@ -53,6 +54,9 @@ interface ChatProps { /** Whether this run is the one currently shown (drives keyboard handlers). */ active?: boolean; profile?: string; + /** Appearance (colour + custom avatar) of `profile`, so agent turns render + * the profile's avatar instead of the default Hermes logo (issue #679). */ + appearance?: { color?: string | null; avatar?: string | null }; onSessionStarted?: () => void; onNewChat?: () => void; /** Optional callback to navigate to Settings → Diagnose section @@ -74,6 +78,7 @@ function Chat({ initialSessionId, active = true, profile, + appearance, onSessionStarted, onNewChat, onOpenDiagnose, @@ -846,18 +851,26 @@ function Chat({
- {messages.length === 0 ? ( - - ) : ( - - )} + + {messages.length === 0 ? ( + + ) : ( + + )} +
diff --git a/src/renderer/src/screens/Chat/ChatEmptyState.tsx b/src/renderer/src/screens/Chat/ChatEmptyState.tsx index 3b6cc672a..868cc0769 100644 --- a/src/renderer/src/screens/Chat/ChatEmptyState.tsx +++ b/src/renderer/src/screens/Chat/ChatEmptyState.tsx @@ -2,6 +2,8 @@ import { memo } from "react"; import { Search, Clock, Mail, Code, ChartLine, Bell } from "lucide-react"; import titleLine from "../../assets/title-line.svg"; import { useI18n } from "../../components/useI18n"; +import ProfileAvatar from "../../components/common/ProfileAvatar"; +import { useAgentAppearance } from "./AgentAvatarContext"; interface Suggestion { i18nKey: string; @@ -50,19 +52,33 @@ export const ChatEmptyState = memo(function ChatEmptyState({ onSelectSuggestion, }: ChatEmptyStateProps): React.JSX.Element { const { t } = useI18n(); + // Show the current agent's avatar (custom image or coloured initial) so the + // empty state matches who you're about to talk to. The default profile keeps + // the Hermes wordmark logo (issue #679). + const { avatar, name, color } = useAgentAppearance(); + const useProfileAvatar = !!avatar || (!!name && name !== "default"); return (
- + {useProfileAvatar ? ( + + ) : ( + + )}
{t("chat.emptyTitle")}
{t("chat.emptyHint")}
diff --git a/src/renderer/src/screens/Chat/MessageRow.tsx b/src/renderer/src/screens/Chat/MessageRow.tsx index 3c906a58e..eef290f29 100644 --- a/src/renderer/src/screens/Chat/MessageRow.tsx +++ b/src/renderer/src/screens/Chat/MessageRow.tsx @@ -5,7 +5,9 @@ import { AgentMarkdown } from "../../components/AgentMarkdown"; import { AttachmentChip } from "../../components/AttachmentChip"; import { MediaSegmentView } from "../../components/MediaImage"; import { useI18n } from "../../components/useI18n"; +import ProfileAvatar from "../../components/common/ProfileAvatar"; import { parseMediaTokens, cleanLeakedToolTags } from "./mediaUtils"; +import { useAgentAppearance } from "./AgentAvatarContext"; import type { ChatBubbleMessage, ChatMessage } from "./types"; export const APPROVAL_RE = @@ -72,6 +74,13 @@ export const HermesAvatar = memo(function HermesAvatar({ /** True only for the avatar of the turn currently being generated. */ active?: boolean; }): React.JSX.Element { + // A profile with its own identity (a custom avatar image, or any non-default + // named profile with its coloured initial) shows that avatar — matching the + // Profiles page (issue #679). The default profile keeps the animated Hermes + // mark below. Hooks still run unconditionally; only the render branches. + const { avatar, name, color } = useAgentAppearance(); + const useProfileAvatar = !!avatar || (!!name && name !== "default"); + const [frozenSrc, setFrozenSrc] = useState(null); const [playing, setPlaying] = useState(active); // Re-keying the on each play session restarts the gif from frame 0 so @@ -124,7 +133,14 @@ export const HermesAvatar = memo(function HermesAvatar({ return (
- {playing ? ( + {useProfileAvatar ? ( + + ) : playing ? ( ) : ( diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index b62eb8331..3e8cd8781 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -443,6 +443,24 @@ function Layout({ return () => window.removeEventListener("keydown", onKeyDown); }, [sessionsModalOpen]); + // Pin the visible, not-yet-started chat to the active profile. This is the + // safety net behind every path that changes the active profile — launch-time + // restore of the persisted active_profile, the ProfileSwitcher, deep links — + // so a fresh chat always routes to the selected agent's gateway and memory. + // Without it a chat minted under "default" keeps talking to default (and its + // memory) even after the active profile has switched (issue #679 follow-up). + useEffect(() => { + setRuns((prev) => + prev.map((r) => + r.runId === activeRunId && + isScratchRun(r) && + r.profile !== activeProfile + ? { ...r, profile: activeProfile } + : r, + ), + ); + }, [activeProfile, activeRunId]); + const handleSelectProfile = useCallback( (name: string) => { // Selecting an agent is administrative: switch the active profile (the @@ -757,6 +775,7 @@ function Layout({ initialSessionId={run.sessionId} active={run.runId === activeRunId} profile={run.profile} + appearance={getAppearance(run.profile)} onNewChat={handleNewChat} onOpenDiagnose={() => goTo("settings")} onLoadingChange={handleRunLoading}