diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index a1585ad6a..475a30b4a 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -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);
+}
diff --git a/src/renderer/src/screens/Chat/MessageRow.tsx b/src/renderer/src/screens/Chat/MessageRow.tsx
index 45520aa00..81ea024d6 100644
--- a/src/renderer/src/screens/Chat/MessageRow.tsx
+++ b/src/renderer/src/screens/Chat/MessageRow.tsx
@@ -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";
@@ -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
;
});
+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;
}
@@ -60,35 +58,65 @@ export const MessageRow = memo(function MessageRow({
showAvatar = true,
}: MessageRowProps): React.JSX.Element {
const { t } = useI18n();
+ const [contextMenu, setContextMenu] = useState(null);
+ const menuRef = useRef(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
- // `{"answer": …}` 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]);
+
if (!isChatBubbleMessage(msg)) {
return (
{showAvatar ?
:
}
-
- {/* Reasoning/tool messages handled separately */}
-
+
);
}
@@ -106,6 +134,7 @@ export const MessageRow = memo(function MessageRow({
className={`chat-message chat-message-${msg.role}${
showAvatar ? "" : " chat-message--grouped"
}`}
+ onContextMenu={handleContextMenu}
>
{!showAvatar ? (
@@ -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.
{segment.value}
@@ -158,10 +182,7 @@ export const MessageRow = memo(function MessageRow({
{showApprovalBar && (
-
)}
+
+ {contextMenu && (
+
+
+
+ {t("chat.copyMessage")}
+
+
+ )}
);
-});
+});
\ No newline at end of file
diff --git a/src/shared/i18n/locales/en/chat.ts b/src/shared/i18n/locales/en/chat.ts
index 1a28eae43..375909147 100644
--- a/src/shared/i18n/locales/en/chat.ts
+++ b/src/shared/i18n/locales/en/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/es/chat.ts b/src/shared/i18n/locales/es/chat.ts
index 1cdf05413..7d6744433 100644
--- a/src/shared/i18n/locales/es/chat.ts
+++ b/src/shared/i18n/locales/es/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/id/chat.ts b/src/shared/i18n/locales/id/chat.ts
index bf4a24061..18f927a5b 100644
--- a/src/shared/i18n/locales/id/chat.ts
+++ b/src/shared/i18n/locales/id/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/ja/chat.ts b/src/shared/i18n/locales/ja/chat.ts
index 83461d9f9..9bf3b404f 100644
--- a/src/shared/i18n/locales/ja/chat.ts
+++ b/src/shared/i18n/locales/ja/chat.ts
@@ -80,6 +80,7 @@ export default {
persona: "現在のペルソナを表示",
version: "Hermes バージョンを表示",
},
+ copyMessage: "メッセージをコピー",
queuedCancel: "キューから削除",
worktree: {
loading: "読み込み中",
diff --git a/src/shared/i18n/locales/pl/chat.ts b/src/shared/i18n/locales/pl/chat.ts
index fa5b153c1..aff7cd8b3 100644
--- a/src/shared/i18n/locales/pl/chat.ts
+++ b/src/shared/i18n/locales/pl/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/pt-BR/chat.ts b/src/shared/i18n/locales/pt-BR/chat.ts
index 1050f2767..7769b01be 100644
--- a/src/shared/i18n/locales/pt-BR/chat.ts
+++ b/src/shared/i18n/locales/pt-BR/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/pt-PT/chat.ts b/src/shared/i18n/locales/pt-PT/chat.ts
index 819dbc9bf..02a47a598 100644
--- a/src/shared/i18n/locales/pt-PT/chat.ts
+++ b/src/shared/i18n/locales/pt-PT/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/tr/chat.ts b/src/shared/i18n/locales/tr/chat.ts
index 40c29f531..a03b11c65 100644
--- a/src/shared/i18n/locales/tr/chat.ts
+++ b/src/shared/i18n/locales/tr/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/zh-CN/chat.ts b/src/shared/i18n/locales/zh-CN/chat.ts
index 49d107459..7a299369e 100644
--- a/src/shared/i18n/locales/zh-CN/chat.ts
+++ b/src/shared/i18n/locales/zh-CN/chat.ts
@@ -77,6 +77,7 @@ export default {
persona: "查看当前人格",
version: "查看 Hermes 版本",
},
+ copyMessage: "复制消息",
queuedCancel: "从队列中移除",
worktree: {
loading: "加载中",
diff --git a/src/shared/i18n/locales/zh-TW/chat.ts b/src/shared/i18n/locales/zh-TW/chat.ts
index 337c15a67..1937c4249 100644
--- a/src/shared/i18n/locales/zh-TW/chat.ts
+++ b/src/shared/i18n/locales/zh-TW/chat.ts
@@ -66,6 +66,7 @@ export default {
version: "檢視 Hermes 版本",
},
queued: "{{count}} 則訊息已排隊 — 代理完成後將自動傳送",
+ copyMessage: "複製訊息",
queuedCancel: "從佇列中移除",
worktree: {
loading: "載入中",