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 (