Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab56255
feat(web): add fullscreen table preview and export actions
techotaku39 Aug 26, 2026
3c18098
fix(web): harden table exports and mobile detection
techotaku39 Aug 26, 2026
9b29c4d
fix(web): address table viewer review findings
techotaku39 Aug 26, 2026
c12f955
fix(web): exclude table controls from shared images
techotaku39 Aug 26, 2026
830e0fa
test(web): stabilize landscape table coverage
techotaku39 Aug 26, 2026
d9ea439
feat(web): complete table preview actions and export fidelity
techotaku39 Aug 27, 2026
13d5c04
fix(web): harden table export and preview coverage
techotaku39 Aug 27, 2026
eec6d66
fix(web): address table preview review findings
techotaku39 Aug 27, 2026
e693ea0
fix(web): harden table preview sizing
techotaku39 Aug 27, 2026
37cc1ba
fix(web): align table actions and export tiles
techotaku39 Aug 27, 2026
01b9d2c
fix(web): preserve table preview sizing
techotaku39 Aug 27, 2026
ee252a3
fix(web): report table image action failures
techotaku39 Aug 27, 2026
e3e1c0a
fix(web): handle pending mobile orientation locks
techotaku39 Aug 27, 2026
e1a961e
fix(web): preserve markdown table cell formatting
techotaku39 Aug 27, 2026
e2f3cea
fix(web): handle synchronous image clipboard errors
techotaku39 Aug 27, 2026
538c12c
fix(web): preserve rich markdown table links
techotaku39 Aug 27, 2026
06a9b3a
fix(web): preserve authored table link targets
techotaku39 Aug 27, 2026
bb6c311
fix(web): close pending table viewer work
techotaku39 Aug 27, 2026
638c0bd
fix(web): escape markdown table text safely
techotaku39 Aug 27, 2026
b21a912
fix(web): round-trip table media text safely
techotaku39 Aug 27, 2026
8dbd458
fix(web): preserve table header cell colors
techotaku39 Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Prebundle the remaining Workbox client before making this suite mandatory.

The clean current-head run fails here. Its log reports new dependencies optimized: workbox-window followed by optimized dependencies changed. reloading; that reload tears down the mobile table dialog, and the close-button click times out on a detached element. web/vite.config.ts:183 already prebundles Workbox modules specifically to prevent this E2E reload, but omits workbox-window.

Suggested fix:

optimizeDeps: {
    include: [
        'workbox-precaching',
        'workbox-routing',
        'workbox-strategies',
        'workbox-expiration',
        'workbox-window',
    ],
},

- run: bun run test

# Serial runner-integration suite: starts real detached runner/session
Expand Down
17 changes: 17 additions & 0 deletions web/e2e-fixtures/markdown-table-fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HAPI Markdown table fixture</title>
<style>
html { background: #fff; }
body { margin: 0; padding: 24px; font-family: system-ui, sans-serif; }
#root { max-width: 960px; margin: 0 auto; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./markdown-table-fixture.tsx"></script>
</body>
</html>
35 changes: 35 additions & 0 deletions web/e2e-fixtures/markdown-table-fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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 |`

function MarkdownTableFixture() {
return (
<HappyChatProvider value={{ sessionTitle: 'Table filename fixture' } as HappyChatContextValue}>
<main data-testid="markdown-table-fixture">
<MarkdownRenderer standalone content={TABLE_MARKDOWN} />
</main>
</HappyChatProvider>
)
}

const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<I18nProvider>
<MarkdownTableFixture />
</I18nProvider>
</React.StrictMode>,
)
}
113 changes: 113 additions & 0 deletions web/e2e/markdown-table-mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 })
await expect(dialog.getByRole('button', { name: 'Copy table as Markdown' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save table as image' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Download table as CSV' })).toBeVisible()
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')

const imageDownloadPromise = page.waitForEvent('download')
await dialog.getByRole('button', { name: 'Save table as image' }).click()
const imageDownload = await imageDownloadPromise
expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/)

const csvDownloadPromise = page.waitForEvent('download')
await dialog.getByRole('button', { name: 'Download table as 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.goto('/e2e-fixtures/markdown-table-fixture.html')
await page.setViewportSize({ width: 915, height: 412 })
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')
})
101 changes: 101 additions & 0 deletions web/e2e/markdown-table.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { expect, test } from '@playwright/test'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] These new specs are not run by PR automation. They live under web/e2e, but .github/workflows/test.yml:16 invokes the root Playwright config and explicitly selects only terminal-wrap-fidelity.spec.ts and composer-copy.spec.ts; bun run test only runs Vitest.

Suggested fix:

- run: cd web && bunx playwright install chromium
- run: cd web && bunx playwright test e2e/markdown-table.spec.ts e2e/markdown-table-mobile.spec.ts

That makes the fullscreen/export regression coverage effective on future PRs.


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('3px: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()
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 as 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: 'Save table as image' }).click()
const imageDownload = await imageDownloadPromise
expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/)

const downloadPromise = page.waitForEvent('download')
await dialog.getByRole('button', { name: 'Download table as 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()
})
})
6 changes: 5 additions & 1 deletion web/src/components/AssistantChat/HappyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,10 @@ export function HappyThread(props: {
const machineLabelsById = useMachineLabels(machines)
const [shareTurn, setShareTurn] = useState<ShareTurnState>(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
Expand Down Expand Up @@ -1606,6 +1609,7 @@ export function HappyThread(props: {
<HappyChatProvider value={{
api: props.api,
sessionId: props.sessionId,
sessionTitle,
metadata: props.metadata,
terminalToolDisplayMode,
showSessionSummaryInChat,
Expand Down
37 changes: 3 additions & 34 deletions web/src/components/AssistantChat/ShareTurnDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { ZoomableLightbox } from '@/components/ZoomableLightbox'
import { safeCopyToClipboard } from '@/lib/clipboard'
import type { ShareTurnMetadataItem } from '@/lib/shareTurnMetadata'
import { getShareImageFileName } from '@/lib/share-image-filename'

type ShareTurnDialogProps = {
isOpen: boolean
Expand Down Expand Up @@ -110,38 +111,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)) {
Expand Down Expand Up @@ -818,7 +787,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
if (preparedBlob) {
runBlobAction(
preparedBlob,
(blob) => shareImageBlob(blob, getShareFileName(props.title)),
(blob) => shareImageBlob(blob, getShareImageFileName(props.title)),
'share'
)
}
Expand All @@ -832,7 +801,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
<button
type="button"
onClick={() => {
void withPng((blob) => downloadBlob(blob, getShareFileName(props.title)), 'download')
void withPng((blob) => downloadBlob(blob, getShareImageFileName(props.title)), 'download')
}}
disabled={busy !== null || !ready}
className="rounded-md bg-[var(--app-button)] px-3 py-2 text-sm text-[var(--app-button-text)] disabled:opacity-50 sm:w-32"
Expand Down
1 change: 1 addition & 0 deletions web/src/components/AssistantChat/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading