Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/renderer/src/screens/Chat/AgentAvatarContext.tsx
Original file line number Diff line number Diff line change
@@ -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<AgentAppearance>({});

export const AgentAvatarProvider = AgentAvatarContext.Provider;

export function useAgentAppearance(): AgentAppearance {
return useContext(AgentAvatarContext);
}
37 changes: 25 additions & 12 deletions src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -74,6 +78,7 @@ function Chat({
initialSessionId,
active = true,
profile,
appearance,
onSessionStarted,
onNewChat,
onOpenDiagnose,
Expand Down Expand Up @@ -846,18 +851,26 @@ function Chat({

<div className="chat-body">
<div className="chat-messages" ref={containerRef}>
{messages.length === 0 ? (
<ChatEmptyState onSelectSuggestion={handleSuggestion} />
) : (
<MessageList
messages={messages}
isLoading={isLoading}
toolProgress={toolProgress}
onApprove={actions.handleApprove}
onDeny={actions.handleDeny}
onClarifyResolved={handleClarifyResolved}
/>
)}
<AgentAvatarProvider
value={{
name: profile,
color: appearance?.color,
avatar: appearance?.avatar,
}}
>
{messages.length === 0 ? (
<ChatEmptyState onSelectSuggestion={handleSuggestion} />
) : (
<MessageList
messages={messages}
isLoading={isLoading}
toolProgress={toolProgress}
onApprove={actions.handleApprove}
onDeny={actions.handleDeny}
onClarifyResolved={handleClarifyResolved}
/>
)}
</AgentAvatarProvider>
<div ref={bottomRef} />
</div>

Expand Down
34 changes: 25 additions & 9 deletions src/renderer/src/screens/Chat/ChatEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="chat-empty">
<div className="chat-empty-icon">
<span
className="chat-empty-logo"
role="img"
aria-label="Hermes"
style={{
maskImage: `url(${titleLine})`,
WebkitMaskImage: `url(${titleLine})`,
}}
/>
{useProfileAvatar ? (
<ProfileAvatar
name={name || "default"}
color={color}
avatar={avatar}
size={64}
/>
) : (
<span
className="chat-empty-logo"
role="img"
aria-label="Hermes"
style={{
maskImage: `url(${titleLine})`,
WebkitMaskImage: `url(${titleLine})`,
}}
/>
)}
</div>
<div className="chat-empty-text">{t("chat.emptyTitle")}</div>
<div className="chat-empty-hint">{t("chat.emptyHint")}</div>
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/src/screens/Chat/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<string | null>(null);
const [playing, setPlaying] = useState(active);
// Re-keying the <img> on each play session restarts the gif from frame 0 so
Expand Down Expand Up @@ -124,7 +133,14 @@ export const HermesAvatar = memo(function HermesAvatar({

return (
<div className="chat-avatar chat-avatar-agent">
{playing ? (
{useProfileAvatar ? (
<ProfileAvatar
name={name || "default"}
color={color}
avatar={avatar}
size={size}
/>
) : playing ? (
<img key={playKey} src={loadingGif} width={size} height={size} alt="" />
) : (
<img src={frozenSrc ?? loadingGif} width={size} height={size} alt="" />
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/screens/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down