import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { PageLoader } from '@/components/page-loader' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Pagination, PaginationButton, PaginationContent, PaginationEllipsis, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' import { sessionRoute } from '../routes' import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar' type ArtifactKind = 'image' | 'file' | 'link' interface ArtifactRecord { id: string kind: ArtifactKind value: string href: string label: string sessionId: string sessionTitle: string timestamp: number } const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g const URL_RE = /https?:\/\/[^\s<>"')]+/g const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' }) function normalizeValue(value: string): string { return value.trim().replace(/[),.;]+$/, '') } function parseMaybeJson(value: string): unknown { if (!value.trim()) { return null } try { return JSON.parse(value) } catch { return null } } function looksLikePathOrUrl(value: string): boolean { return ( value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:image/') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') ) } function looksLikeArtifact(value: string): boolean { if (value.startsWith('data:image/')) { return true } if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) { return true } return value.startsWith('/') && value.includes('.') } function artifactKind(value: string): ArtifactKind { if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) { return 'image' } if ( value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') || value.startsWith('file://') ) { return 'file' } return 'link' } function artifactHref(value: string): string { if ( value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:') ) { return value } if (value.startsWith('/')) { return `file://${encodeURI(value)}` } return value } function artifactLabel(value: string): string { try { const url = new URL(value) const item = url.pathname.split('/').filter(Boolean).pop() return item || value } catch { const parts = value.split(/[\\/]/).filter(Boolean) return parts.pop() || value } } function messageText(message: SessionMessage): string { if (typeof message.content === 'string' && message.content.trim()) { return message.content } if (typeof message.text === 'string' && message.text.trim()) { return message.text } if (typeof message.context === 'string' && message.context.trim()) { return message.context } return '' } function collectStringValues( value: unknown, keyPath: string, collector: (value: string, keyPath: string) => void ): void { if (typeof value === 'string') { collector(value, keyPath) return } if (Array.isArray(value)) { value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector)) return } if (!value || typeof value !== 'object') { return } for (const [key, child] of Object.entries(value as Record)) { collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector) } } function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void { for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) { pushValue(match[2] || '') } for (const match of text.matchAll(MARKDOWN_LINK_RE)) { const start = match.index ?? 0 if (start > 0 && text[start - 1] === '!') { continue } const value = match[2] || '' if (looksLikeArtifact(value)) { pushValue(value) } } for (const match of text.matchAll(URL_RE)) { const value = match[0] || '' if (looksLikeArtifact(value)) { pushValue(value) } } for (const match of text.matchAll(PATH_RE)) { pushValue(match[2] || '') } } function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void { const text = messageText(message) if (text) { collectArtifactsFromText(text, pushValue) } if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) { return } if (Array.isArray(message.tool_calls)) { for (const call of message.tool_calls) { collectStringValues(call, 'tool_call', (value, keyPath) => { const normalized = normalizeValue(value) if (!normalized) { return } if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) { pushValue(normalized) } }) } } const parsed = parseMaybeJson(text) if (parsed !== null) { collectStringValues(parsed, 'tool_result', (value, keyPath) => { const normalized = normalizeValue(value) if (!normalized) { return } if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) { pushValue(normalized) } }) } } function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] { const found = new Map() const title = sessionTitle(session) for (const message of messages) { if (message.role !== 'assistant' && message.role !== 'tool') { continue } collectArtifactsFromMessage(message, candidate => { const value = normalizeValue(candidate) if (!value || !looksLikeArtifact(value)) { return } const key = `${session.id}:${value}` if (found.has(key)) { return } found.set(key, { id: key, kind: artifactKind(value), value, href: artifactHref(value), label: artifactLabel(value), sessionId: session.id, sessionTitle: title, timestamp: message.timestamp || session.last_active || session.started_at || Date.now() }) }) } return Array.from(found.values()) } function formatArtifactTime(timestamp: number): string { return ARTIFACT_TIME_FMT.format(new Date(timestamp)) } function pageRangeLabel(total: number, page: number, pageSize: number): string { if (total === 0) { return '0' } const start = (page - 1) * pageSize + 1 const end = Math.min(total, page * pageSize) return `${start}-${end} of ${total}` } function paginationItems(page: number, pageCount: number): Array { if (pageCount <= 7) { return Array.from({ length: pageCount }, (_, index) => index + 1) } const pages: Array = [1] const start = Math.max(2, page - 1) const end = Math.min(pageCount - 1, page + 1) if (start > 2) { pages.push('ellipsis') } for (let nextPage = start; nextPage <= end; nextPage += 1) { pages.push(nextPage) } if (end < pageCount - 1) { pages.push('ellipsis') } pages.push(pageCount) return pages } interface ArtifactsViewProps extends React.ComponentProps<'section'> { setTitlebarActions?: (actions: ReactNode | null) => void } export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) { const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all') const [refreshing, setRefreshing] = useState(false) const [failedImageIds, setFailedImageIds] = useState>(() => new Set()) const [imagePage, setImagePage] = useState(1) const [filePage, setFilePage] = useState(1) const refreshArtifacts = useCallback(async () => { setRefreshing(true) try { const sessions = (await listSessions(30, 1)).sessions const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) const nextArtifacts: ArtifactRecord[] = [] results.forEach((result, index) => { if (result.status !== 'fulfilled') { return } const session = sessions[index] nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages)) }) setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp)) } catch (err) { notifyError(err, 'Artifacts failed to load') setArtifacts([]) } finally { setRefreshing(false) } }, []) useEffect(() => { void refreshArtifacts() }, [refreshArtifacts]) useEffect(() => { if (!setTitlebarActions) { return } setTitlebarActions( ) return () => setTitlebarActions(null) }, [refreshArtifacts, refreshing, setTitlebarActions]) useEffect(() => { setImagePage(1) setFilePage(1) }, [artifacts, kindFilter, query]) const visibleArtifacts = useMemo(() => { if (!artifacts) { return [] } const q = query.trim().toLowerCase() return artifacts.filter(artifact => { if (kindFilter !== 'all' && artifact.kind !== kindFilter) { return false } if (!q) { return true } return ( artifact.label.toLowerCase().includes(q) || artifact.value.toLowerCase().includes(q) || artifact.sessionTitle.toLowerCase().includes(q) ) }) }, [artifacts, kindFilter, query]) const visibleImageArtifacts = useMemo( () => visibleArtifacts.filter(artifact => artifact.kind === 'image'), [visibleArtifacts] ) const visibleFileArtifacts = useMemo( () => visibleArtifacts.filter(artifact => artifact.kind !== 'image'), [visibleArtifacts] ) const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24)) const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100)) const currentImagePage = Math.min(imagePage, imagePageCount) const currentFilePage = Math.min(filePage, filePageCount) const pagedImageArtifacts = useMemo( () => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24), [currentImagePage, visibleImageArtifacts] ) const pagedFileArtifacts = useMemo( () => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100), [currentFilePage, visibleFileArtifacts] ) const counts = useMemo(() => { const all = artifacts || [] return { all: all.length, image: all.filter(artifact => artifact.kind === 'image').length, file: all.filter(artifact => artifact.kind === 'file').length, link: all.filter(artifact => artifact.kind === 'link').length } }, [artifacts]) const copyArtifact = useCallback(async (value: string) => { try { if (window.hermesDesktop?.writeClipboard) { await window.hermesDesktop.writeClipboard(value) } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value) } notify({ kind: 'success', title: 'Copied', message: value }) } catch (err) { notifyError(err, 'Copy failed') } }, []) const openArtifact = useCallback(async (href: string) => { try { if (window.hermesDesktop?.openExternal) { await window.hermesDesktop.openExternal(href) } else { window.open(href, '_blank', 'noopener,noreferrer') } } catch (err) { notifyError(err, 'Open failed') } }, []) const markImageFailed = useCallback((id: string) => { setFailedImageIds(current => { if (current.has(id)) { return current } return new Set(current).add(id) }) }, []) return (

Artifacts

{counts.all} found
setKindFilter('all')} /> setKindFilter('image')} /> setKindFilter('file')} /> setKindFilter('link')} />
setQuery(event.target.value)} placeholder="Search artifacts..." value={query} /> {query && ( )}
{!artifacts ? ( ) : visibleArtifacts.length === 0 ? (
No artifacts found
Generated images and file outputs will appear here as sessions produce them.
) : (
{visibleImageArtifacts.length > 0 && (

Images

{pagedImageArtifacts.map(artifact => ( navigate(sessionRoute(sessionId))} /> ))}
)} {visibleFileArtifacts.length > 0 && (

{kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}

{pagedFileArtifacts.map(artifact => ( navigate(sessionRoute(sessionId))} /> ))}
Name Location Session Actions
)}
)}
) } interface ArtifactsPaginationProps { className?: string itemLabel: string onPageChange: (page: number) => void page: number pageSize: number total: number } function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) { const pageCount = Math.max(1, Math.ceil(total / pageSize)) return (
{pageRangeLabel(total, page, pageSize)} {itemLabel}
{pageCount > 1 && ( onPageChange(Math.max(1, page - 1))} /> {paginationItems(page, pageCount).map((item, index) => ( {item === 'ellipsis' ? ( ) : ( onPageChange(item)} > {item} )} ))} = pageCount} onClick={() => onPageChange(Math.min(pageCount, page + 1))} /> )}
) } function FilterButton({ active, icon: Icon, label, onClick }: { active: boolean icon: typeof Layers3 label: string onClick: () => void }) { return ( ) } interface ArtifactImageCardProps { artifact: ArtifactRecord failedImage: boolean onImageError: (id: string) => void onOpenChat: (sessionId: string) => void } function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) { return (
{!failedImage && ( onImageError(artifact.id)} slot="artifact-media" src={artifact.href} /> )}
{artifact.kind}
{artifact.label}
{artifact.value}
{artifact.sessionTitle} ยท {formatArtifactTime(artifact.timestamp)}
) } interface ArtifactListRowProps { artifact: ArtifactRecord onCopy: (value: string) => void | Promise onOpen: (href: string) => void | Promise onOpenChat: (sessionId: string) => void } function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) { const Icon = artifact.kind === 'file' ? FileText : Link2 return (
{artifact.label}
{artifact.kind}
{artifact.value}
{artifact.sessionTitle}
{formatArtifactTime(artifact.timestamp)}
) }