feat: add share message as image functionality (#553)

* feat: add share message as image functionality

- Add html-to-image dependency for DOM-to-PNG conversion
- Add share button to agent message footer with loading state
- Generate high-quality PNG images with 24px padding for better readability
- Use theme-aware background colors
- Handle errors gracefully with toast notifications

* fix: align lockfile workspace versions to 1.8.1

---------

Co-authored-by: Jovines <jovines@qq.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jovines
2026-02-28 20:34:38 +02:00
committed by GitHub
co-authored by Jovines Bohdan Triapitsyn
parent be413771dc
commit 934af638fe
3 changed files with 113 additions and 24 deletions
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -28,6 +28,8 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
import { useConfigStore } from '@/stores/useConfigStore';
import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toPng } from 'html-to-image';
import { toast } from '@/components/ui';
import { formatTimestampForDisplay } from './timeFormat';
type SubtaskPartLike = Part & {
@@ -779,6 +781,66 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
[assistantTextParts, isTTSPlaying, playTTS, stopTTS]
);
const [isSharing, setIsSharing] = React.useState(false);
const handleShareImage = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!messageContentRef.current || isSharing) return;
setIsSharing(true);
let wrapper: HTMLDivElement | null = null;
try {
const originalElement = messageContentRef.current;
const computedStyle = window.getComputedStyle(originalElement);
const paddingSize = 24;
wrapper = document.createElement('div');
wrapper.style.cssText = `
padding: ${paddingSize}px;
background-color: var(--surface-background);
display: inline-block;
`;
const clone = originalElement.cloneNode(true) as HTMLElement;
clone.style.cssText = `
${computedStyle.cssText}
transform: none;
contain: none;
`;
wrapper.appendChild(clone);
document.body.appendChild(wrapper);
const dataUrl = await toPng(wrapper, {
quality: 1,
pixelRatio: 2,
backgroundColor: 'var(--surface-background)',
});
const link = document.createElement('a');
link.download = `message-${messageId}.png`;
link.href = dataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('Image saved');
} catch (error) {
console.error('Failed to generate image:', error);
toast.error('Failed to generate image');
} finally {
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
setIsSharing(false);
}
},
[messageId, isSharing]
);
React.useEffect(() => {
return () => {
clearCopyHintTimeout();
@@ -1172,25 +1234,48 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={isSharing || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isSharing) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShareImage}
>
{isSharing ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiShare2Line className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button