refactor: replace CSS group-hover with Radix HoverCard for message actions

Swap the fragile CSS group-hover opacity pattern for Radix HoverCard
which handles hover intent, open/close delays, and portal rendering.
Actions appear below the bubble on hover via stripped HoverCardContent
(no border, shadow, padding, or background). Removes group class.
This commit is contained in:
Taylor Ho
2026-04-13 20:07:09 -10:00
parent 4ece14d64b
commit b7dcb1a515
@@ -30,6 +30,11 @@ import {
ReasoningContent,
} from "@/shared/ui/ai-elements/reasoning";
import { Button } from "@/shared/ui/button";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@/shared/ui/hover-card";
import { ToolChainCards, type ToolChainItem } from "./ToolChainCards";
import { ClickableImage } from "./ClickableImage";
import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler";
@@ -414,7 +419,7 @@ export const MessageBubble = memo(function MessageBubble({
return (
<div
className={cn(
"group flex px-4 py-1",
"flex px-4 py-1",
"animate-in fade-in duration-200 motion-reduce:animate-none",
isEditing
? "flex-row"
@@ -494,81 +499,90 @@ export const MessageBubble = memo(function MessageBubble({
</div>
</div>
) : (
<>
<HoverCard openDelay={0} closeDelay={150}>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated link handler */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
<div
className={cn(
"w-full min-w-0 text-[13px] leading-relaxed",
isUser && "rounded-2xl bg-muted px-4 py-2.5",
)}
onClick={handleContentClick}
>
{messageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{messageAttachments.map((attachment) => (
<MessageAttachmentRow
key={`${attachment.type}-${attachment.path ?? attachment.name}`}
attachment={attachment}
/>
))}
</div>
)}
{groupContentSections(content).map((section, sectionIdx) => {
if (section.type === "toolChain") {
const toolItems = section.items as ToolChainItem[];
return <ToolChainCards key={section.key} toolItems={toolItems} />;
}
const block = section.items[0] as MessageContent;
return (
<div key={`${message.id}-${section.key}`}>
{renderContentBlock(
block,
sectionIdx,
{
defaultImageAlt: t("message.defaultImageAlt"),
redactedThinking: t("message.redactedThinking"),
},
isStreaming,
isUser,
)}
<HoverCardTrigger asChild>
<div
className={cn(
"w-full min-w-0 text-[13px] leading-relaxed",
isUser && "rounded-2xl bg-muted px-4 py-2.5",
)}
onClick={handleContentClick}
>
{messageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{messageAttachments.map((attachment) => (
<MessageAttachmentRow
key={`${attachment.type}-${attachment.path ?? attachment.name}`}
attachment={attachment}
/>
))}
</div>
);
})}
{pathNotice && (
<p className="mt-2 text-xs text-destructive" role="status">
{pathNotice}
</p>
)}
</div>
{/* Hover actions + timestamp */}
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
{textContent && <CopyAction text={textContent} />}
{onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && !isEditing && (
<MessageAction
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
<span className="px-1 text-[10px] text-muted-foreground">
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
)}
{groupContentSections(content).map((section, sectionIdx) => {
if (section.type === "toolChain") {
const toolItems = section.items as ToolChainItem[];
return <ToolChainCards key={section.key} toolItems={toolItems} />;
}
const block = section.items[0] as MessageContent;
return (
<div key={`${message.id}-${section.key}`}>
{renderContentBlock(
block,
sectionIdx,
{
defaultImageAlt: t("message.defaultImageAlt"),
redactedThinking: t("message.redactedThinking"),
},
isStreaming,
isUser,
)}
</div>
);
})}
</span>
</MessageActions>
</>
{pathNotice && (
<p className="mt-2 text-xs text-destructive" role="status">
{pathNotice}
</p>
)}
</div>
</HoverCardTrigger>
{/* Hover actions + timestamp — rendered in portal, stripped styling */}
<HoverCardContent
side="bottom"
align={isUser ? "end" : "start"}
sideOffset={0}
className="w-auto border-none bg-transparent p-0 shadow-none"
>
<MessageActions>
{textContent && <CopyAction text={textContent} />}
{onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && (
<MessageAction
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
<span className="px-1 text-[10px] text-muted-foreground">
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</MessageActions>
</HoverCardContent>
</HoverCard>
)}
</div>
</div>