Files
hermes-agent/apps/desktop/src/app/artifacts/index.tsx
T
ethernet 390a1771b2 fix(sec): update npm deps to resolve npm audit warnings
pin brace-expansion to 5.0.8
update concurrently to 10.0.4
update electron-builder to 26.15.3
update eslint to 10.8.0
update eslint-plugin-perfectionist to 5.10.0
update @assistant-ui/react to 0.15.0
update @assistant-ui/react-streamdown to 0.3.8
update radix-ui to 1.6.7
update react-router-dom to react-router@8.3.0 - react-router-dom is no longer a standalone package, it just reexports react-router
remove @radix-ui/react-slot: we import this from `radix-ui`
remove eslint-plugin-react: we imported it, but never actually used it!
2026-07-31 13:42:03 -04:00

688 lines
23 KiB
TypeScript

import type * as React from 'react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
PaginationButton,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { RowButton } from '@/components/ui/row-button'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { resolveBrandIcon } from '@/lib/brand-icon'
import {
ExternalLink,
ExternalLinkIcon,
hostPathLabel,
shortHostLabel,
urlSlugTitleLabel,
useLinkTitle
} from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
import { normalize } from '@/lib/text'
import { fmtDayTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { openSession } from '../open-session'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
ARTIFACT_FILTERS,
type ArtifactFilter,
artifactImageSrc,
type ArtifactRecord,
collectArtifactsForSession
} from './artifact-utils'
function formatArtifactTime(timestamp: number): string {
return fmtDayTime.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
if (total === 0) {
return a.zero
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return a.rangeOf(start, end, total)
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, index) => index + 1)
}
const pages: Array<number | 'ellipsis'> = [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
}
type CellCtx = {
onOpen: (href: string) => void | Promise<void>
onOpenChat: (sessionId: string) => void
}
interface ArtifactColumn {
Cell: React.ComponentType<{ artifact: ArtifactRecord; ctx: CellCtx }>
bodyClassName: string
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
id: 'location' | 'primary' | 'session'
width: (filter: ArtifactFilter) => string
}
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const { t } = useI18n()
const a = t.artifacts
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const [refreshing, setRefreshing] = useState(false)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
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((left, right) => right.timestamp - left.timestamp))
} catch (err) {
notifyError(err, a.failedLoad)
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [a])
useRefreshHotkey(refreshArtifacts)
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
useEffect(() => {
setImagePage(1)
setFilePage(1)
}, [artifacts, kindFilter, query])
const visibleArtifacts = useMemo(() => {
if (!artifacts) {
return []
}
const q = normalize(query)
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]
)
// Rotating placeholder nudges from real data — search matches file paths and
// session titles, not just labels; show it.
const searchHints = useMemo(() => {
if (!artifacts?.length) {
return undefined
}
const extensions = [
...new Set(artifacts.map(artifact => /\.(\w{2,4})$/.exec(artifact.value)?.[1]?.toLowerCase()).filter(Boolean))
].slice(0, 3) as string[]
const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2)
const hints = [
...extensions.map(ext => t.common.tryHint(`.${ext}`)),
...titles.map(title => t.common.tryHint(title))
]
return hints.length > 0 ? hints : undefined
}, [artifacts, t])
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 openArtifact = useCallback(
async (href: string) => {
try {
// A gateway-local file resolves to file:// in remote mode (the file
// lives on the gateway, not this disk). Opening that locally fails —
// and an OAuth remote connection has no query token to build a download
// URL. Fetch the bytes over the authenticated fs bridge instead.
if (isRemoteGateway() && /^file:/i.test(href)) {
await downloadGatewayMediaFile(href)
return
}
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, a.openFailed)
}
},
[a]
)
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
if (current.has(id)) {
return current
}
return new Set(current).add(id)
})
}, [])
// Stable ctx: recreating it (or its onOpenChat closure) every render made
// every artifact cell re-render whenever the page did — and a link cell's
// async title fetch re-rendered the page repeatedly. openArtifact is already
// a useCallback; navigate is stable, so onOpenChat can be too.
const openChat = useCallback((sessionId: string) => openSession(sessionId, navigate), [navigate])
const cellCtx: CellCtx = useMemo(() => ({ onOpen: openArtifact, onOpenChat: openChat }), [openArtifact, openChat])
return (
<PageSearchShell
{...props}
activeTab={kindFilter}
onSearchChange={setQuery}
onTabChange={id => setKindFilter(id as typeof kindFilter)}
searchHidden={counts.all === 0}
searchHints={searchHints}
searchPlaceholder={a.search}
searchTrailingAction={
<Tip label={refreshing ? a.refreshing : a.refresh}>
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-titlebar"
variant="ghost"
>
{refreshing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</Tip>
}
searchValue={query}
tabs={[
{ id: 'all', label: a.tabAll, meta: artifacts ? counts.all : null },
{ id: 'image', label: a.tabImages, meta: artifacts ? counts.image : null },
{ id: 'file', label: a.tabFiles, meta: artifacts ? counts.file : null },
{ id: 'link', label: a.tabLinks, meta: artifacts ? counts.link : null }
]}
>
{!artifacts ? (
<PageLoader label={a.indexing} />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
<div className="flex flex-col gap-3 px-3 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={a.itemsImage}
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
total={visibleImageArtifacts.length}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] items-start gap-2 pt-1.5">
{pagedImageArtifacts.map(artifact => (
<ArtifactImageCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => openSession(sessionId, navigate)}
/>
))}
</div>
</section>
)}
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter, a)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
)}
</div>
</div>
)}
</PageSearchShell>
)
}
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 { t } = useI18n()
const a = t.artifacts
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
<PaginationContent className="gap-0.5">
<PaginationItem>
<PaginationPrevious disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))} />
</PaginationItem>
{paginationItems(page, pageCount).map((item, index) => (
<PaginationItem key={`${item}-${index}`}>
{item === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={a.goToPage(itemLabel, item)}
isActive={page === item}
onClick={() => onPageChange(item)}
>
{item}
</PaginationButton>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
disabled={page >= pageCount}
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)
}
interface ArtifactImageCardProps {
artifact: ArtifactRecord
failedImage: boolean
onImageError: (id: string) => void
onOpenChat: (sessionId: string) => void
}
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
const { t } = useI18n()
const a = t.artifacts
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
const [src, setSrc] = useState('')
useEffect(() => {
let active = true
setSrc('')
void artifactImageSrc(artifact.value, artifact.href)
.then(nextSrc => {
if (active) {
setSrc(nextSrc)
}
})
.catch(() => {
if (active) {
onImageError(artifact.id)
}
})
return () => {
active = false
}
}, [artifact.href, artifact.id, artifact.value, onImageError])
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div
className={cn(
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
failedImage && 'cursor-default'
)}
>
{!failedImage && src && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
onError={() => onImageError(artifact.id)}
slot="artifact-media"
src={src}
/>
)}
</div>
<div className="space-y-1.5 p-2">
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<FileImage className="size-3" />
{kindLabel}
</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label}
</div>
<div className="mt-0.5 truncate text-[0.625rem] text-(--ui-text-tertiary)">{artifact.value}</div>
</div>
<div className="truncate text-[0.625rem] text-(--ui-text-tertiary)">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" />
{a.chat}
</Button>
</div>
</div>
</article>
)
}
// Single click target for any row cell. External URLs render as <ExternalLink>;
// local actions render as <button>. Padding lives here, NOT on the <td>, so
// the entire cell area is hoverable and clickable in both branches.
function ArtifactCellAction({
children,
href,
onClick,
title
}: {
children: React.ReactNode
href?: string
onClick?: () => void
title?: string
}) {
if (href) {
return (
<ExternalLink
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
href={href}
showExternalIcon={false}
title={title}
>
{children}
</ExternalLink>
)
}
return (
<RowButton
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
onClick={onClick}
>
{children}
</RowButton>
)
}
const PrimaryCell = memo(function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const isLink = artifact.kind === 'link'
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
const Icon = brand ?? (isLink ? Link2 : FileText)
const fetchedTitle = useLinkTitle(isLink ? artifact.href : null)
const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label
return (
<ArtifactCellAction
href={isLink ? artifact.href : undefined}
onClick={isLink ? undefined : () => void ctx.onOpen(artifact.href)}
title={label}
>
<span className="mt-0.5 grid size-6 shrink-0 place-items-center self-start rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Icon className="size-3.5" />
</span>
<span className={cn('min-w-0 flex-1', isLink ? 'wrap-anywhere' : 'truncate')}>
{label}
{isLink && <ExternalLinkIcon />}
</span>
</ArtifactCellAction>
)
})
const LocationCell = memo(function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const { t } = useI18n()
const isLink = artifact.kind === 'link'
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<Tip label={artifact.value}>
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
>
{value}
</div>
</Tip>
<CopyButton
appearance="icon"
buttonSize="icon-xs"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/location:opacity-100"
iconClassName="size-3.5"
label={copyLabel}
text={artifact.value}
title={copyLabel}
/>
</div>
)
})
const SessionCell = memo(function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
return (
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{artifact.sessionTitle}</span>
<span className="truncate text-[0.6875rem] font-normal text-(--ui-text-tertiary)">
{formatArtifactTime(artifact.timestamp)}
</span>
</span>
</ArtifactCellAction>
)
})
const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: (filter, a) =>
filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
{
Cell: LocationCell,
bodyClassName: 'px-2.5 py-1.5',
header: (filter, a) =>
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
id: 'location',
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
},
{
Cell: SessionCell,
bodyClassName: 'p-0',
header: (_filter, a) => a.colSession,
id: 'session',
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
}
]
function ArtifactTable({
artifacts,
ctx,
filter
}: {
artifacts: readonly ArtifactRecord[]
ctx: CellCtx
filter: ArtifactFilter
}) {
const { t } = useI18n()
return (
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<tr>
{ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
{col.header(filter, t.artifacts)}
</th>
))}
</tr>
</thead>
<tbody>
{artifacts.map(artifact => (
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {
const Cell = col.Cell
return (
<td className={cn('align-middle', col.bodyClassName)} key={col.id}>
<Cell artifact={artifact} ctx={ctx} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
)
}