diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx
index 8436240460..01564253c3 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx
@@ -1,7 +1,11 @@
-import { cleanup, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { MarkdownPreview } from './preview-file'
+const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() }))
+
+vi.mock('@/lib/office-files', () => ({ openFileWithSystemApp }))
+
+import { LocalFilePreview, MarkdownPreview } from './preview-file'
// Behavior tests for the .md file preview renderer: input markdown goes
// through normalizeFilePreviewMath -> Streamdown (+ KaTeX math plugin) and must
@@ -51,3 +55,50 @@ describe('MarkdownPreview', () => {
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
})
})
+
+const binaryTarget = {
+ binary: true,
+ byteSize: 8_000,
+ kind: 'file' as const,
+ label: 'report.docx',
+ language: 'text',
+ large: false,
+ path: '/w/report.docx',
+ previewKind: 'binary' as const,
+ source: '/w/report.docx',
+ url: 'file:///w/report.docx'
+}
+
+describe('LocalFilePreview binary refusal', () => {
+ beforeEach(() => {
+ openFileWithSystemApp.mockReset().mockResolvedValue(true)
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('makes the system app the primary action for binary files', () => {
+ render()
+
+ const primary = screen.getByRole('button', { name: 'Open with system app' })
+
+ expect(primary).not.toBeNull()
+ expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull()
+
+ fireEvent.click(primary)
+ expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/report.docx')
+ })
+
+ it('keeps Preview anyway primary for merely large text files', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull()
+ expect(screen.queryByRole('button', { name: 'Open with system app' })).toBeNull()
+ })
+})
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 a16d64f280..198935ad61 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
@@ -34,6 +34,7 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { isComposerChord } from '@/lib/keybinds/chords'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess'
+import { openFileWithSystemApp } from '@/lib/office-files'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { setPreviewDirty } from '@/store/preview-edit'
@@ -1034,11 +1035,19 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
) {
const binary = target.binary || state.binary
const size = target.byteSize || state.byteSize
+ const previewAnyway = () => setForcePreview(true)
return (
setForcePreview(true) }}
+ // Binary files can't render as text — the system default app is the
+ // useful way out; "Preview anyway" stays as the secondary escape hatch.
+ primaryAction={
+ binary
+ ? { label: t.preview.openWithSystemApp, onClick: () => void openFileWithSystemApp(filePath) }
+ : { label: t.preview.previewAnyway, onClick: previewAnyway }
+ }
+ secondaryAction={binary ? { label: t.preview.previewAnyway, onClick: previewAnyway } : undefined}
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
/>