{parts.map((part, i) =>
part.type === 'html' ? (
diff --git a/desktop/src/components/ui/FileTypeIcon.test.tsx b/desktop/src/components/ui/FileTypeIcon.test.tsx
new file mode 100644
index 00000000..e1d74ae4
--- /dev/null
+++ b/desktop/src/components/ui/FileTypeIcon.test.tsx
@@ -0,0 +1,39 @@
+import '@testing-library/jest-dom'
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { FileTypeIcon } from './FileTypeIcon'
+
+describe('FileTypeIcon', () => {
+ it.each([
+ ['报告.pdf', 'pdf', 'PDF'],
+ ['README.md', 'markdown', 'MD'],
+ ['简报.docx', 'docx', 'DOCX'],
+ ['C:\\out\\data.xlsx', 'xlsx', 'XLSX'],
+ ['table.xlsm', 'xlsx', 'XLSM'],
+ ['deck.pptx', 'pptx', 'PPTX'],
+ ['bundle.zip', 'zip', 'ZIP'],
+ ['main.rs', 'code', 'RS'],
+ ['notes.mdx', 'markdown', 'MDX'],
+ ['events.jsonl', 'json', 'JSONL'],
+ ])('renders a labelled icon for %s', (path, kind, label) => {
+ const { container } = render(
)
+ const root = container.firstElementChild as HTMLElement
+ expect(root).toHaveAttribute('data-file-type', kind)
+ expect(root).toHaveAttribute('aria-hidden', 'true')
+ expect(root.querySelector('svg')).not.toBeNull()
+ expect(root.textContent?.toUpperCase()).toContain(label)
+ })
+
+ it('falls back to a neutral icon for extensionless and unknown files', () => {
+ for (const path of ['Makefile', 'weird.zzz']) {
+ const { container } = render(
)
+ expect(container.firstElementChild).toHaveAttribute('data-file-type', 'file')
+ expect(container.querySelector('svg')).not.toBeNull()
+ }
+ })
+
+ it('keeps markdown blue, distinct from the grey library default', () => {
+ const { container } = render(
)
+ expect(container.innerHTML.toLowerCase()).toContain('#3b6fe0')
+ })
+})
diff --git a/desktop/src/components/ui/FileTypeIcon.tsx b/desktop/src/components/ui/FileTypeIcon.tsx
new file mode 100644
index 00000000..e83beaf4
--- /dev/null
+++ b/desktop/src/components/ui/FileTypeIcon.tsx
@@ -0,0 +1,88 @@
+import { FileIcon, defaultStyles, type FileIconProps } from 'react-file-icon'
+import { describeFileType } from '@/lib/openWithItems'
+import { FILE_GROUP_COLORS, shadeHex } from '@/lib/fileTypePalette'
+
+type IconStyle = Partial
+
+const EXTENSION_ALIASES: Record = {
+ docm: 'docx',
+ pages: 'docx',
+ xlsm: 'xlsx',
+ numbers: 'xlsx',
+ pptm: 'pptx',
+ key: 'pptx',
+ markdown: 'md',
+ mdx: 'md',
+ log: 'txt',
+ rst: 'txt',
+ yaml: 'yml',
+ tsx: 'ts',
+ mjs: 'js',
+ cjs: 'js',
+ jsonl: 'json',
+ xhtml: 'html',
+ tgz: 'gz',
+ bz2: 'gz',
+ xz: 'gz',
+ '7z': '7zip',
+ opus: 'ogg',
+ avif: 'png',
+ webp: 'png',
+ ico: 'png',
+}
+
+/** Glyph used when the library has no style for the extension. */
+const GROUP_GLYPHS: Record = {
+ picture_as_pdf: 'acrobat',
+ docs: 'document',
+ markdown: 'document',
+ text_snippet: 'document',
+ table_chart: 'spreadsheet',
+ slideshow: 'presentation',
+ folder_zip: 'compressed',
+ audio_file: 'audio',
+ video_file: 'video',
+ html: 'code',
+ image: 'image',
+ code: 'code',
+}
+
+function resolveIconStyle(path: string): { label: string; style: IconStyle; kind: string } {
+ const { ext, icon } = describeFileType(path)
+ const label = ext.toLowerCase()
+ const key = EXTENSION_ALIASES[label] ?? label
+ const known = (defaultStyles as Record)[key]
+ const color = FILE_GROUP_COLORS[icon] ?? FILE_GROUP_COLORS.insert_drive_file!
+ const style: IconStyle = {
+ type: known?.type ?? GROUP_GLYPHS[icon] ?? 'document',
+ color,
+ labelColor: color,
+ foldColor: shadeHex(color, 0.18),
+ glyphColor: 'rgba(255,255,255,0.4)',
+ labelUppercase: true,
+ }
+ const kind = key === 'md' ? 'markdown' : known ? key : icon === 'insert_drive_file' ? 'file' : icon === 'code' || icon === 'html' ? 'code' : icon
+ return { label, style, kind }
+}
+
+export type FileTypeIconProps = {
+ path: string
+ /** Rendered width in px; the glyph keeps the library's document aspect ratio. */
+ size?: number
+ className?: string
+}
+
+/** Folded-corner document glyph with a colored extension label, from react-file-icon. */
+export function FileTypeIcon({ path, size = 28, className = '' }: FileTypeIconProps) {
+ const { label, style, kind } = resolveIconStyle(path)
+ return (
+
+
+
+ )
+}
diff --git a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts
index 876ddade..b4d1c35c 100644
--- a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts
+++ b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts
@@ -210,6 +210,60 @@ describe('the shape of a sheet', () => {
expect(grid.columnWidths[3]).toBe(72) // not said
})
+ it('widens a column the file gives no width to fit its numbers, so a value is never cut to "12,000…"', async () => {
+ const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['项目', '预算(元)'], ['服务器', '1,234,567,890.00'], ['差旅', '8,000.00']] }]))
+
+ // 16 digits/separators at ~6.6px, plus the cell's padding.
+ expect(grid.columnWidths[1]).toBeGreaterThanOrEqual(120)
+ // A short CJK label still gets the default floor, not a sliver.
+ expect(grid.columnWidths[0]).toBeGreaterThanOrEqual(72)
+ })
+
+ it('leaves a short column at the default width', async () => {
+ const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b'], ['c', 'd']] }]))
+
+ expect(grid.columnWidths).toEqual([72, 72])
+ })
+
+ it('caps how far a long text can widen a column', async () => {
+ const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['x'.repeat(500)]] }]))
+
+ expect(grid.columnWidths[0]).toBe(360)
+ })
+
+ it('does not widen a column for a merged heading that spills over its neighbours', async () => {
+ const grid = await firstGrid(workbookOf([{
+ name: 'A',
+ rows: [['Quarterly budget summary for the whole group', '', ''], ['a', 'b', 'c']],
+ merges: [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }],
+ }]))
+
+ expect(grid.columnWidths).toEqual([72, 72, 72])
+ })
+
+ it('widens a stated column for a number that would otherwise be cut, as Excel would show ####', async () => {
+ // 12 characters is what the file asked for; the preview font needs more for this figure.
+ const grid = await firstGrid(workbookOf([{
+ name: 'A',
+ rows: [['预算', 1234567890.12]],
+ columns: [{ wpx: 40 }, { wpx: 40 }],
+ }]))
+
+ expect(grid.columnWidths[1]).toBeGreaterThan(40)
+ // The text column keeps the width the file set: clipping text is what Excel does too.
+ expect(grid.columnWidths[0]).toBe(40)
+ })
+
+ it('never widens a column the file sets, nor a hidden one', async () => {
+ const grid = await firstGrid(workbookOf([{
+ name: 'A',
+ rows: [['x'.repeat(100), 'y'.repeat(100)]],
+ columns: [{ wpx: 90 }, { hidden: true }],
+ }]))
+
+ expect(grid.columnWidths).toEqual([90, 0])
+ })
+
it('keeps column widths within what can be drawn', async () => {
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b']], columns: [{ wpx: 2 }, { wpx: 9000 }] }]))
diff --git a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts
index ee4b4d09..7380c6dd 100644
--- a/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts
+++ b/desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts
@@ -91,6 +91,9 @@ export type SpreadsheetEngineOptions = {
const DEFAULT_COLUMN_WIDTH = 72
const MIN_COLUMN_WIDTH = 28
const MAX_COLUMN_WIDTH = 480
+/** A column grows to fit its text or numbers, up to this. */
+const MAX_AUTO_COLUMN_WIDTH = 360
+const CELL_PADDING = 18
const MIN_ROW_HEIGHT = 16
const MAX_ROW_HEIGHT = 400
@@ -120,6 +123,31 @@ function cellAlign(cell: SheetJs.CellObject): CellAlign {
return 'left'
}
+/** Rendered width of cell text at the grid's 12px size; a CJK character is about twice a Latin one. */
+function textWidth(text: string): number {
+ let width = 0
+ for (const char of text) width += (char.codePointAt(0) ?? 0) >= 0x2e80 ? 12 : 6.6
+ return width
+}
+
+/**
+ * How wide a column must be to show its cells whole. Excel would draw a number that does
+ * not fit as `####`; a preview that cuts it to `12,000…` hides the value just the same, and
+ * the preview's font is wider than the one the width was chosen for. `numbersOnly` is for a
+ * column whose width the file states: its text may still be clipped, as Excel clips it, but
+ * a number may not. A merged cell's text spills over its span, so it takes no part.
+ */
+function fitColumnWidth(cells: Grid['cells'], column: number, spilling: ReadonlySet, numbersOnly: boolean): number {
+ let widest = 0
+ for (let row = 0; row < cells.length; row += 1) {
+ const cell = cells[row]?.[column]
+ if (!cell || spilling.has(`${row}:${column}`)) continue
+ if (numbersOnly && cell.align !== 'right') continue
+ widest = Math.max(widest, textWidth(cell.text))
+ }
+ return widest === 0 ? 0 : Math.min(Math.ceil(widest + CELL_PADDING), MAX_AUTO_COLUMN_WIDTH)
+}
+
/** Turn a dense SheetJS worksheet into the grid the viewer draws, and let SheetJS's objects go. */
function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
const data = ((sheet as SheetJs.DenseSheet)['!data'] ?? []) as Array | undefined>
@@ -153,11 +181,19 @@ function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
cells.push(line)
}
+ const spilling = new Set()
+ for (const range of sheet['!merges'] ?? []) {
+ if (range.e.c > range.s.c) spilling.add(`${range.s.r}:${range.s.c}`)
+ }
+
const columnInfo = sheet['!cols'] ?? []
const columnWidths = Array.from({ length: columns }, (_, column) => {
const info = columnInfo[column]
if (info?.hidden) return 0
- const width = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : DEFAULT_COLUMN_WIDTH)
+ const stated = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : undefined)
+ const width = stated === undefined
+ ? Math.max(DEFAULT_COLUMN_WIDTH, fitColumnWidth(cells, column, spilling, false))
+ : Math.max(stated, fitColumnWidth(cells, column, spilling, true))
return clamp(Math.round(width), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
})
diff --git a/desktop/src/lib/authedImage.test.ts b/desktop/src/lib/authedImage.test.ts
new file mode 100644
index 00000000..28b0c82b
--- /dev/null
+++ b/desktop/src/lib/authedImage.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const apiGetBlob = vi.hoisted(() => vi.fn())
+
+vi.mock('../api/client', () => ({
+ apiGetBlob,
+ getBaseUrl: () => 'http://127.0.0.1:3456',
+}))
+
+import { attachAuthedImageFallback, fetchServerImageBlobUrl } from './authedImage'
+
+beforeEach(() => {
+ apiGetBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
+ Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:http://localhost/x'), configurable: true, writable: true })
+})
+
+afterEach(() => {
+ Reflect.deleteProperty(URL, 'createObjectURL')
+})
+
+describe('fetchServerImageBlobUrl', () => {
+ it('fetches a local-server image through the credentialed client and returns an object URL', async () => {
+ const src = `http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`
+
+ await expect(fetchServerImageBlobUrl(src)).resolves.toBe('blob:http://localhost/x')
+
+ // Path and query only: the client prepends its own base URL and credential.
+ expect(apiGetBlob).toHaveBeenCalledWith(`/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`)
+ })
+
+ it('serves a session preview URL the same way', async () => {
+ await fetchServerImageBlobUrl('http://127.0.0.1:3456/preview-fs/s1/out/frame.png')
+
+ expect(apiGetBlob).toHaveBeenCalledWith('/preview-fs/s1/out/frame.png')
+ })
+
+ it.each([
+ 'https://example.com/cat.png',
+ 'http://127.0.0.1:9999/api/filesystem/file?path=%2Fetc%2Fhosts',
+ 'http://localhost:3456/api/filesystem/file?path=%2Ftmp%2Fa.png',
+ ])('never sends the credential to another origin (%s)', async (src) => {
+ await expect(fetchServerImageBlobUrl(src)).rejects.toThrow('Not a local-server image URL')
+
+ expect(apiGetBlob).not.toHaveBeenCalled()
+ })
+
+ it('propagates a refusal so the caller can show the failure notice', async () => {
+ apiGetBlob.mockRejectedValue(new Error('403'))
+
+ await expect(fetchServerImageBlobUrl('http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png')).rejects.toThrow('403')
+ })
+})
+
+describe('attachAuthedImageFallback', () => {
+ const localSrc = 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png'
+
+ function mount(src: string) {
+ const container = document.createElement('div')
+ container.innerHTML = `
`
+ document.body.appendChild(container)
+ const detach = attachAuthedImageFallback(container)
+ return { container, image: container.querySelector('img')!, detach }
+ }
+
+ beforeEach(() => {
+ Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
+ })
+
+ it('swaps a refused local image for an authenticated copy', async () => {
+ const { image, detach } = mount(localSrc)
+
+ image.dispatchEvent(new Event('error'))
+
+ await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
+ detach()
+ })
+
+ it('retries each image only once, so a broken body does not loop', async () => {
+ const { image, detach } = mount(localSrc)
+ image.dispatchEvent(new Event('error'))
+ await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
+
+ image.dispatchEvent(new Event('error'))
+
+ expect(apiGetBlob).toHaveBeenCalledTimes(1)
+ detach()
+ })
+
+ it.each(['https://example.com/cat.png', 'data:image/png;base64,AAAA'])('leaves %s alone', async (src) => {
+ const { image, detach } = mount(src)
+
+ image.dispatchEvent(new Event('error'))
+ await Promise.resolve()
+
+ expect(apiGetBlob).not.toHaveBeenCalled()
+ expect(image.getAttribute('src')).toBe(src)
+ detach()
+ })
+
+ it('frees its object URLs and stops listening once detached', async () => {
+ const { image, detach } = mount(localSrc)
+ image.dispatchEvent(new Event('error'))
+ await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
+
+ detach()
+
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/x')
+ const other = document.createElement('img')
+ other.setAttribute('src', localSrc)
+ image.parentElement!.appendChild(other)
+ other.dispatchEvent(new Event('error'))
+ expect(apiGetBlob).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/desktop/src/lib/authedImage.ts b/desktop/src/lib/authedImage.ts
new file mode 100644
index 00000000..59cb3898
--- /dev/null
+++ b/desktop/src/lib/authedImage.ts
@@ -0,0 +1,58 @@
+import { apiGetBlob, getBaseUrl } from '../api/client'
+
+/**
+ * Fetch a local-server image with the app's credential and hand back a `blob:` URL.
+ *
+ * A bare `
` cannot send `Authorization`, and the server refuses a
+ * credential-less cross-site subresource load (it cannot tell the page from a
+ * hostile one). The packaged desktop shell works around that by injecting the
+ * header for an allowlist of media routes; a plain browser tab — the web UI, a LAN
+ * or remote H5 client — has no such hook, so its images go through this fetch.
+ *
+ * Only URLs on the local server's own origin are fetched: the credential must not
+ * follow an arbitrary image URL to another host.
+ */
+export async function fetchServerImageBlobUrl(src: string): Promise {
+ const base = new URL(getBaseUrl())
+ const target = new URL(src, base)
+ if (target.origin !== base.origin) throw new Error('Not a local-server image URL')
+ const blob = await apiGetBlob(`${target.pathname}${target.search}`)
+ return URL.createObjectURL(blob)
+}
+
+/**
+ * The same fallback for images that arrive as sanitized HTML (rendered Markdown),
+ * where there is no component to hang `onError` on: one capturing listener on the
+ * container retries a failed `
` per element, once. Returns the detach function,
+ * which also frees the object URLs it handed out.
+ */
+export function attachAuthedImageFallback(container: HTMLElement): () => void {
+ const objectUrls: string[] = []
+ let detached = false
+
+ const onError = (event: Event) => {
+ const image = event.target
+ if (!(image instanceof HTMLImageElement) || image.dataset.authedFallback) return
+ const src = image.getAttribute('src')
+ if (!src || src.startsWith('blob:') || src.startsWith('data:')) return
+ image.dataset.authedFallback = '1'
+ void fetchServerImageBlobUrl(src).then((url) => {
+ if (detached) {
+ URL.revokeObjectURL(url)
+ return
+ }
+ objectUrls.push(url)
+ image.src = url
+ }).catch(() => {
+ // Not a local-server image, or refused: the browser's own broken-image state stands.
+ })
+ }
+
+ // `error` does not bubble, so the listener has to capture.
+ container.addEventListener('error', onError, true)
+ return () => {
+ detached = true
+ container.removeEventListener('error', onError, true)
+ for (const url of objectUrls) URL.revokeObjectURL(url)
+ }
+}
diff --git a/desktop/src/lib/fileTypePalette.ts b/desktop/src/lib/fileTypePalette.ts
new file mode 100644
index 00000000..f5735a1f
--- /dev/null
+++ b/desktop/src/lib/fileTypePalette.ts
@@ -0,0 +1,28 @@
+/**
+ * Brand colors for file-type icons, keyed by `describeFileType().icon`.
+ *
+ * These are identity colors (PDF red, Word blue, ...) that must read the same in
+ * every theme, and react-file-icon writes them into SVG attributes where CSS
+ * variables are not reliable, so they live here rather than in theme tokens.
+ */
+export const FILE_GROUP_COLORS: Record = {
+ picture_as_pdf: '#D93831',
+ docs: '#2C5898',
+ markdown: '#3B6FE0',
+ text_snippet: '#667085',
+ table_chart: '#1A754C',
+ slideshow: '#D14423',
+ folder_zip: '#B7791F',
+ audio_file: '#AD477C',
+ video_file: '#6655B8',
+ html: '#C05D2C',
+ image: '#24899A',
+ code: '#7656B5',
+ insert_drive_file: '#667085',
+}
+
+/** Darken a #rrggbb color by `amount` (0-1), used for the folded corner. */
+export function shadeHex(hex: string, amount: number): string {
+ const channel = (offset: number) => Math.round(parseInt(hex.slice(offset, offset + 2), 16) * (1 - amount)).toString(16).padStart(2, '0')
+ return `#${channel(1)}${channel(3)}${channel(5)}`
+}
diff --git a/desktop/src/lib/openPreviewLink.test.ts b/desktop/src/lib/openPreviewLink.test.ts
index c62bd943..1f10c245 100644
--- a/desktop/src/lib/openPreviewLink.test.ts
+++ b/desktop/src/lib/openPreviewLink.test.ts
@@ -1,7 +1,14 @@
import { waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const openPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
+// The server's verdict on a document the string check cannot place. Refusing
+// (the 403 a path outside the workspace gets) is the default.
+const getWorkspaceFile = vi.hoisted(() => vi.fn())
+
+vi.mock('../api/sessions', () => ({
+ sessionsApi: { getWorkspaceFile },
+}))
vi.mock('./desktopHost', () => ({
getDesktopHost: () => ({
@@ -24,13 +31,23 @@ vi.mock('./workspace/openTarget', () => ({
vi.mock('../stores/workspaceContentStore', () => ({
useWorkspaceContentStore: {
- getState: () => ({ statusBySession: { s1: { workDir: '/work' } } }),
+ getState: () => ({
+ statusBySession: {
+ s1: { workDir: '/work' },
+ // The server reports the canonical workdir; the chat writes the symlinked form.
+ s3: { workDir: '/private/tmp/app' },
+ },
+ }),
},
}))
import { openPreviewLink } from './openPreviewLink'
import { workspaceOpen } from './workspace/openTarget'
+beforeEach(() => {
+ getWorkspaceFile.mockReset().mockRejectedValue(new Error('403 Path is outside workspace'))
+})
+
afterEach(() => {
openPath.mockReset().mockResolvedValue(undefined)
vi.mocked(workspaceOpen.file).mockClear()
@@ -101,15 +118,44 @@ describe('openPreviewLink for a document the workspace can draw', () => {
expect(openPath).not.toHaveBeenCalled()
})
- it('keeps an absolute document with the system application while the workdir is still unknown', async () => {
- // Session s2 has no workspace status yet. The system app is what this link
- // always did, and it always works; a preview tab that may 403 is a gamble.
+ it('keeps an absolute document with the system application while the workdir is still unknown and the server refuses it', async () => {
+ // Session s2 has no workspace status yet, so the string check cannot place the
+ // path; the server's refusal is what sends it to the system app.
openPreviewLink('/Users/x/Documents/thesis.pdf', 's2')
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/Users/x/Documents/thesis.pdf'))
+ expect(getWorkspaceFile).toHaveBeenCalledWith('s2', '/Users/x/Documents/thesis.pdf')
expect(workspaceOpen.file).not.toHaveBeenCalled()
})
+ it('previews an absolute document the server accepts even though the workdir has not loaded yet', async () => {
+ getWorkspaceFile.mockResolvedValue({ state: 'ok', path: '/work/out/thesis.pdf' })
+
+ openPreviewLink('/work/out/thesis.pdf', 's2')
+
+ await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s2', '/work/out/thesis.pdf', {}))
+ expect(openPath).not.toHaveBeenCalled()
+ })
+
+ it('previews a document written through a symlink of the canonical workdir (/tmp vs /private/tmp)', async () => {
+ // Regression: the output card for `/tmp/app/report.pdf` opened the system app,
+ // because the session's canonical workdir is `/private/tmp/app` and the string
+ // comparison called the document outside the workspace.
+ getWorkspaceFile.mockResolvedValue({ state: 'ok', path: '/tmp/app/report.pdf' })
+
+ expect(openPreviewLink('/tmp/app/report.pdf', 's3')).toBe(true)
+
+ await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s3', '/tmp/app/report.pdf', {}))
+ expect(getWorkspaceFile).toHaveBeenCalledWith('s3', '/tmp/app/report.pdf')
+ expect(openPath).not.toHaveBeenCalled()
+ })
+
+ it('does not ask the server when the path is plainly inside the workdir', () => {
+ openPreviewLink('/work/out/thesis.pdf', 's1')
+
+ expect(getWorkspaceFile).not.toHaveBeenCalled()
+ })
+
it('still previews a relative document while the workdir is unknown', () => {
openPreviewLink('out/thesis.pdf', 's2')
diff --git a/desktop/src/lib/openPreviewLink.ts b/desktop/src/lib/openPreviewLink.ts
index 9c344748..bf4f5bc1 100644
--- a/desktop/src/lib/openPreviewLink.ts
+++ b/desktop/src/lib/openPreviewLink.ts
@@ -4,20 +4,25 @@ import { getDesktopHost } from './desktopHost'
import { isWithinWorkDir } from './assistantOutputTargets'
import { isWorkspaceDocumentFile } from './fileCapabilities'
import { useWorkspaceContentStore } from '../stores/workspaceContentStore'
+import { sessionsApi } from '../api/sessions'
import { workspaceOpen } from './workspace/openTarget'
import { openLocalFileWithSystem, reportOpenFailure, resolveAbsoluteOpenPath } from './systemFileOpen'
/**
- * Whether the workspace preview can actually reach this document.
+ * Cheap, synchronous guess at whether the workspace preview can reach this document.
*
* The file routes serve the session workdir and the roots registered for files a
* turn changed; a document elsewhere (`~/thesis.docx`, another drive) would open
- * a tab that goes straight to a 403. Those keep going to the system application,
- * as every document link did before the workspace could render them. A relative
- * path is resolved against the workdir by the server, which is reachable unless its
- * `../` climbs out of the workdir — `../shared/spec.pdf` is as far outside as its
- * absolute form. An absolute path while the workdir is still unknown is treated as
- * unreachable: the system app is the outcome the user had before, and it always works.
+ * a tab that goes straight to a 403. A relative path is resolved against the
+ * workdir by the server, which is reachable unless its `../` climbs out of the
+ * workdir — `../shared/spec.pdf` is as far outside as its absolute form.
+ *
+ * This is only a string comparison, so a `false` is not final: the workdir the
+ * server reports is canonical (`/private/tmp/app`), while the path in the chat is
+ * whatever form the model wrote (`/tmp/app/report.pdf`). Symlinked folders, registered
+ * access roots and a workdir that has not loaded yet all look "outside" here. Only
+ * the server, which resolves real paths, can settle those — see
+ * {@link documentReachableOnServer}.
*/
function documentReachableInWorkspace(path: string, workDir: string | undefined): boolean {
if (!isRootedLocalPath(path)) return !workDir || isWithinWorkDir(resolveAbsoluteOpenPath(path, workDir), workDir)
@@ -25,6 +30,20 @@ function documentReachableInWorkspace(path: string, workDir: string | undefined)
return isWithinWorkDir(path, workDir)
}
+/**
+ * Ask the server whether it will serve this document in the workspace. It resolves
+ * real paths and registered roots, so it answers what the string check above cannot;
+ * a refusal (403) or an unreachable server means "keep it with the system app".
+ */
+async function documentReachableOnServer(sessionId: string, path: string): Promise {
+ try {
+ await sessionsApi.getWorkspaceFile(sessionId, path)
+ return true
+ } catch {
+ return false
+ }
+}
+
/**
* Route a clicked link the way the chat surface always has: a loopback URL opens
* the workbench browser on the right, a workspace file opens its preview, and a
@@ -48,12 +67,18 @@ export function openPreviewLink(href: string, sessionId: string): boolean {
serverBaseUrl: getServerBaseUrl(),
openBrowser: (id, url) => { workspaceOpen.browser(id, url) },
openFilePreview: (id, path, reveal) => {
- if (isWorkspaceDocumentFile(path) && !documentReachableInWorkspace(path, currentWorkDir())) {
- openSystemFile(path)
+ const openInWorkspace = () => {
+ workspaceOpen.file(id, path, {
+ ...(reveal ? { line: reveal.line, ...(reveal.column ? { column: reveal.column } : {}) } : {}),
+ })
+ }
+ if (!isWorkspaceDocumentFile(path) || documentReachableInWorkspace(path, currentWorkDir())) {
+ openInWorkspace()
return
}
- workspaceOpen.file(id, path, {
- ...(reveal ? { line: reveal.line, ...(reveal.column ? { column: reveal.column } : {}) } : {}),
+ void documentReachableOnServer(id, path).then((reachable) => {
+ if (reachable) openInWorkspace()
+ else openSystemFile(path)
})
},
openSystemFile,
diff --git a/desktop/src/lib/openWithItems.ts b/desktop/src/lib/openWithItems.ts
index 89e474a4..a8b90ea2 100644
--- a/desktop/src/lib/openWithItems.ts
+++ b/desktop/src/lib/openWithItems.ts
@@ -17,7 +17,7 @@ const FILE_TYPE_RULES: Array<{ re: RegExp; key: string; icon: string }> = [
{ re: /\.(mp4|mov|m4v|webm|mkv|avi)$/i, key: 'video', icon: 'video_file' },
{ re: /\.(html?|xhtml)$/i, key: 'web', icon: 'html' },
{ re: /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i, key: 'image', icon: 'image' },
- { re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml|xml|sql)$/i, key: 'code', icon: 'code' },
+ { re: /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonl|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml|xml|sql)$/i, key: 'code', icon: 'code' },
]
export function describeFileType(path: string): FileTypeInfo {
diff --git a/desktop/src/lib/useAuthedImageFallback.ts b/desktop/src/lib/useAuthedImageFallback.ts
new file mode 100644
index 00000000..ee0227ed
--- /dev/null
+++ b/desktop/src/lib/useAuthedImageFallback.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { fetchServerImageBlobUrl } from './authedImage'
+
+/**
+ * Let an `
` that a plain request could not load try once more with the app's
+ * credential (see {@link fetchServerImageBlobUrl}).
+ *
+ * Spread `src` and `onError` onto the image. `onFailure` runs only when the
+ * authenticated attempt has also failed — a missing or denied file, or a body that
+ * is not an image — so callers keep their own failure notice for real failures and
+ * never flash it for a request that was merely missing a header.
+ */
+export function useAuthedImageFallback(src: string | undefined, onFailure?: () => void) {
+ const [resolved, setResolved] = useState<{ source: string; url: string } | null>(null)
+ const triedSource = useRef(undefined)
+ const alive = useRef(true)
+ const objectUrls = useRef([])
+ const onFailureRef = useRef(onFailure)
+ onFailureRef.current = onFailure
+
+ useEffect(() => {
+ alive.current = true
+ const owned = objectUrls.current
+ return () => {
+ alive.current = false
+ for (const url of owned) URL.revokeObjectURL(url)
+ owned.length = 0
+ }
+ }, [])
+
+ const onError = useCallback(() => {
+ if (!src || triedSource.current === src) {
+ onFailureRef.current?.()
+ return
+ }
+ triedSource.current = src
+ void fetchServerImageBlobUrl(src).then((url) => {
+ if (!alive.current) {
+ URL.revokeObjectURL(url)
+ return
+ }
+ objectUrls.current.push(url)
+ setResolved({ source: src, url })
+ }).catch(() => {
+ if (alive.current && triedSource.current === src) onFailureRef.current?.()
+ })
+ }, [src])
+
+ return { src: resolved && resolved.source === src ? resolved.url : src, onError }
+}