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
+7 -4
View File
@@ -98,7 +98,7 @@
},
"packages/desktop": {
"name": "@openchamber/desktop",
"version": "1.7.5",
"version": "1.8.1",
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^24.3.1",
@@ -107,7 +107,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.7.5",
"version": "1.8.1",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
@@ -162,6 +162,7 @@
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
@@ -210,7 +211,7 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.7.5",
"version": "1.8.1",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.2.10",
@@ -233,7 +234,7 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.7.5",
"version": "1.8.1",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -1859,6 +1860,8 @@
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+1
View File
@@ -64,6 +64,7 @@
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
@@ -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