diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7e1d59773f..b2f6b9027e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,6 +14,7 @@ jobs:
- run: bun typecheck
- run: bunx playwright install --with-deps chromium
- run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts
+ - run: cd web && bunx playwright test --config playwright.config.ts e2e/markdown-table.spec.ts e2e/markdown-table-mobile.spec.ts
- run: bun run test
# Serial runner-integration suite: starts real detached runner/session
diff --git a/web/e2e-fixtures/markdown-table-fixture.html b/web/e2e-fixtures/markdown-table-fixture.html
new file mode 100644
index 0000000000..fd5b261141
--- /dev/null
+++ b/web/e2e-fixtures/markdown-table-fixture.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ HAPI Markdown table fixture
+
+
+
+
+
+
+
diff --git a/web/e2e-fixtures/markdown-table-fixture.tsx b/web/e2e-fixtures/markdown-table-fixture.tsx
new file mode 100644
index 0000000000..dd2d1aa330
--- /dev/null
+++ b/web/e2e-fixtures/markdown-table-fixture.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import '../src/index.css'
+import { HappyChatProvider, type HappyChatContextValue } from '../src/components/AssistantChat/context'
+import { I18nProvider } from '../src/lib/i18n-context'
+import { MarkdownRenderer } from '../src/components/MarkdownRenderer'
+
+const TABLE_MARKDOWN = `# Repository activity
+
+| Project | Stars | Language | Latest release | Maintainer | Notes |
+| --- | ---: | --- | --- | --- | --- |
+| HAPI | 128 | TypeScript | 0.28.0 | Local-first team | Remote control for coding agents |
+| HAPI, local-first | 42 | TypeScript | 0.27.3 | Community | A deliberately long description for horizontal table scrolling |
+| Example | 7 | Rust | 1.2.0 | Open source | Stable fixture row |`
+
+const NEAR_BOTTOM_VERTICAL_TABLE_MARKDOWN = `# Near bottom table
+
+| Item | Status |
+| --- | --- |
+${Array.from({ length: 14 }, (_, index) => `| Item ${index + 1} | Ready |`).join('\n')}`
+
+function MarkdownTableFixture() {
+ const query = new URLSearchParams(window.location.search)
+ const content = query.has('near-bottom-scroll')
+ ? NEAR_BOTTOM_VERTICAL_TABLE_MARKDOWN
+ : TABLE_MARKDOWN
+
+ return (
+
+
+
+
+
+ )
+}
+
+const root = document.getElementById('root')
+if (root) {
+ ReactDOM.createRoot(root).render(
+
+
+
+
+ ,
+ )
+}
diff --git a/web/e2e/markdown-table-mobile.spec.ts b/web/e2e/markdown-table-mobile.spec.ts
new file mode 100644
index 0000000000..8efc258cfc
--- /dev/null
+++ b/web/e2e/markdown-table-mobile.spec.ts
@@ -0,0 +1,124 @@
+import { devices, expect, test } from '@playwright/test'
+
+test.use({ ...devices['Pixel 7'] })
+
+test('mobile markdown table viewer requests landscape and releases orientation controls', async ({ page }) => {
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+ await page.evaluate(() => {
+ const state = { requestFullscreen: 0, exitFullscreen: 0, locks: [] as string[], unlocks: 0 }
+ Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state })
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: () => {
+ state.requestFullscreen += 1
+ return Promise.resolve()
+ },
+ })
+ Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ value: () => {
+ state.exitFullscreen += 1
+ return Promise.resolve()
+ },
+ })
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: {
+ lock: (value: string) => {
+ state.locks.push(value)
+ return Promise.resolve()
+ },
+ unlock: () => {
+ state.unlocks += 1
+ },
+ },
+ })
+ })
+
+ const inlineActions = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-actions')
+ await expect(inlineActions).toBeVisible()
+ await expect(inlineActions.getByRole('button')).toHaveCount(1)
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ await expect(dialog).toBeVisible()
+ // A real mobile browser can rotate to a landscape CSS viewport. Keep the
+ // mobile title unshifted even when its width becomes desktop-sized.
+ await page.setViewportSize({ width: 915, height: 412 })
+ const wrapButton = dialog.locator('button[data-hapi-table-wrap-toggle="true"]')
+ await expect(wrapButton).toBeVisible()
+ await expect(wrapButton).toHaveAttribute('aria-pressed', /true|false/)
+ await expect(dialog.getByRole('button', { name: 'Copy table as Markdown' })).toBeVisible()
+ const downloadButton = dialog.getByRole('button', { name: 'Download table' })
+ await expect(downloadButton).toBeVisible()
+ const initiallyWrapped = await wrapButton.getAttribute('aria-pressed')
+ await wrapButton.click()
+ await expect(wrapButton).toHaveAttribute('aria-pressed', initiallyWrapped === 'true' ? 'false' : 'true')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => {
+ const style = getComputedStyle(element)
+ return `${style.paddingLeft}:${style.paddingRight}:${style.paddingTop}:${style.paddingBottom}`
+ })).toBe('6px:6px:0px:0px')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer-heading="true"]').evaluate((element) => getComputedStyle(element).transform)).toBe('none')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"] thead th').first().evaluate((element) => {
+ const thead = element.closest('thead')
+ return `${getComputedStyle(thead ?? element).position}:${getComputedStyle(element).position}:${getComputedStyle(element).top}`
+ })).toBe('static:sticky:0px')
+ await expect.poll(() => page.evaluate(() => {
+ const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState
+ return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : ''
+ })).toBe('1:landscape')
+
+ await downloadButton.click()
+ await expect(page.getByRole('menuitem', { name: 'Download PNG' })).toBeVisible()
+ await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible()
+ const imageDownloadPromise = page.waitForEvent('download')
+ await page.getByRole('menuitem', { name: 'Download PNG' }).click()
+ const imageDownload = await imageDownloadPromise
+ expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/)
+
+ const csvDownloadPromise = page.waitForEvent('download')
+ await downloadButton.click()
+ await page.getByRole('menuitem', { name: 'Download CSV' }).click()
+ const csvDownload = await csvDownloadPromise
+ expect(csvDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/)
+
+ await dialog.getByRole('button', { name: 'Close table full screen' }).click()
+ await expect.poll(() => page.evaluate(() => {
+ const state = (window as Window & { __hapiTableViewerState?: { exitFullscreen: number; unlocks: number } }).__hapiTableViewerState
+ return state ? `${state.exitFullscreen}:${state.unlocks}` : ''
+ })).toBe('1:1')
+})
+
+test('mobile markdown table viewer detects a phone that starts in landscape', async ({ page }) => {
+ await page.setViewportSize({ width: 915, height: 412 })
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+ await expect(page.getByRole('button', { name: 'Open table full screen' })).toBeVisible()
+ await page.evaluate(() => {
+ const state = { requestFullscreen: 0, locks: [] as string[] }
+ Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state })
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: () => {
+ state.requestFullscreen += 1
+ return Promise.resolve()
+ },
+ })
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: {
+ lock: (value: string) => {
+ state.locks.push(value)
+ return Promise.resolve()
+ },
+ unlock: () => {},
+ },
+ })
+ })
+
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+ await expect(page.getByRole('dialog', { name: 'Table filename fixture' })).toBeVisible()
+ await expect.poll(() => page.evaluate(() => {
+ const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState
+ return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : ''
+ })).toBe('1:landscape')
+})
diff --git a/web/e2e/markdown-table.spec.ts b/web/e2e/markdown-table.spec.ts
new file mode 100644
index 0000000000..16db1eb715
--- /dev/null
+++ b/web/e2e/markdown-table.spec.ts
@@ -0,0 +1,288 @@
+import { readFile } from 'node:fs/promises'
+import { expect, test } from '@playwright/test'
+
+test.describe('markdown table actions', () => {
+ test('opens a viewport-sized PC viewer and downloads the CSV', async ({ page }) => {
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+
+ const inlineTable = page.locator('[data-testid="markdown-table-fixture"] table')
+ await expect(inlineTable).toBeVisible()
+ await expect(inlineTable.locator('thead')).toBeVisible()
+
+ const tableFrame = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame')
+ const actions = tableFrame.locator('.aui-md-table-actions')
+ await expect(actions).toBeAttached()
+ await expect(actions.getByRole('button')).toHaveCount(1)
+ const inlineButtonStyles = await actions.getByRole('button').evaluate((element) => {
+ const style = getComputedStyle(element)
+ return { backgroundColor: style.backgroundColor, borderWidth: style.borderTopWidth, backdropFilter: style.backdropFilter }
+ })
+ expect(inlineButtonStyles.backgroundColor).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
+ expect(inlineButtonStyles.borderWidth).toBe('0px')
+ expect(inlineButtonStyles.backdropFilter).toBe('none')
+ await expect.poll(() => actions.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return `${style.top}:${style.right}`
+ })).toBe('0px:3px')
+ await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('0')
+ await tableFrame.hover()
+ await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('1')
+
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ await expect(dialog).toBeVisible()
+
+ const viewerHeading = dialog.locator('[data-hapi-table-viewer-heading="true"]')
+ await expect(viewerHeading).toHaveText('Table filename fixture')
+ await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).fontSize)).toBe('18px')
+ await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).transform)).toBe('matrix(1, 0, 0, 1, 0, -1)')
+ const toolbar = dialog.locator('[data-hapi-table-viewer-toolbar="true"]')
+ await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('0px')
+ await expect.poll(() => toolbar.evaluate((element) => `${getComputedStyle(element).paddingLeft}:${getComputedStyle(element).paddingRight}`)).toBe('6px:6px')
+ await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px')
+ await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingTop)).toBe('0px')
+ await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px')
+ const toolbarEdges = await toolbar.evaluate((element) => {
+ const buttons = element.querySelectorAll('button')
+ const first = buttons[0]?.getBoundingClientRect()
+ const last = buttons[buttons.length - 1]?.getBoundingClientRect()
+ const toolbarRect = element.getBoundingClientRect()
+ return {
+ leftGap: Math.round((first?.left ?? 0) - toolbarRect.left),
+ rightGap: Math.round(toolbarRect.right - (last?.right ?? 0)),
+ }
+ })
+ expect(toolbarEdges).toEqual({ leftGap: 6, rightGap: 6 })
+
+ const box = await dialog.boundingBox()
+ expect(box?.width).toBeGreaterThanOrEqual(1400)
+ expect(box?.height).toBeGreaterThanOrEqual(850)
+ await expect(dialog.locator('[data-hapi-table-viewer="true"] .aui-md-thead')).toBeVisible()
+ const wrapButton = dialog.getByRole('button', { name: 'Enable table wrapping' })
+ await expect(wrapButton).toHaveAttribute('aria-pressed', 'false')
+ await wrapButton.click()
+ await expect(dialog.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => {
+ const table = element.querySelector('table')
+ const cell = table?.querySelector('td')
+ return table && cell
+ ? `${getComputedStyle(element).overflowX}:${getComputedStyle(table).tableLayout}:${getComputedStyle(cell).whiteSpace}`
+ : ''
+ })).toBe('hidden:fixed:normal')
+ await expect.poll(async () => {
+ const toolbarHeight = (await toolbar.boundingBox())?.height ?? 0
+ const headerHeight = await dialog.locator('[data-hapi-table-viewer="true"] thead').evaluate((element) => element.getBoundingClientRect().height)
+ return Math.round(toolbarHeight) - Math.round(headerHeight)
+ }).toBe(0)
+ const viewerLeftOffset = await dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => {
+ const table = element.querySelector('table')
+ if (!table) return -1
+ return Math.round(table.getBoundingClientRect().left - element.getBoundingClientRect().left)
+ })
+ expect(viewerLeftOffset).toBe(0)
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingRight)).toBe('0px')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px')
+
+ await page.evaluate(() => {
+ let copied = ''
+ Object.defineProperty(window, '__hapiCopiedTableMarkdown', {
+ configurable: true,
+ get: () => copied,
+ })
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: async (text: string) => { copied = text } },
+ })
+ })
+ await dialog.getByRole('button', { name: 'Copy table' }).click()
+ await expect(page.getByRole('menuitem').nth(0)).toHaveText('Copy image')
+ await expect(page.getByRole('menuitem').nth(1)).toHaveText('Copy Markdown')
+ await page.getByRole('menuitem', { name: 'Copy Markdown' }).click()
+ await expect.poll(() => page.evaluate(() => (window as Window & { __hapiCopiedTableMarkdown?: string }).__hapiCopiedTableMarkdown ?? '')).toContain('| Project | Stars |')
+
+ const imageDownloadPromise = page.waitForEvent('download')
+ await dialog.getByRole('button', { name: 'Download table' }).click()
+ await expect(page.getByRole('menuitem', { name: 'Download PNG' })).toBeVisible()
+ await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible()
+ await page.getByRole('menuitem', { name: 'Download PNG' }).click()
+ const imageDownload = await imageDownloadPromise
+ expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/)
+ const imagePath = await imageDownload.path()
+ if (!imagePath) throw new Error('PNG download did not produce a file path')
+ const png = await readFile(imagePath)
+ expect(png.subarray(1, 4).toString()).toBe('PNG')
+ const exportedHeight = png.readUInt32BE(20)
+ const tableExportMetrics = await dialog.locator('[data-hapi-table-viewer="true"] table').evaluate((table) => {
+ const tableRect = table.getBoundingClientRect()
+ const rowBottoms = Array.from(table.rows)
+ .map((row) => row.getBoundingClientRect())
+ .filter((rowRect) => rowRect.height > 0)
+ .map((rowRect) => rowRect.bottom - tableRect.top)
+ const fallbackHeight = Math.max(table.scrollHeight, Math.ceil(tableRect.height), 1)
+ const height = rowBottoms.length > 0
+ ? Math.min(fallbackHeight, Math.ceil(Math.max(...rowBottoms)))
+ : fallbackHeight
+ return {
+ width: Math.max(table.scrollWidth, Math.ceil(tableRect.width), 1),
+ height,
+ devicePixelRatio: window.devicePixelRatio,
+ }
+ })
+ const scale = Math.min(
+ tableExportMetrics.devicePixelRatio || 1,
+ 2,
+ Math.sqrt(36_000_000 / (tableExportMetrics.width * tableExportMetrics.height)),
+ )
+ expect(exportedHeight).toBe(Math.ceil(tableExportMetrics.height * scale))
+
+ const downloadPromise = page.waitForEvent('download')
+ await dialog.getByRole('button', { name: 'Download table' }).click()
+ await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible()
+ await page.getByRole('menuitem', { name: 'Download CSV' }).click()
+ const download = await downloadPromise
+ expect(download.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/)
+
+ await dialog.getByRole('button', { name: 'Close table full screen' }).click()
+ await expect(dialog).toBeHidden()
+ })
+
+ test('sizes each action menu to its longest option with symmetric padding', async ({ page }) => {
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+ await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover()
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ await expect(dialog).toBeVisible()
+
+ const measureMenu = async (triggerName: string) => {
+ await dialog.getByRole('button', { name: triggerName }).click()
+ const menu = page.getByRole('menu', { name: triggerName })
+ await expect(menu).toBeVisible()
+ return menu.evaluate((element) => {
+ const menuRect = element.getBoundingClientRect()
+ const options = Array.from(element.querySelectorAll('button')).map((button) => {
+ const range = document.createRange()
+ range.selectNodeContents(button)
+ const textRect = range.getBoundingClientRect()
+ return {
+ text: button.textContent,
+ textWidth: textRect.width,
+ leftGap: textRect.left - menuRect.left,
+ rightGap: menuRect.right - textRect.right,
+ }
+ })
+ const longest = options.reduce((current, option) => {
+ const currentWidth = current.textWidth
+ const optionWidth = option.textWidth
+ return optionWidth > currentWidth ? option : current
+ })
+ return { options, longest }
+ })
+ }
+
+ const copyMenu = await measureMenu('Copy table')
+ expect(copyMenu.options.map((option) => option.text)).toEqual(['Copy image', 'Copy Markdown'])
+ expect(Math.abs(copyMenu.longest.leftGap - copyMenu.longest.rightGap)).toBeLessThanOrEqual(1)
+ await page.keyboard.press('Escape')
+
+ const downloadMenu = await measureMenu('Download table')
+ expect(downloadMenu.options.map((option) => option.text)).toEqual(['Download PNG', 'Download CSV'])
+ expect(Math.abs(downloadMenu.longest.leftGap - downloadMenu.longest.rightGap)).toBeLessThanOrEqual(1)
+ })
+
+ test('keeps file-preview table header geometry aligned with chat tables', async ({ page }) => {
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+
+ const surface = page.locator('[data-testid="markdown-table-fixture"]')
+ const readGeometry = () => surface.evaluate((element) => {
+ const table = element.querySelector('table')
+ const header = table?.querySelector('thead th')
+ const actions = element.querySelector('.aui-md-table-actions')
+ if (!table || !header || !actions) throw new Error('Markdown table geometry is incomplete')
+
+ const headerRect = header.getBoundingClientRect()
+ const actionRect = actions.getBoundingClientRect()
+ const headerStyle = getComputedStyle(header)
+ return {
+ paddingTop: headerStyle.paddingTop,
+ paddingBottom: headerStyle.paddingBottom,
+ paddingLeft: headerStyle.paddingLeft,
+ paddingRight: headerStyle.paddingRight,
+ lineHeight: headerStyle.lineHeight,
+ actionHeaderCenterDelta: Math.round(Math.abs(
+ (actionRect.top + actionRect.height / 2) - (headerRect.top + headerRect.height / 2),
+ )),
+ }
+ })
+
+ const chatGeometry = await readGeometry()
+ await surface.evaluate((element) => element.classList.add('markdown-content'))
+ const filePreviewGeometry = await readGeometry()
+
+ expect(filePreviewGeometry).toEqual(chatGeometry)
+ expect(filePreviewGeometry.actionHeaderCenterDelta).toBeLessThanOrEqual(2)
+ })
+
+ test('defaults an overflowing table to wrapping and remembers an explicit choice', async ({ page }) => {
+ await page.setViewportSize({ width: 600, height: 900 })
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html')
+ await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover()
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ await expect(dialog).toBeVisible()
+ const wrappedButton = dialog.getByRole('button', { name: 'Disable table wrapping' })
+ await expect(wrappedButton).toHaveAttribute('aria-pressed', 'true')
+ await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => {
+ const table = element.querySelector('table')
+ return table && element.scrollWidth <= element.clientWidth
+ ? `${getComputedStyle(table).tableLayout}:${getComputedStyle(element).overflowX}`
+ : ''
+ })).toBe('fixed:hidden')
+
+ await wrappedButton.click()
+ await expect(dialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false')
+ await dialog.getByRole('button', { name: 'Close table full screen' }).click()
+ await expect(dialog).toBeHidden()
+
+ await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover()
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+ const reopenedDialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ await expect(reopenedDialog).toBeVisible()
+ await expect(reopenedDialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ test('does not rebound at the bottom when toolbar space exceeds remaining table overflow', async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 500 })
+ await page.goto('/e2e-fixtures/markdown-table-fixture.html?near-bottom-scroll')
+ await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover()
+ await page.getByRole('button', { name: 'Open table full screen' }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Table filename fixture' })
+ const viewer = dialog.locator('[data-hapi-table-viewer="true"]')
+ await expect(dialog).toBeVisible()
+ await expect.poll(() => viewer.evaluate((element) => element.scrollHeight > element.clientHeight && element.scrollWidth <= element.clientWidth + 1)).toBe(true)
+
+ await expect(dialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false')
+ const viewerBox = await viewer.boundingBox()
+ if (!viewerBox) throw new Error('Long table viewer has no bounding box')
+ await page.mouse.move(viewerBox.x + viewerBox.width / 2, viewerBox.y + viewerBox.height / 2)
+ const readScrollState = () => viewer.evaluate((element) => ({
+ scrollTop: element.scrollTop,
+ maxScrollTop: Math.max(0, element.scrollHeight - element.clientHeight),
+ toolbarHidden: document.querySelector('[data-hapi-table-viewer-toolbar="true"]')?.getAttribute('aria-hidden') === 'true',
+ }))
+ await page.mouse.wheel(0, 10_000)
+ const bottomSamples = [await readScrollState()]
+ for (let index = 0; index < 12; index += 1) {
+ await page.waitForTimeout(25)
+ bottomSamples.push(await readScrollState())
+ }
+
+ expect(bottomSamples.at(-1)?.scrollTop).toBeGreaterThanOrEqual((bottomSamples.at(-1)?.maxScrollTop ?? 0) - 1)
+ expect(bottomSamples.some((sample) => sample.toolbarHidden)).toBe(false)
+ for (let index = 1; index < bottomSamples.length; index += 1) {
+ expect(bottomSamples[index]?.scrollTop).toBeGreaterThanOrEqual((bottomSamples[index - 1]?.scrollTop ?? 0) - 1)
+ }
+ })
+})
diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx
index 79bd4d1ff5..0e6a242f6e 100644
--- a/web/src/components/AssistantChat/HappyThread.tsx
+++ b/web/src/components/AssistantChat/HappyThread.tsx
@@ -501,7 +501,10 @@ export function HappyThread(props: {
const machineLabelsById = useMachineLabels(machines)
const [shareTurn, setShareTurn] = useState(null)
const shareDialogOpen = shareTurn !== null
- const shareTitle = shareTurn ? getSessionTitle(props.session) : ''
+ const sessionTitle = typeof props.session?.id === 'string'
+ ? getSessionTitle(props.session)
+ : undefined
+ const shareTitle = shareTurn ? (sessionTitle ?? '') : ''
const shareRelativeTimeTick = useMinuteTick(headerMetadata.lastActive && shareDialogOpen)
const shareMetadataItems = useMemo(() => {
const agentFlavor = props.session.metadata?.flavor ?? null
@@ -1606,6 +1609,7 @@ export function HappyThread(props: {
vi.fn())
+vi.mock('html2canvas-pro', () => ({ default: html2canvas }))
+
+describe('ShareTurnDialog preview cleanup', () => {
+ beforeEach(() => {
+ html2canvas.mockReset()
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ })
+
+ it('keeps table fullscreen controls interactive in the preview but removes them from exports', () => {
+ const root = document.createElement('div')
+ root.innerHTML = `
+
+
+ | Configuration |
+ | apiUrl |
+
+
+
+
+
+ `
+
+ stripCaptureOnlyControls(root)
+
+ expect(root.querySelector('.aui-md-table-actions')).not.toBeNull()
+ expect(root.querySelector('table')).not.toBeNull()
+ expect(root).toHaveTextContent('apiUrl')
+
+ stripExportControls(root)
+ expect(root.querySelector('.aui-md-table-actions')).toBeNull()
+ })
+
+ it('preserves the link target for Markdown copied from the share preview', () => {
+ const root = document.createElement('div')
+ root.innerHTML = 'Docs'
+
+ stripCaptureOnlyControls(root)
+
+ const anchor = root.querySelector('a')
+ expect(anchor).not.toHaveAttribute('href')
+ expect(anchor).toHaveAttribute('data-hapi-markdown-href', 'https://example.com/docs')
+ })
+
+ it('opens the shared table viewer from the preview action', async () => {
+ render(
+
+
+
+
+ | Configuration |
+ | apiUrl |
+
+
+
+
+
+
+ `,
+ text: 'Configuration apiUrl',
+ }]}
+ onClose={vi.fn()}
+ />
+ ,
+ )
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Open table full screen' }))
+ expect(await screen.findByRole('dialog', { name: 'Share table' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Close table full screen' })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' }))
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Close table full screen' })).not.toBeInTheDocument())
+ })
+})
diff --git a/web/src/components/AssistantChat/ShareTurnDialog.tsx b/web/src/components/AssistantChat/ShareTurnDialog.tsx
index 019f328228..d3019be3e6 100644
--- a/web/src/components/AssistantChat/ShareTurnDialog.tsx
+++ b/web/src/components/AssistantChat/ShareTurnDialog.tsx
@@ -1,10 +1,17 @@
-import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useTranslation } from '@/lib/use-translation'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { ZoomableLightbox } from '@/components/ZoomableLightbox'
+import {
+ enterMobileTableViewer,
+ isMobileTableViewerViewport,
+ leaveMobileTableViewer,
+ TableViewerFromElement,
+} from '@/components/assistant-ui/MarkdownTable'
import { safeCopyToClipboard } from '@/lib/clipboard'
import type { ShareTurnMetadataItem } from '@/lib/shareTurnMetadata'
+import { getShareImageFileName } from '@/lib/share-image-filename'
type ShareTurnDialogProps = {
isOpen: boolean
@@ -33,7 +40,7 @@ function nextFrame(): Promise {
})
}
-function stripCaptureOnlyControls(root: HTMLElement): void {
+export function stripCaptureOnlyControls(root: HTMLElement): void {
for (const element of Array.from(root.querySelectorAll(SHARE_HIDDEN_CONTENT_SELECTOR))) {
if (!(element instanceof HTMLElement) || !root.contains(element)) continue
@@ -58,6 +65,10 @@ function stripCaptureOnlyControls(root: HTMLElement): void {
element.remove()
}
for (const anchor of Array.from(root.querySelectorAll('a'))) {
+ const href = anchor.getAttribute('href')
+ if (href && !anchor.dataset.hapiMarkdownHref) {
+ anchor.dataset.hapiMarkdownHref = href
+ }
anchor.removeAttribute('href')
anchor.removeAttribute('target')
anchor.removeAttribute('rel')
@@ -71,8 +82,8 @@ function stripCaptureOnlyControls(root: HTMLElement): void {
}
}
-function stripExportControls(root: HTMLElement): void {
- for (const element of Array.from(root.querySelectorAll('[data-hapi-share-export-exclude="true"]'))) {
+export function stripExportControls(root: HTMLElement): void {
+ for (const element of Array.from(root.querySelectorAll('[data-hapi-share-export-exclude="true"], .aui-md-table-actions'))) {
element.remove()
}
for (const imageButton of Array.from(root.querySelectorAll('button:has(img)'))) {
@@ -110,38 +121,6 @@ function setPreviewCodeWrap(control: HTMLElement, enabled: boolean): void {
}
}
-function formatShareTimestamp(date = new Date()): string {
- const pad = (value: number) => String(value).padStart(2, '0')
- return [
- date.getFullYear(),
- pad(date.getMonth() + 1),
- pad(date.getDate()),
- pad(date.getHours()),
- pad(date.getMinutes()),
- pad(date.getSeconds())
- ].join('')
-}
-
-function sanitizeShareFileNamePart(title: string): string {
- const withoutControlCharacters = Array.from(title.normalize('NFKC'))
- .filter((character) => {
- const codePoint = character.codePointAt(0) ?? 0
- return codePoint >= 32 && codePoint !== 127
- })
- .join('')
- const sanitized = withoutControlCharacters
- .replace(/[<>:"/\\|?*]+/g, '-')
- .replace(/\s+/g, ' ')
- .replace(/-+/g, '-')
- .replace(/^[ .-]+|[ .-]+$/g, '')
- .trim()
- return Array.from(sanitized || 'Shared turn').slice(0, 80).join('').trim()
-}
-
-function getShareFileName(title: string): string {
- return `HAPI-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp()}.png`
-}
-
function prepareExportElement(element: HTMLElement, exportWidth: number, preserveSourceLayout: boolean): HTMLElement {
const captureElement = element.cloneNode(true)
if (!(captureElement instanceof HTMLElement)) {
@@ -531,6 +510,11 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
naturalWidth: number
naturalHeight: number
} | null>(null)
+ const [previewTable, setPreviewTable] = useState(null)
+ const previewTableRef = useRef(null)
+ const previewTableOpenRef = useRef(false)
+ const previewTableMobileRef = useRef(false)
+ const previewTableFullscreenRef = useRef(false)
const showNativeShareButton = true
const usesCoarsePrimaryPointer = window.matchMedia('(pointer: coarse)').matches
const preserveSourceLayout = Boolean(
@@ -543,6 +527,37 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
? Math.min(1240, Math.max(480, Math.ceil((props.sourceContentWidth ?? 0) + SHARE_EXPORT_HORIZONTAL_PADDING)))
: SHARE_EXPORT_WIDTH
+ const closeTablePreview = useCallback(() => {
+ const wasMobile = previewTableMobileRef.current
+ const enteredFullscreen = previewTableFullscreenRef.current
+ previewTableOpenRef.current = false
+ previewTableRef.current = null
+ previewTableMobileRef.current = false
+ previewTableFullscreenRef.current = false
+ setPreviewTable(null)
+ if (wasMobile) leaveMobileTableViewer(enteredFullscreen)
+ }, [])
+
+ const openTablePreview = useCallback((table: HTMLTableElement) => {
+ previewTableRef.current = table
+ previewTableOpenRef.current = true
+ const isMobile = isMobileTableViewerViewport()
+ previewTableMobileRef.current = isMobile
+ previewTableFullscreenRef.current = false
+ setPreviewTable(table)
+ if (!isMobile) return
+
+ // Start the browser activation-sensitive request directly from the
+ // table button click. The viewer itself is rendered after state updates.
+ void enterMobileTableViewer().then((enteredFullscreen) => {
+ if (!previewTableOpenRef.current || previewTableRef.current !== table) {
+ leaveMobileTableViewer(enteredFullscreen)
+ return
+ }
+ previewTableFullscreenRef.current = enteredFullscreen
+ })
+ }, [])
+
useLayoutEffect(() => {
setReady(false)
if (!props.isOpen) return undefined
@@ -576,6 +591,10 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
if (!appendedSnapshot && isTextOnlySnapshot) appendTextFallback(fragment, snapshot)
}
body.replaceChildren(fragment)
+ // Run one final pass over the assembled preview. This also covers
+ // capture-only controls nested in a snapshot root that was assembled
+ // from multiple top-level nodes.
+ stripCaptureOnlyControls(body)
if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) {
for (const snapshot of props.sourceSnapshots) {
@@ -590,6 +609,24 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
return undefined
}, [props.isOpen, props.sourceSnapshots, restoreTick])
+ useEffect(() => {
+ if (props.isOpen) return undefined
+ closeTablePreview()
+ return undefined
+ }, [closeTablePreview, props.isOpen])
+
+ useEffect(() => {
+ const handleFullscreenChange = () => {
+ if (!previewTableOpenRef.current || !previewTableMobileRef.current || !previewTableFullscreenRef.current) return
+ if (document.fullscreenElement) return
+ closeTablePreview()
+ }
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
+ }, [closeTablePreview])
+
+ useEffect(() => () => closeTablePreview(), [closeTablePreview])
+
useEffect(() => {
const capture = captureRef.current
if (!props.isOpen || !ready || !capture) {
@@ -613,6 +650,15 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
const target = event.target
if (!(target instanceof Element)) return
+ const tableAction = target.closest('.aui-md-table-actions button')
+ if (tableAction) {
+ event.preventDefault()
+ event.stopPropagation()
+ const table = tableAction.closest('.aui-md-table-frame')?.querySelector('table')
+ if (table) openTablePreview(table)
+ return
+ }
+
const wrapButton = target.closest('[data-hapi-code-wrap-toggle="true"]')
if (wrapButton) {
event.preventDefault()
@@ -714,7 +760,8 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
}
return (
-
+ {previewTable ? (
+
+ ) : null}
+ >
)
}
diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx
index dd1a4c1907..23835f8fcd 100644
--- a/web/src/components/AssistantChat/context.tsx
+++ b/web/src/components/AssistantChat/context.tsx
@@ -9,6 +9,7 @@ export type OlderHistoryLoadResult = 'loaded' | 'transient-stop' | 'terminal-sto
export type HappyChatContextValue = {
api: ApiClient
sessionId: string
+ sessionTitle?: string
metadata: SessionMetadataSummary | null
terminalToolDisplayMode: TerminalToolDisplayMode
/** Hub-wide AGENT_NOTIFY_SUMMARY chat display; polled once at chat shell. */
diff --git a/web/src/components/assistant-ui/MarkdownTable.test.tsx b/web/src/components/assistant-ui/MarkdownTable.test.tsx
new file mode 100644
index 0000000000..b4615e7821
--- /dev/null
+++ b/web/src/components/assistant-ui/MarkdownTable.test.tsx
@@ -0,0 +1,1152 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { I18nProvider } from '@/lib/i18n-context'
+import { MarkdownRenderer } from '@/components/MarkdownRenderer'
+
+const html2canvas = vi.hoisted(() => vi.fn())
+vi.mock('html2canvas-pro', () => ({ default: html2canvas }))
+
+import {
+ downloadTableAsCsv,
+ getTableExportScale,
+ getTableExportHeight,
+ getTableExportTileHeight,
+ isMobileTableViewerViewport,
+ MAX_TABLE_EXPORT_DIMENSION,
+ MAX_TABLE_EXPORT_PIXELS,
+ MAX_TABLE_EXPORT_TILE_PIXELS,
+ renderTableAsImage,
+ saveTableAsImage,
+ shouldWrapTableByDefault,
+ serializeTableToMarkdown,
+ serializeTableToCsv,
+ TableViewerFromElement,
+} from './MarkdownTable'
+
+const TABLE_MARKDOWN = `| Project | Stars |
+| --- | ---: |
+| HAPI | 128 |
+| HAPI, local-first | 42 |`
+
+function renderTable(locale: 'en' | 'zh-CN' = 'en') {
+ localStorage.setItem('hapi-lang', locale)
+ return render(
+
+
+ ,
+ )
+}
+
+describe('MarkdownTable', () => {
+ const originalMatchMedia = window.matchMedia
+ const originalOrientation = window.screen.orientation
+ const originalNavigatorShare = navigator.share
+ const originalNavigatorCanShare = navigator.canShare
+ const originalNavigatorClipboard = navigator.clipboard
+ const originalClipboardItem = window.ClipboardItem
+ const originalMaxTouchPoints = navigator.maxTouchPoints
+ const originalUserAgent = navigator.userAgent
+ const originalInnerWidth = window.innerWidth
+ const originalInnerHeight = window.innerHeight
+
+ beforeEach(() => {
+ localStorage.clear()
+ vi.restoreAllMocks()
+ html2canvas.mockReset()
+ window.matchMedia = originalMatchMedia
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: originalNavigatorShare,
+ })
+ Object.defineProperty(navigator, 'canShare', {
+ configurable: true,
+ value: originalNavigatorCanShare,
+ })
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalNavigatorClipboard })
+ Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: originalClipboardItem })
+ Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: originalMaxTouchPoints })
+ Object.defineProperty(navigator, 'userAgent', { configurable: true, value: originalUserAgent })
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: originalOrientation,
+ })
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
+ })
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: originalNavigatorShare,
+ })
+ Object.defineProperty(navigator, 'canShare', {
+ configurable: true,
+ value: originalNavigatorCanShare,
+ })
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalNavigatorClipboard })
+ Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: originalClipboardItem })
+ Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: originalMaxTouchPoints })
+ Object.defineProperty(navigator, 'userAgent', { configurable: true, value: originalUserAgent })
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: originalOrientation,
+ })
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
+ })
+
+ it('keeps the table header as the first row while exposing only a plain fullscreen action', () => {
+ renderTable()
+
+ const table = screen.getByRole('table')
+ expect(table.firstElementChild?.tagName).toBe('THEAD')
+ expect(table.parentElement?.parentElement).toHaveClass('aui-md-table-shell')
+ const actions = table.parentElement?.parentElement?.querySelector('.aui-md-table-actions')
+ expect(actions?.querySelectorAll('button')).toHaveLength(1)
+ expect(actions).toHaveAttribute('data-hapi-share-export-exclude', 'true')
+ expect(screen.getByRole('button', { name: 'Open table full screen' })).toBeInTheDocument()
+ })
+
+ it('keeps inline table actions as tall as a wrapped header row', async () => {
+ let resizeCallback: ResizeObserverCallback | undefined
+ class TestResizeObserver {
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback
+ }
+ observe() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', TestResizeObserver)
+
+ try {
+ renderTable()
+ const table = screen.getByRole('table')
+ const row = table.tHead?.rows[0]
+ const actions = table.parentElement?.parentElement?.querySelector('.aui-md-table-actions')
+ if (!row || !actions) throw new Error('Inline table action geometry is incomplete')
+
+ vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ height: 56 } as DOMRect)
+ await waitFor(() => expect(resizeCallback).toBeDefined())
+ resizeCallback?.([], {} as ResizeObserver)
+ expect(actions).toHaveStyle({ height: '56px' })
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it('opens an enlarged PC viewer without requesting browser fullscreen or orientation lock', async () => {
+ const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ })
+ const lock = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock, unlock: vi.fn() },
+ })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+
+ expect(await screen.findByRole('dialog', { name: 'Table' })).toBeInTheDocument()
+ const dialog = screen.getByRole('dialog', { name: 'Table' })
+ expect(dialog).toContainElement(screen.getByRole('table'))
+ const wrapButton = screen.getByRole('button', { name: 'Enable table wrapping' })
+ expect(wrapButton).toHaveAttribute('aria-pressed', 'false')
+ const copyMenuTrigger = screen.getByRole('button', { name: 'Copy table' })
+ expect(copyMenuTrigger).toHaveAttribute('aria-haspopup', 'menu')
+ fireEvent.click(copyMenuTrigger)
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Copy image', 'Copy Markdown'])
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Copy Markdown' }))
+ const downloadMenuTrigger = screen.getByRole('button', { name: 'Download table' })
+ expect(downloadMenuTrigger).toHaveAttribute('aria-haspopup', 'menu')
+ fireEvent.click(downloadMenuTrigger)
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Download PNG', 'Download CSV'])
+ expect(requestFullscreen).not.toHaveBeenCalled()
+ expect(lock).not.toHaveBeenCalled()
+
+ fireEvent.click(wrapButton)
+ expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')
+ expect(dialog.querySelector('[data-hapi-table-viewer="true"]')).toHaveClass('overflow-x-hidden', 'overflow-y-auto')
+ expect(dialog.querySelector('[data-hapi-table-wrap="true"]')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' }))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument())
+ })
+
+ it('uses concise Chinese labels for the copy and download menus', async () => {
+ renderTable('zh-CN')
+ fireEvent.click(screen.getByRole('button', { name: '横向全屏查看表格' }))
+
+ await screen.findByRole('dialog', { name: '表格' })
+ fireEvent.click(screen.getByRole('button', { name: '复制表格' }))
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['复制图片', '复制 Markdown'])
+ fireEvent.click(screen.getByRole('menuitem', { name: '复制 Markdown' }))
+
+ fireEvent.click(screen.getByRole('button', { name: '下载表格' }))
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['下载 PNG', '下载 CSV'])
+ })
+
+ it('keeps shared-preview tables at max-content width when the source uses w-full', async () => {
+ const sourceTable = document.createElement('table')
+ sourceTable.className = 'aui-md-table w-full border-collapse text-sm'
+ sourceTable.innerHTML = '| Project |
|---|
| HAPI |
'
+
+ render(
+
+ {}}
+ table={sourceTable}
+ imageTitle="Shared table"
+ />
+ ,
+ )
+
+ const dialog = await screen.findByRole('dialog', { name: 'Shared table' })
+ const viewerTable = dialog.querySelector('table')
+ expect(viewerTable).toHaveClass('w-max', 'min-w-full')
+ expect(viewerTable).not.toHaveClass('w-full')
+ })
+
+ it('remembers an explicit wrapping choice when the same table is reopened', async () => {
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Enable table wrapping' }))
+ expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' }))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+
+ const reopenedDialog = await screen.findByRole('dialog', { name: 'Table' })
+ expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' }))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument())
+ expect(reopenedDialog).toBeTruthy()
+ })
+
+ it('uses horizontal overflow to choose the first-open default', () => {
+ const table = document.createElement('table')
+ const viewer = document.createElement('div')
+ Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 901 })
+ Object.defineProperty(viewer, 'clientWidth', { configurable: true, value: 900 })
+ expect(shouldWrapTableByDefault(table, viewer)).toBe(false)
+
+ Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 902 })
+ expect(shouldWrapTableByDefault(table, viewer)).toBe(true)
+ })
+
+ it('shows a saving status while the PNG is being generated', async () => {
+ type CanvasStub = { toBlob: (callback: BlobCallback) => void }
+ let resolveCanvas: ((value: CanvasStub | PromiseLike) => void) | undefined
+ html2canvas.mockReturnValue(new Promise((resolve) => {
+ resolveCanvas = resolve
+ }))
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image')
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Download table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' }))
+
+ const savingStatus = screen.getByRole('status')
+ expect(savingStatus).toHaveTextContent('Saving image…')
+ expect(savingStatus).toHaveAttribute('data-hapi-table-save-status', 'true')
+ expect(savingStatus).toHaveClass('left-1/2', '-translate-x-1/2', 'rounded-full')
+ expect(savingStatus.querySelector('svg')).toHaveClass('animate-spin')
+
+ resolveCanvas?.({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
+ })
+
+ it('copies a generated table image from the PC copy menu', async () => {
+ type CanvasStub = { toBlob: (callback: BlobCallback) => void }
+ let resolveCanvas: ((value: CanvasStub | PromiseLike) => void) | undefined
+ html2canvas.mockReturnValue(new Promise((resolve) => {
+ resolveCanvas = resolve
+ }))
+ const write = vi.fn().mockResolvedValue(undefined)
+ class ClipboardItemStub {
+ constructor(public readonly data: Record>) {}
+ }
+ Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub })
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Copy table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' }))
+
+ expect(write).toHaveBeenCalledTimes(1)
+ const clipboardItem = write.mock.calls[0]?.[0]?.[0] as ClipboardItemStub | undefined
+ expect(clipboardItem?.data['image/png']).toBeInstanceOf(Promise)
+ expect(screen.getByRole('status')).toHaveTextContent('Copying image…')
+ expect(screen.getByRole('status')).toHaveAttribute('data-hapi-table-save-status-action', 'copy')
+ expect(screen.getByRole('status')).toHaveClass(
+ 'border-[var(--app-fg)]',
+ 'bg-[var(--app-fg)]',
+ 'text-[var(--app-bg)]',
+ 'shadow-md',
+ )
+ resolveCanvas?.({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ await waitFor(() => expect(write).toHaveBeenCalledWith([expect.any(ClipboardItemStub)]))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Copy table' }).querySelector('polyline')).toBeInTheDocument())
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
+ })
+
+ it('shows an error when PNG generation fails', async () => {
+ html2canvas.mockRejectedValue(new Error('render failed'))
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Download table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' }))
+
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.'))
+ })
+
+ it('shows an error when copying the generated table image fails', async () => {
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ const write = vi.fn().mockRejectedValue(new Error('clipboard denied'))
+ class ClipboardItemStub {
+ constructor(public readonly data: Record>) {}
+ }
+ Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub })
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Copy table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' }))
+
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.'))
+ })
+
+ it('shows an error when creating the image clipboard item throws', async () => {
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ class ClipboardItemStub {
+ constructor() {
+ throw new Error('clipboard item failed')
+ }
+ }
+ Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub })
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write: vi.fn() } })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Copy table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' }))
+
+ await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.'))
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('re-evaluates the automatic wrapping choice when the viewer width changes', async () => {
+ let resizeCallback: ResizeObserverCallback | undefined
+ class TestResizeObserver {
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback
+ }
+ observe() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', TestResizeObserver)
+
+ try {
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Table' })
+ const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]')
+ const table = viewer?.querySelector('table')
+ if (!viewer || !table) throw new Error('Table viewer did not render')
+
+ let viewerWidth = 100
+ let tableWidth = 200
+ Object.defineProperty(viewer, 'clientWidth', {
+ configurable: true,
+ get: () => viewerWidth,
+ })
+ Object.defineProperty(table, 'scrollWidth', {
+ configurable: true,
+ get: () => table.hasAttribute('data-hapi-table-wrap')
+ ? Math.min(tableWidth, viewerWidth)
+ : tableWidth,
+ })
+
+ await waitFor(() => expect(resizeCallback).toBeDefined())
+ resizeCallback?.([], {} as ResizeObserver)
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true'))
+
+ viewerWidth = 150
+ resizeCallback?.([], {} as ResizeObserver)
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true'))
+
+ viewerWidth = 300
+ tableWidth = 200
+ resizeCallback?.([], {} as ResizeObserver)
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false'))
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it('requests mobile browser fullscreen and landscape orientation, then releases both on close', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+
+ const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+ const exitFullscreen = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ })
+ Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ value: exitFullscreen,
+ })
+ const lock = vi.fn().mockResolvedValue(undefined)
+ const unlock = vi.fn()
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock, unlock },
+ })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+
+ await waitFor(() => {
+ expect(requestFullscreen).toHaveBeenCalledTimes(1)
+ expect(lock).toHaveBeenCalledWith('landscape')
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' }))
+ await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1))
+ expect(unlock).toHaveBeenCalledTimes(1)
+ })
+
+ it('releases orientation when the browser exits fullscreen externally', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+
+ const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ })
+ const lock = vi.fn().mockResolvedValue(undefined)
+ const unlock = vi.fn()
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock, unlock },
+ })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await waitFor(() => {
+ expect(requestFullscreen).toHaveBeenCalledTimes(1)
+ expect(lock).toHaveBeenCalledWith('landscape')
+ })
+
+ document.dispatchEvent(new Event('fullscreenchange'))
+ await waitFor(() => expect(unlock).toHaveBeenCalledTimes(1))
+ expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument()
+ })
+
+ it('does not miss external fullscreen exit while orientation lock is pending', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+
+ const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ })
+ let resolveLock: (() => void) | undefined
+ const lock = vi.fn().mockImplementation(() => new Promise((resolve) => {
+ resolveLock = resolve
+ }))
+ const unlock = vi.fn()
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock, unlock },
+ })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await waitFor(() => {
+ expect(requestFullscreen).toHaveBeenCalledTimes(1)
+ expect(lock).toHaveBeenCalledWith('landscape')
+ })
+
+ document.dispatchEvent(new Event('fullscreenchange'))
+ await waitFor(() => expect(unlock).toHaveBeenCalledTimes(1))
+ expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument()
+ resolveLock?.()
+ })
+
+ it('cleans up a pending mobile fullscreen request when the table unmounts', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+
+ let resolveFullscreen: (() => void) | undefined
+ const requestFullscreen = vi.fn().mockImplementation(() => new Promise((resolve) => {
+ resolveFullscreen = resolve
+ }))
+ const exitFullscreen = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ })
+ Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ value: exitFullscreen,
+ })
+ const unlock = vi.fn()
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock: vi.fn().mockResolvedValue(undefined), unlock },
+ })
+
+ const rendered = renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1))
+
+ rendered.unmount()
+ resolveFullscreen?.()
+ await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1))
+ expect(unlock).toHaveBeenCalled()
+ })
+
+ it('defers PNG rendering until the user requests an image save', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image')
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ await screen.findByRole('dialog', { name: 'Table' })
+ expect(html2canvas).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' }))
+ await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(1))
+ })
+
+ it('renders a fresh PNG after wrapping changes during an earlier render', async () => {
+ type CanvasStub = { toBlob: (callback: BlobCallback) => void }
+ const pending: Array<(value: CanvasStub | PromiseLike) => void> = []
+ html2canvas.mockImplementation(() => new Promise((resolve) => pending.push(resolve)))
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image')
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Table' })
+ fireEvent.click(screen.getByRole('button', { name: 'Download table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' }))
+ await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(1))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable table wrapping' }))
+ pending[0]?.({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['first'], { type: 'image/png' })),
+ })
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download table' }))
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' }))
+ await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(2))
+ pending[1]?.({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['second'], { type: 'image/png' })),
+ })
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
+ })
+
+ it('recognizes a coarse-pointer phone that starts in landscape', () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 915 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 412 })
+
+ expect(isMobileTableViewerViewport()).toBe(true)
+ })
+
+ it('does not classify a touch-enabled Windows laptop as mobile', () => {
+ window.matchMedia = vi.fn(() => ({
+ matches: false,
+ media: '',
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1366 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 700 })
+ Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
+ Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })
+
+ expect(isMobileTableViewerViewport()).toBe(false)
+ })
+
+ it('hides the viewer toolbar while scrolling down and restores it while scrolling up', async () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 })
+
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: vi.fn().mockResolvedValue(undefined),
+ })
+ Object.defineProperty(window.screen, 'orientation', {
+ configurable: true,
+ value: { lock: vi.fn().mockResolvedValue(undefined), unlock: vi.fn() },
+ })
+
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Table' })
+ const toolbar = dialog.querySelector('[data-hapi-table-viewer-toolbar="true"]')
+ const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]') as HTMLDivElement
+ expect(toolbar).toHaveAttribute('aria-hidden', 'false')
+
+ let scrollTop = 0
+ Object.defineProperty(viewer, 'scrollTop', {
+ configurable: true,
+ get: () => scrollTop,
+ })
+ Object.defineProperty(viewer, 'scrollHeight', {
+ configurable: true,
+ get: () => 100,
+ })
+ Object.defineProperty(viewer, 'clientHeight', {
+ configurable: true,
+ get: () => 20,
+ })
+
+ scrollTop = 20
+ fireEvent.scroll(viewer)
+ await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'true'))
+
+ scrollTop = 15
+ fireEvent.scroll(viewer)
+ expect(toolbar).toHaveAttribute('aria-hidden', 'true')
+
+ scrollTop = 80
+ fireEvent.scroll(viewer)
+ expect(toolbar).toHaveAttribute('aria-hidden', 'true')
+
+ scrollTop = 79
+ fireEvent.scroll(viewer)
+ expect(toolbar).toHaveAttribute('aria-hidden', 'true')
+
+ scrollTop = 80
+ fireEvent.scroll(viewer)
+ expect(toolbar).toHaveAttribute('aria-hidden', 'true')
+
+ scrollTop = 79
+ fireEvent.scroll(viewer)
+ expect(toolbar).toHaveAttribute('aria-hidden', 'true')
+
+ scrollTop = 65
+ fireEvent.scroll(viewer)
+ await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false'))
+
+ scrollTop = 0
+ fireEvent.scroll(viewer)
+ await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false'))
+ })
+
+ it('keeps the toolbar visible when collapsing it would clamp the bottom scroll position', async () => {
+ renderTable()
+ fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' }))
+ const dialog = await screen.findByRole('dialog', { name: 'Table' })
+ const toolbar = dialog.querySelector('[data-hapi-table-viewer-toolbar="true"]') as HTMLDivElement
+ const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]') as HTMLDivElement
+ vi.spyOn(toolbar, 'getBoundingClientRect').mockReturnValue({ height: 36 } as DOMRect)
+
+ let scrollTop = 0
+ Object.defineProperty(viewer, 'scrollTop', {
+ configurable: true,
+ get: () => scrollTop,
+ })
+ Object.defineProperty(viewer, 'scrollHeight', {
+ configurable: true,
+ get: () => 100,
+ })
+ Object.defineProperty(viewer, 'clientHeight', {
+ configurable: true,
+ get: () => 64,
+ })
+
+ scrollTop = 36
+ fireEvent.scroll(viewer)
+ await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false'))
+ })
+
+ it('serializes table cells as an Excel-friendly CSV', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Stars |
|---|
| HAPI | 128 |
| HAPI, local-first | 42 |
'
+
+ expect(serializeTableToCsv(table)).toBe('\uFEFF"Project","Stars"\r\n"HAPI","128"\r\n"HAPI, local-first","42"\r\n')
+ })
+
+ it('neutralizes formula-leading CSV cells', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| =SUM(A1:A2) | +10 | -1 | @command |
'
+
+ expect(serializeTableToCsv(table)).toBe('\uFEFF"\'=SUM(A1:A2)","\'+10","\'-1","\'@command"\r\n')
+ })
+
+ it('serializes the rendered table as Markdown', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Notes |
|---|
| HAPI | Supports | tables |
'
+
+ expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| HAPI | Supports \\| tables |\n')
+ })
+
+ it('preserves inline Markdown formatting when copying a table', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Notes |
|---|
| Docs | hapi and fast local |
'
+
+ expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| [Docs](https://example.com) | `hapi` and **fast** *local* |\n')
+ })
+
+ it('preserves original link targets and safe code fences when copying Markdown', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Notes |
|---|
| Open | a`b a b |
'
+
+ expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| [Open](custom://target) | ``a`b`` ` a b ` |\n')
+ })
+
+ it('preserves backslashes inside inline code when copying Markdown', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Path |
|---|
|
'
+ const code = document.createElement('code')
+ code.textContent = 'C:\\tmp'
+ table.tBodies[0]!.rows[0]!.cells[0]!.append(code)
+
+ expect(serializeTableToMarkdown(table)).toBe('| Path |\n| --- |\n| `C:\\tmp` |\n')
+ })
+
+ it('omits internal HAPI file-link schemes when copying Markdown', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Files |
|---|
|
'
+ const cell = table.tBodies[0]!.rows[0]!.cells[0]!
+ const plainLink = document.createElement('a')
+ plainLink.setAttribute('href', 'hapi-file:docs%2Frouter.tsx')
+ plainLink.textContent = 'docs/router.tsx'
+ const codeLink = document.createElement('a')
+ codeLink.setAttribute('href', 'hapi-file-candidate:docs%2Fmain.ts')
+ const code = document.createElement('code')
+ code.textContent = 'docs/main.ts'
+ codeLink.append(code)
+ cell.append(plainLink, ' ', codeLink)
+
+ expect(serializeTableToMarkdown(table)).toBe('| Files |\n| --- |\n| docs/router.tsx `docs/main.ts` |\n')
+ })
+
+ it('escapes image alt text before rebuilding Markdown', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Image |
|---|
|
'
+ const image = document.createElement('img')
+ image.setAttribute('src', 'https://example.com/table.png')
+ image.setAttribute('alt', 'a]b' + '\\' + '|c')
+ table.tBodies[0]!.rows[0]!.cells[0]!.append(image)
+
+ const expectedAlt = 'a' + '\\]' + 'b' + '\\\\' + '\\|' + 'c'
+ expect(serializeTableToMarkdown(table)).toBe(`| Image |\n| --- |\n|  |\n`)
+ })
+
+ it('keeps sanitized custom-link destinations available for Markdown copying', () => {
+ render(
+
+
+ ,
+ )
+
+ const link = screen.getByRole('link', { name: 'Open' })
+ expect(link).toHaveAttribute('href', '#')
+ expect(link).toHaveAttribute('data-hapi-markdown-href', 'custom://target')
+ expect(serializeTableToMarkdown(screen.getByRole('table'))).toContain('[Open](custom://target)')
+ })
+
+ it('round-trips literal Markdown metacharacters in plain table text', () => {
+ const source = '| Value |\n| --- |\n| \\*literal\\* \\_literal\\_ \\[brackets\\] \\`ticks\\` \\~\\~tilde\\~\\~ \\ |'
+ const firstRender = render(
+
+
+ ,
+ )
+
+ const copied = serializeTableToMarkdown(screen.getByRole('table'))
+ expect(copied).toBe(`${source}\n`)
+ firstRender.unmount()
+
+ render(
+
+
+ ,
+ )
+ expect(screen.getByRole('table').tBodies[0]?.rows[0]?.cells[0]).toHaveTextContent('*literal* _literal_ [brackets] `ticks` ~~tilde~~ ')
+ })
+
+ it('preserves Markdown column alignment when copying a table', () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Stars | Status |
|---|
| HAPI | 128 | Active |
'
+
+ expect(serializeTableToMarkdown(table)).toBe('| Project | Stars | Status |\n| --- | ---: | :---: |\n| HAPI | 128 | Active |\n')
+ })
+
+ it('downloads the rendered table as CSV and keeps its Blob URL alive briefly', () => {
+ vi.useFakeTimers()
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table')
+ const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ let clickedRel: string | undefined
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+ clickedRel = this.rel
+ })
+
+ try {
+ downloadTableAsCsv(table, 'repositories.csv')
+
+ expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
+ expect(click).toHaveBeenCalledTimes(1)
+ expect(clickedRel).toBe('')
+ expect(document.querySelector('a[download="repositories.csv"]')).toBeNull()
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(999)
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(1)
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:hapi-table')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('renders and downloads the table as a PNG image', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image')
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+
+ await saveTableAsImage(table, 'repositories.png')
+
+ expect(html2canvas).toHaveBeenCalledWith(expect.any(HTMLTableElement), expect.objectContaining({ useCORS: true }))
+ expect(html2canvas.mock.calls[0]?.[0]).not.toBe(table)
+ expect(click).toHaveBeenCalledTimes(1)
+ expect(document.querySelector('a[download="repositories.png"]')).toBeNull()
+ })
+
+ it('renders a static table copy when the live header is sticky', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project |
|---|
| HAPI |
'
+ table.querySelector('thead')?.setAttribute('style', 'position: sticky; top: 0;')
+
+ html2canvas.mockImplementation(async () => {
+ return {
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ }
+ })
+
+ await renderTableAsImage(table)
+
+ const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined
+ expect(renderedTable).toBeDefined()
+ expect(renderedTable).not.toBe(table)
+ expect(renderedTable?.querySelector('thead')?.getAttribute('style')).not.toContain('position: sticky')
+ expect(document.querySelector('[data-hapi-table-image-render="true"]')).toBeNull()
+ expect(table.querySelector('thead')).toHaveStyle({ position: 'sticky', top: '0px' })
+ })
+
+ it('preserves source column widths and header styling in the image clone', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| Project | Status |
|---|
| HAPI | Ready |
'
+ table.style.backgroundColor = 'rgb(28, 28, 30)'
+ table.tHead!.style.backgroundColor = 'rgb(53, 59, 67)'
+ table.tHead!.rows[0]!.cells[0]!.style.backgroundColor = 'rgb(70, 70, 70)'
+ table.tHead!.rows[0]!.cells[1]!.style.backgroundColor = 'rgb(80, 80, 80)'
+ Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 1_200 })
+ vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ top: 0, width: 1_200, height: 120 } as DOMRect)
+ const rows = Array.from(table.rows)
+ rows.forEach((row, rowIndex) => {
+ vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ top: rowIndex * 60, bottom: (rowIndex + 1) * 60, height: 60 } as DOMRect)
+ Array.from(row.cells).forEach((cell, cellIndex) => {
+ vi.spyOn(cell, 'getBoundingClientRect').mockReturnValue({
+ width: 600,
+ height: 60,
+ left: cellIndex * 600,
+ right: (cellIndex + 1) * 600,
+ } as DOMRect)
+ })
+ })
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+
+ await renderTableAsImage(table)
+
+ const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined
+ expect(renderedTable?.style.tableLayout).toBe('fixed')
+ expect(Array.from(renderedTable?.querySelectorAll('col') ?? []).map((col) => col.style.width)).toEqual(['600px', '600px'])
+ expect(renderedTable?.tHead?.style.backgroundColor).toBe('rgb(53, 59, 67)')
+ expect(Array.from(renderedTable?.tHead?.querySelectorAll('th') ?? []).map((cell) => cell.style.backgroundColor)).toEqual([
+ 'rgb(70, 70, 70)',
+ 'rgb(80, 80, 80)',
+ ])
+ })
+
+ it('crops trailing table box space from PNG exports', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 844 })
+ vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({
+ top: 120,
+ width: 1_851,
+ height: 844,
+ } as DOMRect)
+ const row = table.querySelector('tr')
+ vi.spyOn(row!, 'getBoundingClientRect').mockReturnValue({
+ top: 120,
+ bottom: 720,
+ height: 600,
+ } as DOMRect)
+ html2canvas.mockResolvedValue({
+ toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
+ })
+
+ expect(getTableExportHeight(table)).toBe(600)
+ await renderTableAsImage(table)
+
+ const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined
+ const options = html2canvas.mock.calls[0]?.[1] as { height: number } | undefined
+ expect(options?.height).toBe(600)
+ expect(renderedTable?.style.height).toBe('600px')
+ })
+
+ it('keeps export scale bounded and calculates safe vertical tiles', () => {
+ const scale = getTableExportScale(10_000, 10_000, 1)
+ expect(scale).toBeLessThan(1)
+ expect(10_000 * 10_000 * scale ** 2).toBeLessThanOrEqual(MAX_TABLE_EXPORT_PIXELS)
+
+ const tallScale = getTableExportScale(100, 100_000, 2)
+ expect(Math.ceil(100 * tallScale)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION)
+ expect(Math.ceil(100_000 * tallScale)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION)
+
+ const tileHeight = getTableExportTileHeight(3_000, 2)
+ expect(3_000 * tileHeight * 2 ** 2).toBeLessThanOrEqual(MAX_TABLE_EXPORT_TILE_PIXELS)
+ expect(3_000 * (tileHeight + 1) * 2 ** 2).toBeGreaterThan(MAX_TABLE_EXPORT_TILE_PIXELS)
+ })
+
+ it('measures intrinsic width while a table is currently wrapped', () => {
+ const table = document.createElement('table')
+ const viewer = document.createElement('div')
+ table.setAttribute('data-hapi-table-wrap', 'true')
+ Object.defineProperty(viewer, 'clientWidth', { configurable: true, value: 150 })
+ Object.defineProperty(table, 'scrollWidth', {
+ configurable: true,
+ get: () => table.hasAttribute('data-hapi-table-wrap') ? 150 : 200,
+ })
+
+ expect(shouldWrapTableByDefault(table, viewer)).toBe(true)
+ expect(table.getAttribute('data-hapi-table-wrap')).toBe('true')
+ })
+
+ it('stitches oversized exports from bounded vertical tiles', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 3_000 })
+ Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 4_000 })
+ vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ width: 3_000, height: 4_000 } as DOMRect)
+
+ const originalDevicePixelRatio = window.devicePixelRatio
+ Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 })
+ const drawImage = vi.fn()
+ const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D)
+ const toBlob = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => {
+ callback(new Blob(['png'], { type: 'image/png' }))
+ })
+ html2canvas.mockImplementation(async (_element: HTMLElement, options: { width: number; height: number; scale: number }) => ({
+ width: Math.ceil(options.width * options.scale),
+ height: Math.ceil(options.height * options.scale),
+ toBlob: (callback: BlobCallback) => callback(new Blob(['tile'], { type: 'image/png' })),
+ }))
+
+ try {
+ await renderTableAsImage(table)
+
+ const expectedTiles = Math.ceil(4_000 / getTableExportTileHeight(3_000, getTableExportScale(3_000, 4_000, 2)))
+ expect(html2canvas).toHaveBeenCalledTimes(expectedTiles)
+ expect(drawImage).toHaveBeenCalledTimes(expectedTiles)
+ const scale = getTableExportScale(3_000, 4_000, 2)
+ const destinationRanges = drawImage.mock.calls.map((call) => ({
+ top: call[6] as number,
+ bottom: (call[6] as number) + (call[8] as number),
+ }))
+ for (let index = 1; index < destinationRanges.length; index += 1) {
+ expect(destinationRanges[index - 1]?.bottom).toBe(destinationRanges[index]?.top)
+ }
+ expect(destinationRanges.at(-1)?.bottom).toBe(Math.ceil(4_000 * scale))
+ expect(toBlob).toHaveBeenCalledTimes(1)
+ expect(getContext).toHaveBeenCalledWith('2d')
+ expect(document.querySelector('[data-hapi-table-image-render="true"]')).toBeNull()
+ } finally {
+ Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: originalDevicePixelRatio })
+ }
+ })
+
+ it('keeps the stitched PNG canvas within the browser dimension limit', async () => {
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 1_000 })
+ Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 20_000 })
+ vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ width: 1_000, height: 20_000 } as DOMRect)
+
+ const createdCanvases: HTMLCanvasElement[] = []
+ const createElement = document.createElement.bind(document)
+ vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
+ const element = createElement(tagName, options)
+ if (tagName === 'canvas') createdCanvases.push(element as HTMLCanvasElement)
+ return element
+ })
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D)
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => {
+ callback(new Blob(['png'], { type: 'image/png' }))
+ })
+ html2canvas.mockImplementation(async (_element: HTMLElement, options: { width: number; height: number; scale: number }) => ({
+ width: Math.ceil(options.width * options.scale),
+ height: Math.ceil(options.height * options.scale),
+ toBlob: (callback: BlobCallback) => callback(new Blob(['tile'], { type: 'image/png' })),
+ }))
+
+ await renderTableAsImage(table)
+
+ expect(createdCanvases).toHaveLength(1)
+ expect(Math.max(createdCanvases[0]?.width ?? 0, createdCanvases[0]?.height ?? 0)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION)
+ })
+
+ it('uses the same direct download path on touch devices as shared images', () => {
+ window.matchMedia = vi.fn((query: string) => ({
+ matches: query.includes('pointer: coarse'),
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() { return false },
+ })) as typeof window.matchMedia
+
+ const share = vi.fn().mockResolvedValue(undefined)
+ const canShare = vi.fn().mockReturnValue(true)
+ Object.defineProperty(navigator, 'share', { configurable: true, value: share })
+ Object.defineProperty(navigator, 'canShare', { configurable: true, value: canShare })
+
+ const table = document.createElement('table')
+ table.innerHTML = '| HAPI |
'
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ downloadTableAsCsv(table, 'repositories.csv')
+
+ expect(canShare).not.toHaveBeenCalled()
+ expect(share).not.toHaveBeenCalled()
+ expect(click).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/src/components/assistant-ui/MarkdownTable.tsx b/web/src/components/assistant-ui/MarkdownTable.tsx
new file mode 100644
index 0000000000..3867c28806
--- /dev/null
+++ b/web/src/components/assistant-ui/MarkdownTable.tsx
@@ -0,0 +1,1251 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ComponentPropsWithoutRef,
+ type RefObject,
+ type ReactNode,
+} from 'react'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import * as Popover from '@radix-ui/react-popover'
+import { CheckIcon, CloseIcon, CopyIcon, WrapIcon } from '@/components/icons'
+import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
+import { Spinner } from '@/components/Spinner'
+import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
+import { getShareImageFileName, getShareTableFileName } from '@/lib/share-image-filename'
+import { useTranslation } from '@/lib/use-translation'
+import { cn } from '@/lib/utils'
+
+type TableProps = ComponentPropsWithoutRef<'table'>
+
+type IconProps = {
+ className?: string
+}
+
+type TableOrientationApi = {
+ lock?: (orientation: 'landscape') => Promise
+ unlock?: () => void
+}
+
+function ExpandIcon(props: IconProps) {
+ return (
+
+ )
+}
+
+function DownloadIcon(props: IconProps) {
+ return (
+
+ )
+}
+
+function TableActionButton(props: {
+ label: string
+ onClick: () => void
+ children: ReactNode
+ variant?: 'surface' | 'ghost'
+}) {
+ const variantClassName = props.variant === 'ghost'
+ ? 'border-0 bg-transparent text-[var(--app-hint)] shadow-none hover:text-[var(--app-fg)]'
+ : 'border border-[var(--app-border)] bg-[var(--app-md-table-bg)]/90 text-[var(--app-hint)] shadow-sm hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]'
+
+ return (
+
+ )
+}
+
+function isCoarsePointerDevice(): boolean {
+ if (typeof window === 'undefined') return false
+
+ const coarsePointer = window.matchMedia('(pointer: coarse)').matches
+ const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints : 0
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''
+ const mobileUserAgent = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent)
+ const desktopModeIpad = /Macintosh/i.test(userAgent) && touchPoints > 1
+
+ return coarsePointer || mobileUserAgent || desktopModeIpad
+}
+
+function TableActionMenu(props: {
+ label: string
+ children: ReactNode
+ tabIndex?: number
+ items: Array<{
+ label: string
+ onSelect: () => void
+ disabled?: boolean
+ }>
+}) {
+ const [open, setOpen] = useState(false)
+ const closeTimerRef = useRef(null)
+
+ const clearCloseTimer = useCallback(() => {
+ if (closeTimerRef.current == null) return
+ window.clearTimeout(closeTimerRef.current)
+ closeTimerRef.current = null
+ }, [])
+
+ const scheduleClose = useCallback(() => {
+ clearCloseTimer()
+ closeTimerRef.current = window.setTimeout(() => {
+ closeTimerRef.current = null
+ setOpen(false)
+ }, 140)
+ }, [clearCloseTimer])
+
+ useEffect(() => () => clearCloseTimer(), [clearCloseTimer])
+
+ return (
+
+
+
+
+
+
+
+
+ {props.items.map((item) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+/** Exported for responsive behavior tests and future table viewers. */
+export function isMobileTableViewerViewport(): boolean {
+ if (typeof window === 'undefined') return false
+ const shortSide = Math.min(window.innerWidth, window.innerHeight)
+ return shortSide <= 767 && isCoarsePointerDevice()
+}
+
+function getTableCellText(cell: HTMLTableCellElement): string {
+ const innerText = cell.innerText
+ const text = typeof innerText === 'string' ? innerText : cell.textContent ?? ''
+ return text.replace(/\s+/g, ' ').trim()
+}
+
+function escapeCsvCell(value: string): string {
+ const safeValue = /^[\t\r\n ]*[=+\-@]/.test(value) ? `'${value}` : value
+ return `"${safeValue.replace(/"/g, '""')}"`
+}
+
+export function serializeTableToCsv(table: HTMLTableElement): string {
+ const rows = Array.from(table.rows).map((row) =>
+ Array.from(row.cells).map((cell) => escapeCsvCell(getTableCellText(cell))).join(','),
+ )
+
+ return rows.length > 0 ? `\uFEFF${rows.join('\r\n')}\r\n` : '\uFEFF'
+}
+
+function escapeMarkdownTableCell(value: string): string {
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
+}
+
+function serializeMarkdownText(value: string): string {
+ return value
+ .replace(/\s+/g, ' ')
+ .replace(/\\/g, '\\\\')
+ .replace(/([`*_\[\]<>~])/g, '\\$1')
+}
+
+function serializeInlineMarkdown(node: Node): string {
+ if (node.nodeType === Node.TEXT_NODE) return serializeMarkdownText(node.textContent ?? '')
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ return Array.from(node.childNodes, serializeInlineMarkdown).join('')
+ }
+
+ const element = node as HTMLElement
+ const children = () => Array.from(element.childNodes, serializeInlineMarkdown).join('')
+ switch (element.tagName.toLowerCase()) {
+ case 'a': {
+ const href = element.dataset.hapiMarkdownHref ?? element.getAttribute('href')
+ const label = children()
+ if (href?.startsWith('hapi-file:') || href?.startsWith('hapi-file-candidate:')) return label
+ return href ? `[${label}](${href})` : label
+ }
+ case 'code':
+ return serializeCodeSpan(element.textContent ?? '')
+ case 'strong':
+ case 'b':
+ return `**${children()}**`
+ case 'em':
+ case 'i':
+ return `*${children()}*`
+ case 'del':
+ case 's':
+ return `~~${children()}~~`
+ case 'br':
+ return ' '
+ case 'img': {
+ const src = element.getAttribute('src')
+ const alt = serializeMarkdownText(element.getAttribute('alt') ?? '')
+ return src ? `` : alt
+ }
+ case 'span': {
+ const href = element.dataset.hapiMarkdownHref
+ return href ? `[${children()}](${href})` : children()
+ }
+ default:
+ return children()
+ }
+}
+
+function serializeCodeSpan(value: string): string {
+ const longestRun = Math.max(
+ 0,
+ ...Array.from(value.matchAll(/`+/g), (match) => match[0].length),
+ )
+ const fence = '`'.repeat(longestRun + 1)
+ const needsPadding = value.startsWith('`')
+ || value.endsWith('`')
+ || (value.startsWith(' ') && value.endsWith(' ') && value.trim().length > 0)
+ const padding = needsPadding ? ' ' : ''
+ return `${fence}${padding}${value}${padding}${fence}`
+}
+
+function getTableCellMarkdown(cell: HTMLTableCellElement): string {
+ return Array.from(cell.childNodes, serializeInlineMarkdown)
+ .join('')
+ .trim()
+}
+
+function formatMarkdownTableRow(cells: string[], width: number): string {
+ const padded = [...cells, ...Array.from({ length: Math.max(0, width - cells.length) }, () => '')]
+ return `| ${padded.map(escapeMarkdownTableCell).join(' | ')} |`
+}
+
+function getTableCellAlignment(cell: HTMLTableCellElement | undefined): 'left' | 'center' | 'right' | undefined {
+ const alignment = cell?.getAttribute('align') ?? cell?.style.textAlign
+ if (alignment === 'left' || alignment === 'center' || alignment === 'right') return alignment
+ return undefined
+}
+
+function formatMarkdownAlignment(alignment: 'left' | 'center' | 'right' | undefined): string {
+ if (alignment === 'left') return ':---'
+ if (alignment === 'center') return ':---:'
+ if (alignment === 'right') return '---:'
+ return '---'
+}
+
+export function serializeTableToMarkdown(table: HTMLTableElement): string {
+ const rows = Array.from(table.rows).map((row) =>
+ Array.from(row.cells).map(getTableCellMarkdown),
+ )
+ if (rows.length === 0) return ''
+
+ const width = Math.max(...rows.map((row) => row.length), 1)
+ const header = rows[0] ?? []
+ const headerCells = Array.from(table.tHead?.rows[0]?.cells ?? table.rows[0]?.cells ?? [])
+ const separator = Array.from({ length: width }, (_, index) => formatMarkdownAlignment(getTableCellAlignment(headerCells[index])))
+ return [
+ formatMarkdownTableRow(header, width),
+ formatMarkdownTableRow(separator, width),
+ ...rows.slice(1).map((row) => formatMarkdownTableRow(row, width)),
+ ].join('\n') + '\n'
+}
+
+const TABLE_WRAP_PREFERENCE_PREFIX = 'hapi-table-wrap:v1'
+
+function hashTableWrapIdentity(value: string): string {
+ let hash = 2166136261
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index)
+ hash = Math.imul(hash, 16777619)
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0')
+}
+
+export function getTableWrapPreferenceKey(table: HTMLTableElement, scope = 'Table'): string {
+ const identity = `${scope.trim() || 'Table'}\n${serializeTableToMarkdown(table)}`
+ return `${TABLE_WRAP_PREFERENCE_PREFIX}:${hashTableWrapIdentity(identity)}`
+}
+
+export function shouldWrapTableByDefault(table: HTMLTableElement, viewer: HTMLElement): boolean {
+ const wrappedValue = table.getAttribute('data-hapi-table-wrap')
+ if (wrappedValue === null) return table.scrollWidth > viewer.clientWidth + 1
+
+ table.removeAttribute('data-hapi-table-wrap')
+ try {
+ return table.scrollWidth > viewer.clientWidth + 1
+ } finally {
+ table.setAttribute('data-hapi-table-wrap', wrappedValue)
+ }
+}
+
+function readTableWrapPreference(key: string | undefined): boolean | null {
+ if (!key || typeof window === 'undefined') return null
+ try {
+ const value = window.localStorage.getItem(key)
+ if (value === '1') return true
+ if (value === '0') return false
+ } catch {
+ // Private browsing and blocked storage should not affect the viewer.
+ }
+ return null
+}
+
+function writeTableWrapPreference(key: string | undefined, value: boolean): void {
+ if (!key || typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(key, value ? '1' : '0')
+ } catch {
+ // Ignore storage quota and privacy-mode failures.
+ }
+}
+
+export function downloadTableAsCsv(table: HTMLTableElement, filename = 'hapi-table.csv'): void {
+ if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
+
+ const blob = new Blob([serializeTableToCsv(table)], { type: 'text/csv;charset=utf-8' })
+ downloadBlob(blob, filename)
+}
+
+function downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = filename
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ // Match the working session-image download path. Some mobile browsers
+ // read a Blob URL asynchronously after click; revoking it immediately
+ // makes the download appear to do nothing.
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+const TABLE_IMAGE_STYLE_PROPERTIES = [
+ 'background-color',
+ 'border-bottom-color',
+ 'border-bottom-style',
+ 'border-bottom-width',
+ 'border-collapse',
+ 'border-left-color',
+ 'border-left-style',
+ 'border-left-width',
+ 'border-right-color',
+ 'border-right-style',
+ 'border-right-width',
+ 'border-spacing',
+ 'border-top-color',
+ 'border-top-style',
+ 'border-top-width',
+ 'box-sizing',
+ 'color',
+ 'font-family',
+ 'font-size',
+ 'font-style',
+ 'font-weight',
+ 'letter-spacing',
+ 'line-height',
+ 'overflow-wrap',
+ 'padding-bottom',
+ 'padding-left',
+ 'padding-right',
+ 'padding-top',
+ 'text-align',
+ 'text-decoration',
+ 'text-transform',
+ 'vertical-align',
+ 'white-space',
+ 'word-break',
+] as const
+
+function copyTableImageStyles(source: HTMLTableElement, clone: HTMLTableElement): void {
+ const sourceElements = [source, ...Array.from(source.querySelectorAll('*'))]
+ const cloneElements = [clone, ...Array.from(clone.querySelectorAll('*'))]
+
+ sourceElements.forEach((sourceElement, index) => {
+ const cloneElement = cloneElements[index]
+ if (!cloneElement) return
+ const cloneStyle = (cloneElement as Element & { style?: CSSStyleDeclaration }).style
+ if (!cloneStyle) return
+ const computedStyle = getComputedStyle(sourceElement)
+ for (const property of TABLE_IMAGE_STYLE_PROPERTIES) {
+ cloneStyle.setProperty(property, computedStyle.getPropertyValue(property), 'important')
+ }
+ })
+}
+
+function getTableColumnWidths(table: HTMLTableElement): number[] {
+ const widths: number[] = []
+ for (const row of Array.from(table.rows)) {
+ let columnIndex = 0
+ for (const cell of Array.from(row.cells)) {
+ const span = Math.max(1, cell.colSpan || 1)
+ const cellWidth = cell.getBoundingClientRect().width
+ if (cellWidth > 0 && Number.isFinite(cellWidth)) {
+ const columnWidth = cellWidth / span
+ for (let offset = 0; offset < span; offset += 1) {
+ const index = columnIndex + offset
+ widths[index] = Math.max(widths[index] ?? 0, columnWidth)
+ }
+ }
+ columnIndex += span
+ }
+ }
+ return widths
+}
+
+function applyTableImageColumnWidths(table: HTMLTableElement, clone: HTMLTableElement, tableWidth: number): void {
+ const measuredWidths = getTableColumnWidths(table)
+ const totalMeasuredWidth = measuredWidths.reduce((total, width) => total + width, 0)
+ if (measuredWidths.length === 0 || totalMeasuredWidth <= 0) return
+
+ const widthScale = tableWidth / totalMeasuredWidth
+ const colgroup = document.createElement('colgroup')
+ for (const measuredWidth of measuredWidths) {
+ const col = document.createElement('col')
+ col.style.width = `${measuredWidth * widthScale}px`
+ colgroup.appendChild(col)
+ }
+ clone.querySelectorAll(':scope > colgroup').forEach((existing) => existing.remove())
+ clone.insertBefore(colgroup, clone.firstChild)
+ clone.style.setProperty('table-layout', 'fixed', 'important')
+}
+
+function isTransparentColor(value: string): boolean {
+ return value === '' || value === 'transparent' || /rgba\([^)]*,\s*0\s*\)$/i.test(value)
+}
+
+function getTableHeaderBackground(table: HTMLTableElement): string | null {
+ const head = table.tHead
+ if (!head) return null
+ const computedBackground = getComputedStyle(head).backgroundColor
+ if (!isTransparentColor(computedBackground)) return computedBackground
+
+ let element: Element | null = head
+ while (element) {
+ const value = getComputedStyle(element).getPropertyValue('--app-md-table-head-bg').trim()
+ if (value) return value
+ element = element.parentElement
+ }
+ return getComputedStyle(document.documentElement).getPropertyValue('--app-md-table-head-bg').trim() || null
+}
+
+function applyTableImageHeaderBackground(table: HTMLTableElement, clone: HTMLTableElement): void {
+ const background = getTableHeaderBackground(table)
+ const cloneHead = clone.tHead
+ if (!background || !cloneHead) return
+
+ cloneHead.style.setProperty('background-color', background, 'important')
+ cloneHead.querySelectorAll(':is(th, td)').forEach((cell) => {
+ if (!(cell instanceof HTMLElement)) return
+ if (!isTransparentColor(cell.style.getPropertyValue('background-color'))) return
+ cell.style.setProperty('background-color', background, 'important')
+ })
+}
+
+function createStaticTableImageClone(
+ table: HTMLTableElement,
+ tableWidth: number,
+ tableHeight: number,
+ tileTop = 0,
+ tileHeight?: number,
+): {
+ table: HTMLTableElement
+ capture: HTMLElement
+ cleanup: () => void
+} {
+ const wrapper = document.createElement('div')
+ wrapper.dataset.hapiTableImageRender = 'true'
+ Object.assign(wrapper.style, {
+ position: 'fixed',
+ left: '-100000px',
+ top: '0',
+ width: `${tableWidth}px`,
+ maxWidth: 'none',
+ height: tileHeight == null ? 'auto' : `${tileHeight}px`,
+ overflow: tileHeight == null ? 'visible' : 'hidden',
+ pointerEvents: 'none',
+ })
+
+ const clone = table.cloneNode(true) as HTMLTableElement
+ clone.style.setProperty('width', `${tableWidth}px`, 'important')
+ clone.style.setProperty('min-width', `${tableWidth}px`, 'important')
+ clone.style.setProperty('height', `${tableHeight}px`, 'important')
+ copyTableImageStyles(table, clone)
+ applyTableImageColumnWidths(table, clone, tableWidth)
+ applyTableImageHeaderBackground(table, clone)
+ if (tileHeight != null && tileTop > 0) {
+ clone.style.setProperty('transform', `translateY(-${tileTop}px)`, 'important')
+ }
+ clone.querySelectorAll('thead, thead *').forEach((element) => {
+ if (!(element instanceof HTMLElement)) return
+ element.style.setProperty('position', 'static', 'important')
+ element.style.removeProperty('top')
+ element.style.removeProperty('z-index')
+ })
+
+ wrapper.appendChild(clone)
+ document.body.appendChild(wrapper)
+ return {
+ table: clone,
+ capture: tileHeight == null ? clone : wrapper,
+ cleanup: () => wrapper.remove(),
+ }
+}
+
+export const MAX_TABLE_EXPORT_PIXELS = 36_000_000
+export const MAX_TABLE_EXPORT_TILE_PIXELS = 12_000_000
+export const MAX_TABLE_EXPORT_DIMENSION = 16_384
+
+export function getTableExportScale(
+ tableWidth: number,
+ tableHeight: number,
+ devicePixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio,
+): number {
+ const area = Math.max(1, tableWidth) * Math.max(1, tableHeight)
+ const dimensionScale = MAX_TABLE_EXPORT_DIMENSION / Math.max(tableWidth, tableHeight)
+ return Math.min(
+ devicePixelRatio || 1,
+ 2,
+ Math.sqrt(MAX_TABLE_EXPORT_PIXELS / area),
+ dimensionScale,
+ )
+}
+
+export function getTableExportTileHeight(tableWidth: number, scale: number): number {
+ return Math.max(1, Math.floor(
+ MAX_TABLE_EXPORT_TILE_PIXELS / Math.max(1, tableWidth * scale * scale),
+ ))
+}
+
+export function getTableExportHeight(table: HTMLTableElement): number {
+ const tableRect = table.getBoundingClientRect()
+ const fallbackHeight = Math.max(table.scrollHeight, Math.ceil(tableRect.height), 1)
+ const rowBottoms = Array.from(table.rows)
+ .map((row) => row.getBoundingClientRect())
+ .filter((rowRect) => rowRect.height > 0 && Number.isFinite(rowRect.bottom))
+ .map((rowRect) => rowRect.bottom - tableRect.top)
+ if (rowBottoms.length === 0) return fallbackHeight
+
+ const contentHeight = Math.ceil(Math.max(...rowBottoms))
+ return contentHeight > 0 ? Math.min(fallbackHeight, contentHeight) : fallbackHeight
+}
+
+function canvasToPngBlob(canvas: HTMLCanvasElement): Promise {
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')).then((blob) => {
+ if (!blob) throw new Error('Failed to encode table image')
+ return blob
+ })
+}
+
+export async function renderTableAsImage(table: HTMLTableElement): Promise {
+ if (typeof document === 'undefined') throw new Error('Cannot render a table outside the browser')
+
+ const { default: html2canvas } = await import('html2canvas-pro')
+ const tableRect = table.getBoundingClientRect()
+ const tableWidth = Math.max(table.scrollWidth, Math.ceil(tableRect.width), 1)
+ const tableHeight = getTableExportHeight(table)
+ const tableBackground = getComputedStyle(table).backgroundColor
+ const backgroundColor = tableBackground === 'rgba(0, 0, 0, 0)'
+ ? getComputedStyle(document.body).backgroundColor
+ : tableBackground
+ const scale = getTableExportScale(tableWidth, tableHeight)
+ const rasterPixels = tableWidth * tableHeight * scale * scale
+ const renderOptions = {
+ backgroundColor: backgroundColor || null,
+ foreignObjectRendering: false,
+ logging: false,
+ scale,
+ useCORS: true,
+ windowWidth: Math.max(document.documentElement.clientWidth, tableWidth),
+ windowHeight: Math.max(document.documentElement.clientHeight, tableHeight),
+ }
+
+ if (rasterPixels <= MAX_TABLE_EXPORT_TILE_PIXELS) {
+ const imageTable = createStaticTableImageClone(table, tableWidth, tableHeight)
+ try {
+ const canvas = await html2canvas(imageTable.capture, {
+ ...renderOptions,
+ width: tableWidth,
+ height: tableHeight,
+ })
+ return canvasToPngBlob(canvas)
+ } finally {
+ imageTable.cleanup()
+ }
+ }
+
+ const outputCanvas = document.createElement('canvas')
+ outputCanvas.width = Math.max(1, Math.ceil(tableWidth * scale))
+ outputCanvas.height = Math.max(1, Math.ceil(tableHeight * scale))
+ const outputContext = outputCanvas.getContext('2d')
+ if (!outputContext) throw new Error('Failed to create table image canvas')
+
+ const tileHeight = getTableExportTileHeight(tableWidth, scale)
+ for (let tileTop = 0; tileTop < tableHeight; tileTop += tileHeight) {
+ const currentTileHeight = Math.min(tileHeight, tableHeight - tileTop)
+ const imageTable = createStaticTableImageClone(table, tableWidth, tableHeight, tileTop, currentTileHeight)
+ try {
+ const tileCanvas = await html2canvas(imageTable.capture, {
+ ...renderOptions,
+ width: tableWidth,
+ height: currentTileHeight,
+ windowHeight: Math.max(document.documentElement.clientHeight, currentTileHeight),
+ })
+ const destinationTop = Math.round(tileTop * scale)
+ const destinationBottom = tileTop + currentTileHeight >= tableHeight
+ ? outputCanvas.height
+ : Math.round((tileTop + currentTileHeight) * scale)
+ outputContext.drawImage(
+ tileCanvas,
+ 0,
+ 0,
+ tileCanvas.width,
+ tileCanvas.height,
+ 0,
+ destinationTop,
+ outputCanvas.width,
+ Math.max(1, destinationBottom - destinationTop),
+ )
+ } finally {
+ imageTable.cleanup()
+ }
+ }
+
+ return canvasToPngBlob(outputCanvas)
+}
+
+export async function saveTableAsImage(table: HTMLTableElement, filename = getShareImageFileName('Table', 'table')): Promise {
+ const blob = await renderTableAsImage(table)
+ await downloadBlob(blob, filename)
+}
+
+function writeTableImageToClipboard(
+ mimeType: string,
+ image: Blob | PromiseLike,
+): Promise {
+ try {
+ const ClipboardItemCtor = window.ClipboardItem
+ if (!navigator.clipboard?.write || !ClipboardItemCtor) {
+ throw new Error('Image clipboard is not supported in this browser')
+ }
+ return Promise.resolve(navigator.clipboard.write([
+ new ClipboardItemCtor({ [mimeType]: image }),
+ ]))
+ } catch (error) {
+ return Promise.reject(error)
+ }
+}
+
+export function copyTableImageToClipboard(blob: Blob): Promise {
+ return writeTableImageToClipboard(blob.type, blob)
+}
+
+export function copyTableImagePromiseToClipboard(imagePromise: Promise): Promise {
+ return writeTableImageToClipboard('image/png', imagePromise)
+}
+
+/**
+ * Mobile browsers generally only honor orientation locks from fullscreen.
+ * Keep this best-effort so unsupported browsers still get the full table view.
+ */
+export async function enterMobileTableViewer(): Promise {
+ if (typeof document === 'undefined') return false
+
+ let enteredFullscreen = false
+ const root = document.documentElement
+ if (!document.fullscreenElement && typeof root.requestFullscreen === 'function') {
+ try {
+ await root.requestFullscreen()
+ enteredFullscreen = true
+ } catch {
+ // Fullscreen can be denied by browser policy; keep the viewer usable.
+ }
+ }
+
+ const orientation = typeof window !== 'undefined'
+ ? window.screen.orientation as unknown as TableOrientationApi | undefined
+ : undefined
+ if (orientation && typeof orientation.lock === 'function') {
+ try {
+ void orientation.lock('landscape').catch(() => undefined)
+ } catch {
+ // Orientation lock is unavailable on some browsers and iOS versions.
+ }
+ }
+
+ return enteredFullscreen
+}
+
+export function leaveMobileTableViewer(enteredFullscreen: boolean): void {
+ if (typeof window !== 'undefined') {
+ const orientation = window.screen.orientation as unknown as TableOrientationApi | undefined
+ if (orientation && typeof orientation.unlock === 'function') {
+ try {
+ orientation.unlock()
+ } catch {
+ // Ignore browsers that reject unlock after an interrupted rotation.
+ }
+ }
+ }
+
+ if (enteredFullscreen && typeof document !== 'undefined' && typeof document.exitFullscreen === 'function') {
+ void document.exitFullscreen().catch(() => {
+ // The user may already have exited browser fullscreen manually.
+ })
+ }
+}
+
+function TableViewer(props: {
+ open: boolean
+ onClose: () => void
+ tableProps: TableProps
+ tableRef: RefObject
+ imageTitle: string
+ tableWrapPreferenceKey?: string
+}) {
+ const { t } = useTranslation()
+ const { className, children, ...rest } = props.tableProps
+ const { copied, copy, markCopied } = useCopyToClipboard()
+ const [imageAction, setImageAction] = useState<'copy' | 'download' | null>(null)
+ const [imageError, setImageError] = useState(false)
+ const [wrapEnabled, setWrapEnabled] = useState(false)
+ const [toolbarVisible, setToolbarVisible] = useState(true)
+ const viewerRef = useRef(null)
+ const toolbarRef = useRef(null)
+ const lastScrollTopRef = useRef(0)
+ const reverseScrollDistanceRef = useRef(0)
+ const toolbarVisibleRef = useRef(true)
+ const explicitWrapRef = useRef(false)
+ const isMobileViewer = isMobileTableViewerViewport()
+
+ const setToolbarState = useCallback((visible: boolean) => {
+ if (toolbarVisibleRef.current === visible) return
+ toolbarVisibleRef.current = visible
+ setToolbarVisible(visible)
+ }, [])
+
+ const handleViewerScroll = useCallback(() => {
+ const viewer = viewerRef.current
+ if (!viewer) return
+
+ const scrollTop = viewer.scrollTop
+ const previousScrollTop = lastScrollTopRef.current
+ lastScrollTopRef.current = scrollTop
+ const delta = scrollTop - previousScrollTop
+
+ if (scrollTop <= 0) {
+ reverseScrollDistanceRef.current = 0
+ setToolbarState(true)
+ } else if (delta > 0) {
+ reverseScrollDistanceRef.current = 0
+ const toolbarHeight = toolbarRef.current?.getBoundingClientRect().height ?? 0
+ const distanceToBottom = Math.max(0, viewer.scrollHeight - viewer.clientHeight - scrollTop)
+ setToolbarState(toolbarHeight > 0 && distanceToBottom < toolbarHeight)
+ } else if (delta < 0 && !toolbarVisibleRef.current) {
+ reverseScrollDistanceRef.current += -delta
+ const distanceToBottom = Math.max(0, viewer.scrollHeight - viewer.clientHeight - scrollTop)
+ if (reverseScrollDistanceRef.current >= 12 && distanceToBottom > 8) {
+ reverseScrollDistanceRef.current = 0
+ setToolbarState(true)
+ }
+ } else if (delta >= 0) {
+ reverseScrollDistanceRef.current = 0
+ }
+ }, [setToolbarState])
+
+ const setViewerElement = useCallback((viewer: HTMLDivElement | null) => {
+ const previousViewer = viewerRef.current
+ if (previousViewer) previousViewer.removeEventListener('scroll', handleViewerScroll)
+
+ viewerRef.current = viewer
+ if (!viewer) return
+
+ reverseScrollDistanceRef.current = 0
+ lastScrollTopRef.current = viewer.scrollTop
+ setToolbarState(viewer.scrollTop <= 0)
+ viewer.addEventListener('scroll', handleViewerScroll, { passive: true })
+ }, [handleViewerScroll, setToolbarState])
+
+ useEffect(() => {
+ if (!props.open) {
+ lastScrollTopRef.current = 0
+ reverseScrollDistanceRef.current = 0
+ setToolbarState(true)
+ }
+ }, [props.open, setToolbarState])
+
+ useEffect(() => () => {
+ viewerRef.current?.removeEventListener('scroll', handleViewerScroll)
+ }, [handleViewerScroll])
+
+ useEffect(() => {
+ if (!props.open) {
+ setWrapEnabled(false)
+ setImageError(false)
+ }
+ }, [props.open])
+
+ useLayoutEffect(() => {
+ if (!props.open) {
+ explicitWrapRef.current = false
+ return undefined
+ }
+
+ const storedPreference = readTableWrapPreference(props.tableWrapPreferenceKey)
+ explicitWrapRef.current = storedPreference !== null
+ if (storedPreference !== null) {
+ setWrapEnabled(storedPreference)
+ return undefined
+ }
+
+ const measureOverflow = () => {
+ if (explicitWrapRef.current) return
+ const viewer = viewerRef.current
+ const table = props.tableRef.current
+ if (!viewer || !table) return
+ const shouldWrap = shouldWrapTableByDefault(table, viewer)
+ setWrapEnabled(shouldWrap)
+ }
+
+ let observer: ResizeObserver | undefined
+ const frame = window.requestAnimationFrame(() => {
+ measureOverflow()
+ const viewer = viewerRef.current
+ if (viewer && typeof ResizeObserver !== 'undefined') {
+ observer = new ResizeObserver(measureOverflow)
+ observer.observe(viewer)
+ }
+ })
+ window.addEventListener('resize', measureOverflow)
+ return () => {
+ window.cancelAnimationFrame(frame)
+ observer?.disconnect()
+ window.removeEventListener('resize', measureOverflow)
+ }
+ }, [props.open, props.tableRef, props.tableWrapPreferenceKey])
+
+ const handleWrapToggle = useCallback(() => {
+ const nextValue = !wrapEnabled
+ explicitWrapRef.current = true
+ setWrapEnabled(nextValue)
+ writeTableWrapPreference(props.tableWrapPreferenceKey, nextValue)
+ }, [props.tableWrapPreferenceKey, wrapEnabled])
+
+ const handleDownload = useCallback(() => {
+ if (props.tableRef.current) {
+ downloadTableAsCsv(props.tableRef.current, getShareTableFileName(props.imageTitle, 'csv'))
+ }
+ }, [props.imageTitle, props.tableRef])
+
+ const handleCopyMarkdown = useCallback(() => {
+ if (props.tableRef.current) {
+ void copy(serializeTableToMarkdown(props.tableRef.current))
+ }
+ }, [copy, props.tableRef])
+
+ const getPreparedImage = useCallback((table: HTMLTableElement): Promise => {
+ return renderTableAsImage(table)
+ }, [])
+
+ const handleSaveImage = useCallback(() => {
+ const table = props.tableRef.current
+ if (!table || imageAction) return
+
+ setImageError(false)
+ setImageAction('download')
+ const filename = getShareImageFileName(props.imageTitle, 'table')
+ void getPreparedImage(table)
+ .then((blob) => downloadBlob(blob, filename))
+ .catch(() => setImageError(true))
+ .finally(() => setImageAction(null))
+ }, [getPreparedImage, imageAction, props.imageTitle, props.tableRef])
+
+ const handleCopyImage = useCallback(() => {
+ const table = props.tableRef.current
+ if (!table || imageAction) return
+
+ setImageError(false)
+ setImageAction('copy')
+ const imagePromise = getPreparedImage(table)
+ void copyTableImagePromiseToClipboard(imagePromise)
+ .then(() => markCopied())
+ .catch(() => setImageError(true))
+ .finally(() => setImageAction(null))
+ }, [getPreparedImage, imageAction, markCopied, props.tableRef])
+
+ const viewerTitle = props.imageTitle.trim() || t('table.viewerTitle')
+
+ return (
+ {
+ if (!nextOpen) props.onClose()
+ }}
+ >
+
+
+
+ {imageAction ? (
+
+
+ {t(imageAction === 'copy' ? 'table.copyingImage' : 'table.savingImage')}
+
+ ) : null}
+ {imageError ? (
+
+ {t('table.imageActionFailed')}
+
+ ) : null}
+
+ {viewerTitle}
+
+
+ {viewerTitle}
+
+
+
+
+
+ {viewerTitle}
+
+
+
+ {isMobileViewer ? (
+
+ ) : (
+
+ {copied ? : }
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/** Reuse the same table viewer for tables rendered inside a share preview. */
+export function TableViewerFromElement(props: {
+ open: boolean
+ onClose: () => void
+ table: HTMLTableElement
+ imageTitle: string
+}) {
+ const tableRef = useRef(null)
+ const tableProps: TableProps = {
+ className: cn(props.table.getAttribute('class'), 'w-max min-w-full'),
+ dangerouslySetInnerHTML: { __html: props.table.innerHTML },
+ }
+
+ return (
+
+ )
+}
+
+export function MarkdownTable(props: TableProps) {
+ const { t } = useTranslation()
+ const chatContext = useOptionalHappyChatContext()
+ const { className, children, ...rest } = props
+ const inlineTableRef = useRef(null)
+ const inlineActionsRef = useRef(null)
+ const viewerTableRef = useRef(null)
+ const tableWrapPreferenceKeyRef = useRef(undefined)
+ const [viewerOpen, setViewerOpen] = useState(false)
+ const openRef = useRef(false)
+ const mobileViewerRef = useRef(false)
+ const enteredFullscreenRef = useRef(false)
+ const imageTitle = chatContext?.sessionTitle?.trim() || t('table.viewerTitle')
+ const tableWrapScope = chatContext?.sessionId ?? imageTitle
+
+ const closeViewer = useCallback(() => {
+ openRef.current = false
+ setViewerOpen(false)
+
+ if (mobileViewerRef.current) {
+ mobileViewerRef.current = false
+ const enteredFullscreen = enteredFullscreenRef.current
+ enteredFullscreenRef.current = false
+ leaveMobileTableViewer(enteredFullscreen)
+ }
+ }, [])
+
+ const openViewer = useCallback(() => {
+ openRef.current = true
+ const table = inlineTableRef.current
+ tableWrapPreferenceKeyRef.current = table
+ ? getTableWrapPreferenceKey(table, tableWrapScope)
+ : undefined
+ setViewerOpen(true)
+
+ const isMobile = isMobileTableViewerViewport()
+ mobileViewerRef.current = isMobile
+ if (!isMobile) return
+
+ void enterMobileTableViewer().then((enteredFullscreen) => {
+ if (!openRef.current) {
+ leaveMobileTableViewer(enteredFullscreen)
+ return
+ }
+ enteredFullscreenRef.current = enteredFullscreen
+ })
+ }, [tableWrapScope])
+
+ useEffect(() => {
+ if (typeof document === 'undefined') return undefined
+
+ const handleFullscreenChange = () => {
+ if (!openRef.current || !mobileViewerRef.current || !enteredFullscreenRef.current) return
+ if (document.fullscreenElement) return
+
+ leaveMobileTableViewer(false)
+ enteredFullscreenRef.current = false
+ mobileViewerRef.current = false
+ openRef.current = false
+ setViewerOpen(false)
+ }
+
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
+ }, [])
+
+ useEffect(() => () => {
+ const wasMobile = mobileViewerRef.current
+ const enteredFullscreen = enteredFullscreenRef.current
+ openRef.current = false
+ mobileViewerRef.current = false
+ enteredFullscreenRef.current = false
+ if (wasMobile) leaveMobileTableViewer(enteredFullscreen)
+ }, [])
+
+ useLayoutEffect(() => {
+ const row = inlineTableRef.current?.tHead?.rows[0]
+ const actions = inlineActionsRef.current
+ if (!row || !actions) return undefined
+
+ const syncHeight = () => {
+ const height = row.getBoundingClientRect().height
+ if (height > 0) actions.style.height = `${height}px`
+ }
+
+ syncHeight()
+ if (typeof ResizeObserver === 'undefined') return undefined
+ const observer = new ResizeObserver(syncHeight)
+ observer.observe(row)
+ return () => observer.disconnect()
+ }, [children])
+
+ const tableProps = { ...rest, className, children }
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx
index 34c9968981..5c2a92114e 100644
--- a/web/src/components/assistant-ui/markdown-text.tsx
+++ b/web/src/components/assistant-ui/markdown-text.tsx
@@ -31,6 +31,7 @@ import { classifyNoSchemeHref } from '@/lib/markdown-href-policy'
import { remarkSessionPathLinks } from '@/lib/remark-session-path-links'
import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference'
import { UriConfirmDialog } from '@/components/UriConfirmDialog'
+import { MarkdownTable } from '@/components/assistant-ui/MarkdownTable'
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
@@ -504,6 +505,7 @@ function Code(props: ComponentPropsWithoutRef<'code'>) {
function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) {
const { filePath, sessionId, ...anchorProps } = props
+ const markdownHref = getMarkdownSourceHref(anchorProps, anchorProps.href)
const navigate = useNavigate()
const rel = anchorProps.target === '_blank' ? (anchorProps.rel ?? 'noreferrer') : anchorProps.rel
const search = new URLSearchParams({ path: encodeBase64(filePath), origin: 'chat' }).toString()
@@ -530,6 +532,7 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin
& { filePath: strin
function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessionId: string }) {
const navigate = useNavigate()
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
+ const markdownHref = getMarkdownSourceHref(props, props.href)
// Preserve Vite BASE_URL for copy / open-in-new-tab (SPA click uses navigate).
const href = buildSessionReferencePath(props.targetSessionId)
@@ -560,6 +564,7 @@ function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessio
& { targetSessio
* which uses useNavigate for SPA routing.
* - Session citation paths (`/sessions/`): SessionPathAnchor SPA navigation.
*/
-function InertMarkdownHref(props: { href: string; children?: ReactNode; className?: string }) {
+function getMarkdownSourceHref(props: object, fallback: string | undefined): string | undefined {
+ const sourceHref = (props as Record)['data-hapi-markdown-href']
+ return typeof sourceHref === 'string' ? sourceHref : fallback
+}
+
+function InertMarkdownHref(props: { href: string; markdownHref?: string; children?: ReactNode; className?: string }) {
// Plain/muted — intentionally not an , so middle-click / copy-link can't
// invent a dead SPA route either.
return (
{props.children}
@@ -630,6 +641,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
}
const { onClick, href, ...rest } = props
+ const markdownHref = getMarkdownSourceHref(rest, href)
// Windows candidate (or raw / %5C-normalized drive path): classify with workspace
// before painting FilePathAnchor or treating `C:` as a custom URI scheme.
@@ -638,7 +650,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
const isCandidateHref = href ? normalizedScheme(href) === 'hapi-file-candidate' : false
if (isCandidateHref && (!candidatePath || !/^[A-Za-z]:[\\/]/.test(candidatePath))) {
return (
-
+
{props.children}
)
@@ -665,12 +677,12 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
})
if (decision.action === 'file') {
if (!chat) {
- return {props.children}
+ return {props.children}
}
return
}
if (decision.action === 'inert') {
- return {props.children}
+ return {props.children}
}
// action === 'navigate' → fall through (only for non-Windows scheme-less SPA)
}
@@ -727,6 +739,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
) {
return
}
-function Table(props: ComponentPropsWithoutRef<'table'>) {
- const { className, ...rest } = props
-
- return (
-
- )
-}
-
function Thead(props: ComponentPropsWithoutRef<'thead'>) {
return
}
@@ -868,7 +871,7 @@ export const defaultComponents: DefaultComponentsMap = memoizeMarkdownComponents
ol: OrderedList,
li: ListItem,
hr: Hr,
- table: Table,
+ table: MarkdownTable,
thead: Thead,
tbody: Tbody,
tr: Tr,
diff --git a/web/src/hooks/useCopyToClipboard.ts b/web/src/hooks/useCopyToClipboard.ts
index dee2666fa1..9386761f71 100644
--- a/web/src/hooks/useCopyToClipboard.ts
+++ b/web/src/hooks/useCopyToClipboard.ts
@@ -6,18 +6,22 @@ export function useCopyToClipboard(resetDelay = 1500) {
const [copied, setCopied] = useState(false)
const { haptic } = usePlatform()
+ const markCopied = useCallback(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), resetDelay)
+ }, [resetDelay])
+
const copy = useCallback(async (text: string) => {
try {
await safeCopyToClipboard(text)
haptic.notification('success')
- setCopied(true)
- setTimeout(() => setCopied(false), resetDelay)
+ markCopied()
return true
} catch {
haptic.notification('error')
return false
}
- }, [haptic, resetDelay])
+ }, [haptic, markCopied])
- return { copied, copy }
+ return { copied, copy, markCopied }
}
diff --git a/web/src/index.css b/web/src/index.css
index 2e5a087dab..64f86e456a 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -445,8 +445,8 @@ html:has(.chat-scroll-y) #root {
.markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; }
.markdown-content blockquote { border-left: 3px solid var(--app-hint); padding-left: 0.75rem; margin: 0.5rem 0; opacity: 0.85; }
.markdown-content ul, .markdown-content ol { padding-left: 1.5rem; margin: 0.5rem 0; }
-.markdown-content table { border-collapse: collapse; width: 100%; }
-.markdown-content th, .markdown-content td { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; }
+.markdown-content table:not(.aui-md-table) { border-collapse: collapse; width: 100%; }
+.markdown-content th:not(.aui-md-th), .markdown-content td:not(.aui-md-td) { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; }
.aui-md {
color: var(--app-fg);
@@ -479,6 +479,79 @@ html:has(.chat-scroll-y) #root {
margin-top: max(0px, calc((var(--app-chat-line-height) - var(--app-message-action-size)) / 2));
}
+/* Markdown tables keep their header as the first table row. Table actions sit
+ * in the top-right corner of that row, so they do not create a blank shelf. */
+.aui-md-table-frame {
+ position: relative;
+}
+
+.aui-md-table-frame:has(> .aui-md-table-actions) .aui-md-table > thead > tr > :last-child {
+ padding-right: 5rem;
+}
+
+.aui-md-table-actions {
+ position: absolute;
+ top: 0;
+ right: 0.1875rem;
+ line-height: normal;
+ min-height: calc(1lh + 1rem);
+ align-items: center;
+ z-index: 2;
+ transition: opacity 150ms ease;
+}
+
+/* Desktop fonts make the Chinese title's visual center sit a pixel lower than
+ * the icon row; keep the mobile toolbar unchanged. */
+@media (hover: hover) and (pointer: fine) {
+ [data-hapi-table-viewer-heading="true"] {
+ transform: translateY(-1px);
+ }
+
+ [data-hapi-table-viewer-toolbar="true"] {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .aui-md-table-actions {
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .aui-md-table-shell:hover .aui-md-table-actions,
+ .aui-md-table-shell:focus-within .aui-md-table-actions {
+ opacity: 1;
+ pointer-events: auto;
+ }
+}
+
+/* Keep the header cells sticky instead of the whole thead. Sticky table
+ * sections have inconsistent painting behavior across mobile WebViews; the
+ * cells also let the header cover the viewer's top padding without leaving a
+ * visible gap while the table is being scrolled. */
+[data-hapi-table-viewer="true"] .aui-md-thead > tr > :is(th, td) {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--app-md-table-head-bg);
+}
+
+/* Fullscreen tables keep their readable, horizontally scrollable layout by
+ * default. The toolbar wrap toggle switches to a fixed-width table and lets
+ * every cell break long content so the viewport has no horizontal scrollbar. */
+[data-hapi-table-wrap="true"] {
+ width: 100% !important;
+ min-width: 0 !important;
+ table-layout: fixed !important;
+}
+
+[data-hapi-table-wrap="true"] :is(th, td) {
+ white-space: normal !important;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
@media (hover: hover) and (pointer: fine) {
.happy-message-actions {
opacity: 0;
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index 2cccf284e8..1ebee733db 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -320,6 +320,25 @@ export default {
'mermaid.loading': 'Loading diagram…',
'mermaid.renderError': 'Could not render diagram.',
+ // Markdown tables
+ 'table.openFullscreen': 'Open table full screen',
+ 'table.closeFullscreen': 'Close table full screen',
+ 'table.viewerTitle': 'Table',
+ 'table.copyMarkdown': 'Copy Markdown',
+ 'table.copyMarkdownButton': 'Copy table as Markdown',
+ 'table.copiedMarkdown': 'Table Markdown copied',
+ 'table.copy': 'Copy table',
+ 'table.copyImage': 'Copy image',
+ 'table.copyingImage': 'Copying image…',
+ 'table.imageActionFailed': 'Could not process table image.',
+ 'table.wrap.enable': 'Enable table wrapping',
+ 'table.wrap.disable': 'Disable table wrapping',
+ 'table.saveImage': 'Save table as image',
+ 'table.savingImage': 'Saving image…',
+ 'table.download': 'Download table',
+ 'table.downloadPng': 'Download PNG',
+ 'table.downloadCsv': 'Download CSV',
+
// Common buttons
'button.cancel': 'Cancel',
'button.save': 'Save',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index 2c2aea59eb..01bf1f53e9 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -324,6 +324,25 @@ export default {
'mermaid.loading': '正在加载图表…',
'mermaid.renderError': '无法渲染图表。',
+ // Markdown tables
+ 'table.openFullscreen': '横向全屏查看表格',
+ 'table.closeFullscreen': '关闭表格全屏',
+ 'table.viewerTitle': '表格',
+ 'table.copyMarkdown': '复制 Markdown',
+ 'table.copyMarkdownButton': '复制表格 Markdown',
+ 'table.copiedMarkdown': '表格 Markdown 已复制',
+ 'table.copy': '复制表格',
+ 'table.copyImage': '复制图片',
+ 'table.copyingImage': '复制图片中…',
+ 'table.imageActionFailed': '表格图片处理失败。',
+ 'table.wrap.enable': '开启表格换行',
+ 'table.wrap.disable': '关闭表格换行',
+ 'table.saveImage': '保存表格为图片',
+ 'table.savingImage': '保存图片中…',
+ 'table.download': '下载表格',
+ 'table.downloadPng': '下载 PNG',
+ 'table.downloadCsv': '下载 CSV',
+
// Common buttons
'button.cancel': '取消',
'button.save': '保存',
diff --git a/web/src/lib/remark-file-path-links.test.ts b/web/src/lib/remark-file-path-links.test.ts
index 8b74879fa9..626dd8e66f 100644
--- a/web/src/lib/remark-file-path-links.test.ts
+++ b/web/src/lib/remark-file-path-links.test.ts
@@ -6,6 +6,9 @@ type TestNode = {
value?: string
url?: string
children?: TestNode[]
+ data?: {
+ hProperties?: Record
+ }
}
function transform(text: string): TestNode[] {
@@ -181,7 +184,9 @@ describe('remarkFilePathLinks — explicit markdown links', () => {
it('rewrites a relative link with a #fragment, stripping it from the target', () => {
const nodes = transformNodes([linkNode('docs/foo.md#section')])
- expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('docs/foo.md')
+ const link = nodes.find((n) => n.type === 'link')!
+ expect(linkedPath(link)).toBe('docs/foo.md')
+ expect(link.data?.hProperties?.['data-hapi-markdown-href']).toBe('docs/foo.md#section')
})
it('does not rewrite POSIX absolute file links (containment needs session cwd in )', () => {
diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts
index 21e3e2aa30..91d76a72c0 100644
--- a/web/src/lib/remark-file-path-links.ts
+++ b/web/src/lib/remark-file-path-links.ts
@@ -28,6 +28,9 @@ type MarkdownNode = {
url?: string
title?: string | null
children?: MarkdownNode[]
+ data?: {
+ hProperties?: Record
+ }
}
function createFileHref(path: string): string {
@@ -233,6 +236,13 @@ function rewriteFileLinkNode(node: MarkdownNode): void {
// backslashes survive mdast→hast URI normalization.
if (isWindowsAbsolutePath(target)) {
if (!hasKnownFileExtension(target)) return
+ node.data = {
+ ...node.data,
+ hProperties: {
+ ...node.data?.hProperties,
+ 'data-hapi-markdown-href': url,
+ },
+ }
node.url = createFileCandidateHref(target)
return
}
@@ -241,6 +251,13 @@ function rewriteFileLinkNode(node: MarkdownNode): void {
if (!shouldLinkPath(target)) return
+ node.data = {
+ ...node.data,
+ hProperties: {
+ ...node.data?.hProperties,
+ 'data-hapi-markdown-href': url,
+ },
+ }
node.url = createFileHref(target)
}
diff --git a/web/src/lib/share-image-filename.test.ts b/web/src/lib/share-image-filename.test.ts
new file mode 100644
index 0000000000..a17b30b8a9
--- /dev/null
+++ b/web/src/lib/share-image-filename.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { getShareImageFileName, getShareTableFileName, sanitizeShareFileNamePart } from './share-image-filename'
+
+describe('share image filenames', () => {
+ const timestamp = new Date(2026, 7, 18, 10, 27, 56)
+
+ it('keeps turn image filenames compatible with the existing HAPI convention', () => {
+ expect(getShareImageFileName('标题', 'turn', timestamp)).toBe('HAPI-标题-20260818102756.png')
+ })
+
+ it('adds the Table discriminator while retaining the conversation title and timestamp', () => {
+ expect(getShareImageFileName('标题', 'table', timestamp)).toBe('HAPI Table-标题-20260818102756.png')
+ })
+
+ it('uses the same table title and timestamp for CSV downloads', () => {
+ expect(getShareTableFileName('标题', 'csv', timestamp)).toBe('HAPI Table-标题-20260818102756.csv')
+ })
+
+ it('sanitizes unsafe title characters before creating a filename', () => {
+ expect(sanitizeShareFileNamePart(' report:/ Q3 ')).toBe('report- Q3')
+ })
+})
diff --git a/web/src/lib/share-image-filename.ts b/web/src/lib/share-image-filename.ts
new file mode 100644
index 0000000000..514adf4fcd
--- /dev/null
+++ b/web/src/lib/share-image-filename.ts
@@ -0,0 +1,46 @@
+export type ShareImageKind = 'turn' | 'table'
+
+export function formatShareTimestamp(date = new Date()): string {
+ const pad = (value: number) => String(value).padStart(2, '0')
+ return [
+ date.getFullYear(),
+ pad(date.getMonth() + 1),
+ pad(date.getDate()),
+ pad(date.getHours()),
+ pad(date.getMinutes()),
+ pad(date.getSeconds())
+ ].join('')
+}
+
+export function sanitizeShareFileNamePart(title: string): string {
+ const withoutControlCharacters = Array.from(title.normalize('NFKC'))
+ .filter((character) => {
+ const codePoint = character.codePointAt(0) ?? 0
+ return codePoint >= 32 && codePoint !== 127
+ })
+ .join('')
+ const sanitized = withoutControlCharacters
+ .replace(/[<>:"/\\|?*]+/g, '-')
+ .replace(/\s+/g, ' ')
+ .replace(/-+/g, '-')
+ .replace(/^[ .-]+|[ .-]+$/g, '')
+ .trim()
+ return Array.from(sanitized || 'Shared turn').slice(0, 80).join('').trim()
+}
+
+export function getShareImageFileName(
+ title: string,
+ kind: ShareImageKind = 'turn',
+ date = new Date(),
+): string {
+ const prefix = kind === 'table' ? 'HAPI Table' : 'HAPI'
+ return `${prefix}-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp(date)}.png`
+}
+
+export function getShareTableFileName(
+ title: string,
+ extension: 'png' | 'csv',
+ date = new Date(),
+): string {
+ return getShareImageFileName(title, 'table', date).replace(/\.png$/, `.${extension}`)
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index cb877c0349..1fc2247f90 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -189,7 +189,8 @@ export default defineConfig({
'workbox-precaching',
'workbox-routing',
'workbox-strategies',
- 'workbox-expiration'
+ 'workbox-expiration',
+ 'workbox-window'
]
},
resolve: {