Files
hermes-agent/apps/desktop/src/components/chat/zoomable-image.tsx
T
Brooklyn Nicholson 4dd9732a94 feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes
- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.
2026-05-11 16:34:25 -04:00

168 lines
4.9 KiB
TypeScript

'use client'
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
}
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
}
return
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
data-slot={slot ?? 'aui_zoomable-image'}
>
<button
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{lightbox}
</>
)
}
function ImageActionButton({
onClick,
saving,
variant
}: {
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
)}
disabled={saving}
onClick={event => {
event.stopPropagation()
void onClick()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)
}