From 934af638fee8856e2bb22c2bb862e0dacf1cd197 Mon Sep 17 00:00:00 2001 From: Jovines <1246634075@qq.com> Date: Sun, 1 Mar 2026 02:34:38 +0800 Subject: [PATCH] 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 Co-authored-by: Bohdan Triapitsyn --- bun.lock | 11 +- packages/ui/package.json | 1 + .../components/chat/message/MessageBody.tsx | 125 +++++++++++++++--- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index e75aeba8..a044ee26 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index bfa56611..83573a5f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 56074ca5..27989d74 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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> = ({ [assistantTextParts, isTTSPlaying, playTTS, stopTTS] ); + const [isSharing, setIsSharing] = React.useState(false); + + const handleShareImage = React.useCallback( + async (event: React.MouseEvent) => { + 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> = ({ )} - - Copy answer - - )} - - - - - Start new session from this answer - + + Copy answer + + )} + + + + + {isSharing ? 'Saving image...' : 'Save as image'} + + + + + + Start new session from this answer +