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
33 changes: 33 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -10738,3 +10738,36 @@ body {
font-size: 11px;
color: var(--text-muted);
}

/* Right-click context menu on messages */
.chat-context-menu {
position: fixed;
z-index: 1000;
min-width: 160px;
padding: 4px;
background: var(--bg-elevated);
border: 1px solid var(--border-bright);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}

.chat-context-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-sans);
cursor: pointer;
transition: background var(--transition);
text-align: left;
}

.chat-context-menu-item:hover {
background: var(--bg-hover);
}
104 changes: 71 additions & 33 deletions src/renderer/src/screens/Chat/MessageRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo, useMemo } from "react";
import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Copy } from "lucide-react";
import icon from "../../assets/icon.png";
import { AgentMarkdown } from "../../components/AgentMarkdown";
import { AttachmentChip } from "../../components/AttachmentChip";
Expand Down Expand Up @@ -30,24 +31,21 @@ export const HermesAvatar = memo(function HermesAvatar({
);
});

/**
* Empty box the size of an avatar. Rendered in place of the avatar on
* continuation rows of a turn (the thinking/tool rows and answer bubble that
* follow the first row) so one turn shows a single avatar while every row
* stays aligned to the same content column.
*/
export const AvatarSpacer = memo(function AvatarSpacer(): React.JSX.Element {
return <div className="chat-avatar" aria-hidden="true" />;
});

interface ContextMenuState {
x: number;
y: number;
}

interface MessageRowProps {
msg: ChatMessage;
isLast: boolean;
isLoading: boolean;
onApprove: () => void;
onDeny: () => void;
/** False on continuation rows of a turn — render a spacer instead of the
* avatar so the turn reads as one grouped block. Defaults to true. */
showAvatar?: boolean;
}

Expand All @@ -60,35 +58,65 @@ export const MessageRow = memo(function MessageRow({
showAvatar = true,
}: MessageRowProps): React.JSX.Element {
const { t } = useI18n();
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const menuRef = useRef<HTMLDivElement>(null);

// MessageRow is wrapped in memo() but still re-renders on any prop change
// (e.g. isLoading toggling at the end of a stream), and `parseMediaTokens`
// runs a full regex pipeline. Cache the result against the message content
// so a long conversation doesn't reparse every row on every render.
// Only agent bubbles need media parsing — user bubbles render content
// verbatim — so this is gated on the role to skip the work entirely for
// user rows. (Follow-up item from PR #303 review.)
const bubbleContent = isChatBubbleMessage(msg)
? (msg as ChatBubbleMessage).content
: null;

const segments = useMemo(
() =>
msg.role === "agent" && bubbleContent
? // Recover any tool/skill call the model leaked as text (e.g. a raw
// `<skill_view>{"answer": …}</skill_view>` tag) before tokenizing.
parseMediaTokens(cleanLeakedToolTags(bubbleContent))
? parseMediaTokens(cleanLeakedToolTags(bubbleContent))
: null,
[msg.role, bubbleContent],
);

// Only chat bubble messages have content/attachments
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
},
[],
);

const closeContextMenu = useCallback(() => setContextMenu(null), []);

const handleCopy = useCallback(async () => {
if (!bubbleContent) return;
try {
await window.hermesAPI.copyToClipboard(bubbleContent);
} catch {
// ignore clipboard errors
}
closeContextMenu();
}, [bubbleContent, closeContextMenu]);

// Close context menu on click outside or Escape
useEffect(() => {
if (!contextMenu) return;
function onClick(e: MouseEvent): void {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
}
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape") closeContextMenu();
}
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKey);
};
}, [contextMenu, closeContextMenu]);
Comment on lines +96 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Multiple context menus can stack simultaneously

The dismiss effect only listens for click events, so a right-click (contextmenu event) on a different message row does not fire onClick and therefore never closes the current menu. Because every MessageRow manages its own state independently, the second right-click opens a second chat-context-menu alongside the first. The user ends up with two (or more) stacked menus that cannot be closed without an extra regular click.

The fix is to also listen for contextmenu on the document and close any open menu when it fires (before the targeted row's handleContextMenu re-opens one for the new position).


if (!isChatBubbleMessage(msg)) {
return (
<div className={`chat-message chat-message-${msg.role}`}>
{showAvatar ? <HermesAvatar /> : <AvatarSpacer />}
<div className={`chat-bubble chat-bubble-${msg.role}`}>
{/* Reasoning/tool messages handled separately */}
</div>
<div className={`chat-bubble chat-bubble-${msg.role}`} />
</div>
);
}
Expand All @@ -106,6 +134,7 @@ export const MessageRow = memo(function MessageRow({
className={`chat-message chat-message-${msg.role}${
showAvatar ? "" : " chat-message--grouped"
}`}
onContextMenu={handleContextMenu}
>
{!showAvatar ? (
<AvatarSpacer />
Expand All @@ -131,11 +160,6 @@ export const MessageRow = memo(function MessageRow({
? segments.map((segment) =>
segment.type === "text" ? (
segment.value.trim() ? (
// Keyed on the segment's character offset rather than its
// array index — a MEDIA: token appearing mid-stream shifts
// every subsequent index, which would otherwise re-mount
// each downstream MediaSegmentView and re-fire its
// `mediaFileExists` probe.
<AgentMarkdown key={`t-${segment.start}`}>
{segment.value}
</AgentMarkdown>
Expand All @@ -158,17 +182,31 @@ export const MessageRow = memo(function MessageRow({
</div>
{showApprovalBar && (
<div className="chat-approval-bar">
<button
className="chat-approval-btn chat-approve"
onClick={onApprove}
>
<button className="chat-approval-btn chat-approve" onClick={onApprove}>
{t("chat.approve")}
</button>
<button className="chat-approval-btn chat-deny" onClick={onDeny}>
{t("chat.deny")}
</button>
</div>
)}

{contextMenu && (
<div
ref={menuRef}
className="chat-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Menu can render off-screen near viewport edges

contextMenu.x and contextMenu.y are raw cursor coordinates. When the cursor is in the lower-right corner of the viewport the 160 px-wide, ~40 px-tall menu overflows outside the window with no way to scroll it back into view. A boundary check clamping left to Math.min(x, window.innerWidth - menuWidth) and top to Math.min(y, window.innerHeight - menuHeight) would keep it fully visible.

>
Comment on lines +194 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Right-clicking directly on the open context menu bubbles the contextmenu event up to the outer div's onContextMenu={handleContextMenu}, which calls setContextMenu with new coordinates and re-positions the menu. Adding onContextMenu={e => e.stopPropagation()} on the menu container prevents this.

Suggested change
{contextMenu && (
<div
ref={menuRef}
className="chat-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu && (
<div
ref={menuRef}
className="chat-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(e) => e.stopPropagation()}
>

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

<button
type="button"
className="chat-context-menu-item"
onClick={handleCopy}
>
<Copy size={14} />
<span>{t("chat.copyMessage")}</span>
</button>
</div>
)}
</div>
);
});
});
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export default {
queued: "{{count}} message(s) queued — will send when the agent finishes",
queuedCount: "{{count}} queued",
queuedAttachment: "{{count}} attachment(s)",
copyMessage: "Copy message",
queuedCancel: "Remove from queue",
worktree: {
loading: "Loading",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export default {
version: "Mostrar la versión de Hermes",
},
queued: "{{count}} mensaje(s) en cola — se enviará cuando el agente termine",
copyMessage: "Copiar mensaje",
queuedCancel: "Quitar de la cola",
worktree: {
loading: "Cargando",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/id/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default {
persona: "Tampilkan persona saat ini",
version: "Tampilkan versi Hermes",
},
copyMessage: "Salin pesan",
queuedCancel: "Batalkan pesan antrian",
worktree: {
loading: "Memuat",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/ja/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default {
persona: "現在のペルソナを表示",
version: "Hermes バージョンを表示",
},
copyMessage: "メッセージをコピー",
queuedCancel: "キューから削除",
worktree: {
loading: "読み込み中",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pl/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default {
},
queued:
"{{count}} wiadomość/wiadomości w kolejce — zostaną wysłane po zakończeniu pracy agenta",
copyMessage: "Kopiuj wiadomość",
queuedCancel: "Usuń z kolejki",
worktree: {
loading: "Ładowanie",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default {
persona: "Mostrar a persona atual",
version: "Mostrar a versão do Hermes",
},
copyMessage: "Copiar mensagem",
queuedCancel: "Remover da fila",
worktree: {
loading: "Carregando",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-PT/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default {
},
queued:
"{{count}} mensagem(ns) em fila — serão enviadas quando o agente terminar",
copyMessage: "Copiar mensagem",
queuedCancel: "Remover da fila",
worktree: {
loading: "A carregar",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/tr/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export default {
version: "Hermes sürümünü göster",
},
queued: "{{count}} mesaj sırada — ajan işini bitirince gönderilecek",
copyMessage: "Mesajı kopyala",
queuedCancel: "Sıradan kaldır",
worktree: {
loading: "Yükleniyor",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-CN/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default {
persona: "查看当前人格",
version: "查看 Hermes 版本",
},
copyMessage: "复制消息",
queuedCancel: "从队列中移除",
worktree: {
loading: "加载中",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-TW/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default {
version: "檢視 Hermes 版本",
},
queued: "{{count}} 則訊息已排隊 — 代理完成後將自動傳送",
copyMessage: "複製訊息",
queuedCancel: "從佇列中移除",
worktree: {
loading: "載入中",
Expand Down
Loading