diff --git a/apps/desktop/src/app/chat/preview-error-close.test.tsx b/apps/desktop/src/app/chat/preview-error-close.test.tsx new file mode 100644 index 0000000000..04894189f3 --- /dev/null +++ b/apps/desktop/src/app/chat/preview-error-close.test.tsx @@ -0,0 +1,74 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { registry } from '@/contrib/registry' +import { $previewTabs, closeRightRail, openPreview, previewTabId } from '@/store/preview' +import { $connection } from '@/store/session' + +import { watchPreviewTiles } from './preview-tile' + +vi.mock('./right-rail/real-profile-consent-dialog', () => ({ + RealProfileConsentDialog: () => null +})) + +// #87411: a persisted preview tab pointing at a deleted file rendered +// "Preview unavailable" with nothing to click. The body's Close must be the +// tab's own Close — the same verb the strip's ✕ and ⌘W run. + +const target = { + kind: 'file' as const, + label: 'report.csv', + path: '/tmp/gone/report.csv', + previewKind: 'text' as const, + source: '/tmp/gone/report.csv', + url: 'file:///tmp/gone/report.csv' +} + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + +beforeAll(() => { + watchPreviewTiles() +}) + +beforeEach(() => { + $connection.set({ mode: 'local' } as never) + desktopWindow.hermesDesktop = { + readFileText: vi.fn(async () => { + throw new Error('Text preview failed: file does not exist.') + }) + } as unknown as Window['hermesDesktop'] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +}) + +afterEach(() => { + cleanup() + closeRightRail() + $connection.set(null) + delete desktopWindow.hermesDesktop + vi.unstubAllGlobals() +}) + +describe('a preview whose file is gone', () => { + it('offers Close in the error state, and it closes the tab', async () => { + openPreview(target) + + const tabId = previewTabId(target) + const pane = registry.getArea('panes').find(contribution => contribution.id === `preview-tile:${tabId}`) + + expect(pane?.render).toBeTypeOf('function') + + await act(async () => { + render(<>{pane!.render!()}) + }) + + expect(await screen.findByText('Preview unavailable')).toBeTruthy() + expect($previewTabs.get().map(tab => tab.id)).toEqual([tabId]) + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect($previewTabs.get()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 154c529d86..534ac881f1 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -13,7 +13,13 @@ import { useStore } from '@nanostores/react' import { findGroup } from '@/components/pane-shell/tree/model' -import { $activeTreeGroup, $layoutTree, revealTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { + $activeTreeGroup, + $layoutTree, + closeTabPane, + revealTreePane, + treePanesWithPrefix +} from '@/components/pane-shell/tree/store' import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { FileTypeIcon } from '@/components/ui/file-type-icon' import { ToolIcon } from '@/components/ui/tool-icon' @@ -282,7 +288,8 @@ const watchPreviewTileMirror = paneMirror<{ id: string }>({ return target?.kind === 'url' || target?.previewKind === 'html' }, - render: tabId => , + // The body's own Close (an error state's way out) is the tab's ✕, verbatim. + render: tabId => closeTabPane(previewPaneId(tabId))} tabId={tabId} />, close: tabId => { forgetBrowserPage(tabId) forgetPreviewConsole(tabId) diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx index 5b1a84e209..8c8aff2047 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx @@ -1,5 +1,5 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { $artifactRegistry, $artifactVersionSelection, artifactPreviewTarget, upsertArtifact } from '@/store/artifacts' @@ -109,4 +109,21 @@ describe('ArtifactPreview', () => { expect(screen.queryByTitle('Dashboard')).toBeNull() }) + + it('gives the missing-artifact state a Close that closes its tab', async () => { + const { artifactId } = register('Dashboard', 'html', '

gone

') + const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)! + const target = artifactPreviewTarget(record) + const onClose = vi.fn() + + $artifactRegistry.set({}) + + await act(async () => { + render() + }) + + expect(screen.getByText('Artifact unavailable')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledOnce() + }) }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx index 07d60393a8..4322a98596 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx @@ -172,7 +172,7 @@ function VersionStepper({ * The target only carries the artifact id — content is read live from the * registry, so an open tab picks up new versions as the model iterates. */ -export function ArtifactPreview({ target }: { target: PreviewTarget }) { +export function ArtifactPreview({ onClose, target }: { onClose?: () => void; target: PreviewTarget }) { const { t } = useI18n() const copy = t.artifactPreview const artifactId = target.url @@ -188,7 +188,13 @@ export function ArtifactPreview({ target }: { target: PreviewTarget }) { const record = useMemo(() => findArtifact(registry, artifactId), [artifactId, registry]) if (!record) { - return + return ( + + ) } const renderable = record.kind === 'html' || record.kind === 'svg' diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 1fbb6b7ac2..c1fd8689a2 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -729,10 +729,13 @@ export function SourceView({ filePath, language, text }: { filePath?: string; la export type PreviewViewMode = 'diff' | 'rendered' | 'source' export function LocalFilePreview({ + onClose, onSelectRendered, reloadKey, target }: { + /** Closes the preview's tab; offered when the file can't be shown. */ + onClose?: () => void /** Present when the pane can render this file live (HTML). Adds the * `rendered` mode to the switcher and routes its selection to the pane. */ onSelectRendered?: () => void @@ -1095,12 +1098,16 @@ export function LocalFilePreview({ return } + // A preview that can't load (the file was moved or deleted) is a dead end, + // so it carries its own way out rather than leaving it to the tab strip. + const closeAction = onClose ? { label: t.common.close, onClick: onClose } : undefined + if (state.error) { - return + return } if (pdfError) { - return + return } if ( diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index b93e06fa8f..958e9cc1b9 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -135,6 +135,9 @@ interface GuestContextMenuParams { interface PreviewPaneProps { embedded?: boolean + /** Closes this preview's tab. Offered by body states that are a dead end + * (a file that no longer exists) so the way out is not only the strip. */ + onClose?: () => void onRestartServer?: (url: string, context?: string) => Promise reloadRequest?: number /** The preview tab this pane renders. Keys the per-tab console store the @@ -237,7 +240,14 @@ function PreviewLoadError({ ) } -export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = 0, tabId, target }: PreviewPaneProps) { +export function PreviewPane({ + embedded = false, + onClose, + onRestartServer, + reloadRequest = 0, + tabId, + target +}: PreviewPaneProps) { const { t } = useI18n() const copy = t.preview.web // The console store belongs to the TAB, not this render: the toggles live on @@ -1391,9 +1401,10 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = )} {!isWebPreview && (target.kind === 'artifact' ? ( - + ) : ( selectRenderMode('preview') : undefined} reloadKey={localReloadKey} target={target} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 625bae8ea4..d831bcbb84 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -6,6 +6,8 @@ import { $previewReloadRequest, $previewTabs } from '@/store/preview' import { PreviewPane } from './preview-pane' interface PreviewTilePaneProps { + /** The tab's own Close, for body states that offer one (a failed load). */ + onClose?: () => void /** The `$previewTabs` id this pane renders. */ tabId: string } @@ -20,7 +22,7 @@ interface PreviewTilePaneProps { * bridge the old rail wrapper used, since the mirror renders this pane with no * props to thread. */ -export function PreviewTilePane({ tabId }: PreviewTilePaneProps) { +export function PreviewTilePane({ onClose, tabId }: PreviewTilePaneProps) { const previewReloadRequest = useStore($previewReloadRequest) const previewTabs = useStore($previewTabs) const restartPreviewServer = useStore($restartPreviewServer) @@ -35,6 +37,7 @@ export function PreviewTilePane({ tabId }: PreviewTilePaneProps) { return (