@@ -4,13 +4,12 @@ import { useChatStore } from '@renderer/stores/chat-store'
44import { useUIStore } from '@renderer/stores/ui-store'
55import { useAgentStore } from '@renderer/stores/agent-store'
66import { MessageItem } from './MessageItem'
7- import { MessageSquare , Briefcase , Code2 , RefreshCw , ArrowDown , ClipboardCopy , Check , ImageDown , Loader2 } from 'lucide-react'
7+ import { MessageSquare , Briefcase , Code2 , RefreshCw , ArrowDown , Loader2 } from 'lucide-react'
88import { Button } from '@renderer/components/ui/button'
9- import { sessionToMarkdown } from '@renderer/lib/utils/export-chat'
9+
1010import type { ContentBlock , ToolResultContent , UnifiedMessage } from '@renderer/lib/api/types'
1111import type { ToolCallState } from '@renderer/lib/agent/types'
12- import { toast } from 'sonner'
13- import appIconUrl from '../../../../../resources/icon.png'
12+
1413
1514const modeHints = {
1615 chat : {
@@ -170,8 +169,6 @@ export function MessageList({ onRetry, onEditUserMessage }: MessageListProps): R
170169 return result
171170 } , [ visibleRenderableMeta , messages ] )
172171 const hiddenMessageCount = Math . max ( 0 , renderableMeta . items . length - visibleRenderableMeta . length )
173- const [ copiedAll , setCopiedAll ] = React . useState ( false )
174- const [ exporting , setExporting ] = React . useState ( false )
175172 const contentRef = React . useRef < HTMLDivElement > ( null )
176173 const hasAssistantMessages = renderableMeta . hasAssistantMessages
177174
@@ -349,110 +346,10 @@ export function MessageList({ onRetry, onEditUserMessage }: MessageListProps): R
349346 )
350347 }
351348
352- const handleCopyAll = ( ) : void => {
353- if ( ! activeSession ) return
354- const md = sessionToMarkdown ( activeSession )
355- navigator . clipboard . writeText ( md )
356- setCopiedAll ( true )
357- setTimeout ( ( ) => setCopiedAll ( false ) , 2000 )
358- }
359-
360- const handleExportImage = async ( ) : Promise < void > => {
361- const node = contentRef . current
362- if ( ! node || ! activeSession ) return
363- setExporting ( true )
364-
365- // Convert app icon to base64 data URL for embedding in the footer
366- let iconDataUrl = ''
367- try {
368- const resp = await fetch ( appIconUrl )
369- const blob = await resp . blob ( )
370- iconDataUrl = await new Promise < string > ( ( resolve , reject ) => {
371- const reader = new FileReader ( )
372- reader . onloadend = ( ) => resolve ( reader . result as string )
373- reader . onerror = reject
374- reader . readAsDataURL ( blob )
375- } )
376- } catch {
377- // Silently skip icon if it fails to load
378- }
379-
380- // Build and inject footer DOM element
381- const footer = document . createElement ( 'div' )
382- footer . setAttribute ( 'data-export-footer' , '1' )
383- footer . style . cssText = 'margin-top:24px;padding:20px 0 8px;border-top:1px solid rgba(128,128,128,0.2);display:flex;align-items:center;justify-content:center;gap:14px;'
384- footer . innerHTML = [
385- iconDataUrl
386- ? `<img src="${ iconDataUrl } " style="width:40px;height:40px;border-radius:8px;flex-shrink:0;" />`
387- : '' ,
388- '<div style="display:flex;flex-direction:column;justify-content:center;gap:2px;">' ,
389- ' <span style="font-weight:600;font-size:14px;color:rgba(128,128,128,0.75);">OpenCowork</span>' ,
390- ' <span style="font-size:11px;color:rgba(128,128,128,0.5);">AI-Powered Collaborative Development Platform</span>' ,
391- ' <span style="font-size:11px;color:rgba(128,128,128,0.45);">github.com/AIDotNet/OpenCowork</span>' ,
392- '</div>' ,
393- ] . join ( '\n' )
394- node . appendChild ( footer )
395-
396- try {
397- // Wait for browser to layout the footer before capturing
398- await new Promise ( ( r ) => setTimeout ( r , 150 ) )
399-
400- const bgRaw = getComputedStyle ( document . documentElement ) . getPropertyValue ( '--background' ) . trim ( )
401- const bgColor = bgRaw ? `hsl(${ bgRaw } )` : '#ffffff'
402- const { toPng } = await import ( 'html-to-image' )
403- const dataUrl = await toPng ( node , { backgroundColor : bgColor , pixelRatio : 2 } )
404-
405- const base64 = dataUrl . split ( ',' ) [ 1 ]
406- const binary = atob ( base64 )
407- const bytes = new Uint8Array ( binary . length )
408- for ( let i = 0 ; i < binary . length ; i ++ ) bytes [ i ] = binary . charCodeAt ( i )
409- const blob = new Blob ( [ bytes ] , { type : 'image/png' } )
410- await navigator . clipboard . write ( [ new ClipboardItem ( { 'image/png' : blob } ) ] )
411- toast . success ( t ( 'messageList.imageCopied' ) , { description : t ( 'messageList.imageCopiedDesc' ) } )
412- } catch ( err ) {
413- console . error ( 'Export image failed:' , err )
414- toast . error ( t ( 'messageList.exportImageFailed' ) , { description : String ( err ) } )
415- } finally {
416- if ( node . contains ( footer ) ) node . removeChild ( footer )
417- setExporting ( false )
418- }
419- }
420-
421349 return (
422350 < div className = "relative flex-1" >
423- { /* Floating action bar — visible by default when conversation has content */ }
424- { messages . length > 0 && (
425- < div className = "absolute top-2 right-4 z-10 flex items-center gap-0.5 rounded-lg border bg-background/80 backdrop-blur-sm shadow-sm px-0.5 py-0.5" >
426- < button
427- className = "group/btn flex h-6 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200 disabled:opacity-50"
428- onClick = { handleExportImage }
429- disabled = { exporting || ! ! streamingMessageId }
430- >
431- { exporting ? < Loader2 className = "size-3.5 shrink-0 animate-spin" /> : < ImageDown className = "size-3.5 shrink-0" /> }
432- < span
433- className = "max-w-0 overflow-hidden pl-0 text-[10px] opacity-0 whitespace-nowrap group-hover/btn:max-w-[140px] group-hover/btn:pl-1 group-hover/btn:opacity-100"
434- style = { { transition : 'max-width 220ms cubic-bezier(0.4, 0, 0.2, 1), opacity 160ms ease, padding 180ms ease' } }
435- >
436- { exporting ? t ( 'messageList.exporting' ) : t ( 'messageList.exportImage' ) }
437- </ span >
438- </ button >
439- < button
440- className = "group/btn flex h-6 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200 disabled:opacity-50"
441- onClick = { handleCopyAll }
442- disabled = { ! ! streamingMessageId }
443- >
444- { copiedAll ? < Check className = "size-3.5 shrink-0" /> : < ClipboardCopy className = "size-3.5 shrink-0" /> }
445- < span
446- className = "max-w-0 overflow-hidden pl-0 text-[10px] opacity-0 whitespace-nowrap group-hover/btn:max-w-[140px] group-hover/btn:pl-1 group-hover/btn:opacity-100"
447- style = { { transition : 'max-width 220ms cubic-bezier(0.4, 0, 0.2, 1), opacity 160ms ease, padding 180ms ease' } }
448- >
449- { copiedAll ? t ( 'messageList.copied' ) : t ( 'messageList.copyAll' ) }
450- </ span >
451- </ button >
452- </ div >
453- ) }
454351 < div ref = { scrollContainerRef } className = "absolute inset-0 overflow-y-auto" >
455- < div ref = { contentRef } className = "mx-auto max-w-3xl space-y-6 p-4 overflow-hidden" >
352+ < div ref = { contentRef } data-message-content className = "mx-auto max-w-3xl space-y-6 p-4 overflow-hidden" >
456353 { hiddenMessageCount > 0 && (
457354 < div className = "flex justify-center" >
458355 < button
0 commit comments