diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 098e59108aea..b5e15dd30cf1 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,6 +1,7 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' +import { $artifactTabs } from '@/store/artifacts' import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri // file tabs remain (the rail UI falls back to tabs[0]). Gating only on // `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look // broken with a file tab still on screen. - if ($previewTarget.get() || $filePreviewTabs.get().length > 0) { + if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) { return closeActiveRightRailTab() } diff --git a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx new file mode 100644 index 000000000000..c463ee84c41a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx @@ -0,0 +1,222 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + $artifactVersionSelection, + type ArtifactRecord, + selectArtifactVersion +} from '@/store/artifacts' +import { notifyError } from '@/store/notifications' + +import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers' +import { PreviewEmptyState } from './preview-file' + +type ArtifactViewMode = 'preview' | 'source' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +const HEADER_BUTTON_CLASS = + 'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +function VersionStepper({ + current, + onSelect, + total +}: { + current: number + onSelect: (index: number) => void + total: number +}) { + const { t } = useI18n() + const copy = t.artifactPane + + if (total < 2) { + return null + } + + return ( +
+ + + + {copy.versionOf(current + 1, total)} + + + +
+ ) +} + +export function ArtifactPane({ artifactId }: { artifactId: string }) { + const { t } = useI18n() + const copy = t.artifactPane + const registry = useStore($artifactRegistry) + const versionSelection = useStore($artifactVersionSelection) + // View mode is per-pane, ephemeral: renderable artifacts open in preview. + const [userMode, setUserMode] = useState(null) + + // Reset the explicit mode when the pane is reused for another artifact. + useEffect(() => { + setUserMode(null) + }, [artifactId]) + + const record = useMemo(() => { + for (const records of Object.values(registry)) { + const found = records.find(candidate => candidate.id === artifactId) + + if (found) { + return found + } + } + + return null + }, [artifactId, registry]) + + if (!record) { + return + } + + const isRenderable = record.kind === 'html' || record.kind === 'svg' + const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1) + const version = record.versions[versionIndex]! + const isCurrentVersion = versionIndex >= record.versions.length - 1 + const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source' + const downloadName = artifactDownloadName(record.kind, record.language, record.title) + + const modeLabel: Record = { + preview: copy.modePreview, + source: copy.modeSource + } + + return ( +
+
+
+ selectArtifactVersion(artifactId, index)} + total={record.versions.length} + /> + {!isCurrentVersion && ( + + )} +
+ {isRenderable && + (['preview', 'source'] as const).map(candidate => ( + + ))} +
+ + + + + {record.kind === 'html' && window.hermesDesktop && ( + + + + )} +
+
+
+ {mode === 'preview' && isRenderable ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx new file mode 100644 index 000000000000..00ed9a58a58b --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -0,0 +1,107 @@ +import DOMPurify from 'dompurify' +import { useMemo } from 'react' +import ShikiHighlighter from 'react-shiki' + +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' +import type { ArtifactKind } from '@/lib/artifact-detect' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 + +/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row + * windowing as the file preview's SourceView so a 5k-line artifact scrolls + * smoothly, minus the gutter drag/selection machinery (artifact content has no + * on-disk path to reference lines against). */ +export function ArtifactSourceView({ language, text }: { language: string; text: string }) { + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) + + return ( +
+
+ {beforeRows > 0 &&
} + {visibleChunks.map(chunk => ( +
+ + {chunk.text} + +
+ ))} + {afterRows > 0 &&
} +
+
+ ) +} + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no /) rendering + * with sane defaults instead of quirks-mode soup. */ +export function composeArtifactHtml(content: string): string { + if (/]|', + '', + '', + content, + '' + ].join('\n') +} + +/** + * Sandboxed live renderer for html/svg artifact content. + * + * HTML runs in an `