Skip to content

feat(web): add fullscreen table preview and export actions - #1693

Open
techotaku39 wants to merge 21 commits into
tiann:mainfrom
techotaku39:feat/web-table-actions-fullscreen
Open

feat(web): add fullscreen table preview and export actions#1693
techotaku39 wants to merge 21 commits into
tiann:mainfrom
techotaku39:feat/web-table-actions-fullscreen

Conversation

@techotaku39

@techotaku39 techotaku39 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

Markdown tables need a compact way to inspect wide or long content without disrupting the table header. The initial viewer also needed reliable image export fidelity across desktop, mobile, and shared previews.

Summary

  • Add an interactive fullscreen table viewer that expands within the desktop viewport and requests browser fullscreen plus landscape orientation on supported mobile browsers.
  • Keep table actions in the header area without adding a blank toolbar row, while hiding the viewer toolbar during normal downward scrolling and restoring it while scrolling upward.
  • Keep the inline action layer aligned with the actual first header row, including when the header wraps to multiple lines.
  • Protect the final near-bottom scroll range from toolbar layout changes that could cause the browser to rebound the table.
  • Add an explicit wrap toggle with an adaptive first-open default; keep reevaluating the automatic choice after viewer resize/orientation changes until the user makes an explicit choice, then persist that per-table preference in localStorage.
  • Measure intrinsic table width during automatic re-evaluation so a previously wrapped table remains correctly wrapped while the viewport is still too narrow.
  • Add PC copy and download menus:
    • Copy image, Copy Markdown.
    • Download PNG, Download CSV.
  • Keep mobile copying as a direct Markdown action while retaining the PNG/CSV download menu.
  • Preserve links, inline code, emphasis, strikethrough, and images when copying Markdown table cells, including authored file-link targets, sanitized custom-link targets, inert path links, and code spans containing backticks, backslashes, or significant spaces.
  • Restore auto-linked internal HAPI file paths as plain text while preserving inline-code formatting, and escape image alt text safely.
  • Escape literal Markdown metacharacters in ordinary table text so copied Markdown round-trips without inventing formatting.
  • Show progress feedback for image operations, use a high-contrast copy-image status, and show the copy action success check state for both Markdown and image copies.
  • Start image clipboard writes during the original user activation while allowing rasterization to finish asynchronously.
  • Surface localized error feedback when PNG generation, encoding, download, or image clipboard operations fail, including synchronous clipboard API exceptions.
  • Release mobile orientation locks when the browser exits fullscreen outside the viewer close control, including while the initial orientation lock is still pending.
  • Invalidate pending fullscreen work when a table component unmounts.
  • Reuse the viewer in shared-turn previews, including mobile fullscreen/orientation handling, while removing interactive controls from exported images.
  • Improve PNG export fidelity by preserving computed table styles, source column widths, cell alignment, independent per-cell header colors, and the effective table bounds; large exports are rendered in bounded vertical tiles.
  • Bound both the total raster pixel budget and the final stitched canvas dimensions for very large tables.
  • Keep fractional-scale tile destinations contiguous and cover the complete final canvas.
  • Keep PNG and CSV filenames aligned with the session title and timestamp.
  • Unify file-preview table styling with chat tables, preserve max-content sizing in shared previews, and localize the new controls and standalone fallback title in English and Simplified Chinese.
  • Keep the implementation frontend-only without new backend APIs or database migrations.
  • Prebundle the Workbox dependency used by the PWA in development so browser tests do not reload mid-interaction.
  • Avoid reusing stale asynchronous image renders and keep the table action affordance centered against the header row.

Validation

  • bun typecheck — passed.
  • pwsh -NoProfile -File .\scripts\Invoke-HapiTaskPlaywright.ps1 -Name table-actions-fullscreen -Suite Root terminal-wrap-fidelity.spec.ts — 2/2 passed.
  • Table-focused Vitest (bun run test -- src/components/assistant-ui/MarkdownTable.test.tsx src/components/AssistantChat/ShareTurnDialog.test.tsx) — 47/47 passed.
  • Directly affected link/renderer Vitest (bun run test -- src/lib/remark-file-path-links.test.ts src/components/assistant-ui/markdown-a.test.tsx) — 119/119 passed.
  • Web Playwright (pwsh -NoProfile -File .\scripts\Invoke-HapiTaskPlaywright.ps1 -Name table-actions-fullscreen -Suite Web) — 22 passed, 16 skipped by the existing live-session configuration.
  • Isolated live-session validation against the settings table with a near-bottom, no-horizontal-overflow viewport — passed.
  • bun run build — passed.
  • Public test deployment health check — local and public endpoints returned HTTP 200.

Related Issues

None

AI Disclosure

Implemented and validated with OpenAI Codex (GPT-5.6) assistance.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Neutralize formula-leading CSV cells - quoting alone does not stop spreadsheet applications from evaluating cells beginning with =, +, -, or @. Since table text can come from agent/repository output, opening the downloaded CSV can execute attacker-controlled formulas. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:135.
    Suggested fix:
    function escapeCsvCell(value: string): string {
        const safeValue = /^[=+\-@]/.test(value) ? `'${value}` : value
        return `"${safeValue.replace(/"/g, '""')}"`
    }
  • [Major] Bound PNG rasterization size - the new export passes the full table dimensions at up to 2x scale without the pixel cap already used by ShareTurnDialog. A large generated table can request a canvas hundreds of megapixels wide/high, exhausting memory or exceeding browser limits; mobile also starts this render eagerly when the viewer opens. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:243.
    Suggested fix:
    const MAX_TABLE_EXPORT_PIXELS = 24_000_000
    const maxScale = Math.sqrt(MAX_TABLE_EXPORT_PIXELS / (tableWidth * tableHeight))
    const scale = Math.min(window.devicePixelRatio || 1, 2, maxScale)
  • [Minor] Recognize phones already in landscape - mobile behavior is gated by viewport width, so common phone landscape widths such as 844-932px are classified as desktop and never request fullscreen/orientation lock. The E2E test only rotates after opening and does not cover opening while already landscape. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:126.
    Suggested fix:
    const shortSide = Math.min(window.innerWidth, window.innerHeight)
    return shortSide <= 767 && isCoarsePointerDevice()

Summary
Review mode: initial
Three issues found in the new export/fullscreen path: CSV formula injection, unbounded image rasterization, and initial-landscape phone detection.

Testing
Not run (automation; PR code was not executed under the review security policy). GitHub integration is currently failing in unchanged CLI integration tests; the main test check was pending at review time. Add coverage for formula-leading CSV cells, oversized-table scale reduction, and opening the mobile viewer from an initial landscape viewport.

HAPI Bot

return text.replace(/\s+/g, ' ').trim()
}

function escapeCsvCell(value: string): string {

@github-actions github-actions Bot Aug 26, 2026

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] Quoting alone does not neutralize spreadsheet formulas. A table cell beginning with =, +, -, or @ is still evaluated by Excel/Sheets after the user opens the export, and this content can originate in untrusted agent/repository output.

Suggested fix:

function escapeCsvCell(value: string): string {
    const safeValue = /^[=+\-@]/.test(value) ? `'${value}` : value
    return `"${safeValue.replace(/"/g, '""')}"`
}

Please add a regression test with formula-leading cells.

backgroundColor: backgroundColor || null,
foreignObjectRendering: false,
logging: false,
scale: Math.min(window.devicePixelRatio || 1, 2),

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] This uses the entire table dimensions at up to 2× scale with no pixel budget. Large generated tables can exceed browser canvas limits or allocate hundreds of MB/GB; on mobile, renderTableAsImage is also invoked eagerly on viewer open. The existing share-image implementation caps output at 24M pixels.

Suggested fix:

const MAX_TABLE_EXPORT_PIXELS = 24_000_000
const maxScale = Math.sqrt(MAX_TABLE_EXPORT_PIXELS / (tableWidth * tableHeight))
const scale = Math.min(window.devicePixelRatio || 1, 2, maxScale)

Pass scale to html2canvas and test that oversized tables reduce it.

/** Exported for responsive behavior tests and future table viewers. */
export function isMobileTableViewerViewport(): boolean {
if (typeof window === 'undefined') return false
return window.matchMedia('(max-width: 767px)').matches && isCoarsePointerDevice()

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] Width-only detection misses phones that are already in landscape: common CSS viewport widths (844–932px) fail this check, so opening the table then uses desktop mode and skips fullscreen/orientation handling. The new E2E test starts portrait and rotates only after opening.

Suggested fix:

const shortSide = Math.min(window.innerWidth, window.innerHeight)
return shortSide <= 767 && isCoarsePointerDevice()

Add an initial-landscape viewport case before clicking the open button.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Defer table PNG rendering until the user requests it — opening any table on a mobile-classified device immediately calls renderTableAsImage, even when the user only wants to read, copy Markdown, or download CSV. The configured 24M-pixel ceiling still permits an RGBA canvas of roughly 96 MB before clone/encoder overhead, so a large table can stall or terminate a low-memory mobile tab merely by opening the viewer. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:409.
    Suggested fix:
    useEffect(() => {
        if (!props.open) setPreparedImage(null)
    }, [props.open])
    
    const blob = preparedImage ?? await renderTableAsImage(table)
    downloadBlob(blob, filename)
    setPreparedImage(blob)
  • [Minor] Preserve column alignment when copying Markdown — the renderer honors GFM alignment such as ---:, but serialization always emits ---, so copying the fixture changes the Stars column from right-aligned to left-aligned when pasted back. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:167.
    Suggested fix:
    const headerCells = Array.from(table.tHead?.rows[0]?.cells ?? [])
    const separator = Array.from({ length: width }, (_, index) => {
        const align = headerCells[index]?.getAttribute('align')
        return align === 'center' ? ':---:' : align === 'right' ? '---:' : align === 'left' ? ':---' : '---'
    })
  • [Minor] Do not classify every touch-enabled desktop as mobile — maxTouchPoints > 0 makes hybrid laptops pass the coarse-pointer check; after the short-side change, a normal 1366×768 touch laptop browser viewport can now request fullscreen and landscape orientation. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:116.
    Suggested fix:
    const ipadDesktopUa = /Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1
    return coarsePointer || mobileUserAgent || ipadDesktopUa
  • [Minor] Wire the new browser tests into CI — these specs are under web/e2e, while .github/workflows/test.yml:16 runs the root Playwright config and explicitly selects only two root specs. bun run test is Vitest-only, so the added fullscreen/export coverage is not exercised on PRs. Evidence web/e2e/markdown-table.spec.ts:1.
    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

Summary
Review mode: follow-up after new commits
Four issues remain in the current full diff: one mobile performance risk, two behavior regressions, and one CI coverage gap.

Testing
Not run (automation; PR code was not executed under the review security policy). Static diff/context inspection only.

HAPI Bot

if (!table) return undefined

let cancelled = false
void renderTableAsImage(table).then((blob) => {

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] Opening the viewer on every mobile-classified device eagerly rasterizes the table, even if the user only reads it or exports CSV/Markdown. With the 24M-pixel cap, the canvas alone can still require roughly 96 MB of RGBA storage before clone and PNG-encoding overhead, so a large table can stall or kill a mobile tab just by opening the viewer.

Suggested fix:

useEffect(() => {
    if (!props.open) setPreparedImage(null)
}, [props.open])

const blob = preparedImage ?? await renderTableAsImage(table)
downloadBlob(blob, filename)
setPreparedImage(blob)

Remove the eager renderTableAsImage call from the open effect, render on Save, and add a test that opening the mobile viewer does not call html2canvas.


const width = Math.max(...rows.map((row) => row.length), 1)
const header = rows[0] ?? []
const separator = Array.from({ length: width }, () => '---')

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] The rendered table preserves GFM alignment, but copy serialization always emits ---. For the included ---: Stars column, copying and pasting the table changes it from right-aligned to left-aligned.

Suggested fix:

const headerCells = Array.from(table.tHead?.rows[0]?.cells ?? [])
const separator = Array.from({ length: width }, (_, index) => {
    const align = headerCells[index]?.getAttribute('align')
    return align === 'center' ? ':---:' : align === 'right' ? '---:' : align === 'left' ? ':---' : '---'
})

Update the serialization test to retain the fixture's right alignment.

if (typeof window === 'undefined') return false

const coarsePointer = window.matchMedia('(pointer: coarse)').matches
const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints : 0

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] maxTouchPoints > 0 does not mean the primary pointer is coarse; it also matches Windows hybrid laptops. Combined with the new short-side check, a normal 1366×768 touch laptop browser viewport can be classified as mobile and unexpectedly request fullscreen/orientation lock.

Suggested fix:

const ipadDesktopUa = /Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1
return coarsePointer || mobileUserAgent || ipadDesktopUa

This keeps desktop-UA iPads covered without treating every touch-enabled desktop as a phone.

@@ -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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Exclude the fullscreen affordance from share-turn snapshots — handleShareTurn captures message outerHTML, and the share preview only strips controls matching its exclusion conventions. The new .aui-md-table-actions wrapper matches none of them, so the expand button survives as a dead control in the share preview and remains visible in touch-layout PNG exports; the unconditional header padding also survives after removal. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:642 (related context web/src/components/AssistantChat/ShareTurnDialog.tsx:37, web/src/components/AssistantChat/ShareTurnDialog.tsx:540).
    Suggested fix:
    <div
        data-hapi-share-exclude="true"
        className="aui-md-table-actions flex items-center"
    >
    .aui-md-table-frame:has(> .aui-md-table-actions) .aui-md-table > thead > tr > :last-child {
        padding-right: 5rem;
    }

Summary
Review mode: follow-up after new commits

The four prior findings are resolved in the latest head. One minor share-export regression remains.

Testing
Not run by reviewer (automation/security policy). Static full-diff and surrounding-context inspection; the current CI table Playwright step passed. Add a share-turn snapshot/export assertion that .aui-md-table-actions is absent.

HAPI Bot

{children}
</table>
</div>
<div className="aui-md-table-actions flex items-center">

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] handleShareTurn snapshots the message via outerHTML, but this new action wrapper matches none of ShareTurnDialog’s control-exclusion selectors. The expand button therefore survives as a dead control in the share preview and is visible in touch-layout PNG exports; simply removing it would also leave the unconditional 5rem header padding behind.

Suggested fix:

<div
    data-hapi-share-exclude="true"
    className="aui-md-table-actions flex items-center"
>
.aui-md-table-frame:has(> .aui-md-table-actions) .aui-md-table > thead > tr > :last-child {
    padding-right: 5rem;
}

Please add a share-turn regression assertion that the captured preview/export contains neither .aui-md-table-actions nor the extra action padding.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • None.

Summary
Review mode: follow-up after new commits

No issues found in the latest full diff. The prior share-turn control/padding finding is resolved at the current head.

Testing
Not run (automation/security policy). GitHub test passed, including the table Playwright coverage. The separate integration job failed in unchanged CLI runner tests and is not attributable to this web-only diff. Residual gap: web/src/components/assistant-ui/MarkdownTable.test.tsx:93 verifies the exclusion marker, but there is still no share-turn preview/export assertion confirming that the action and its header padding are absent.

HAPI Bot

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] The newly mandatory table Playwright step is failing on the current head (.github/workflows/test.yml:17).
  • [Major] The prepared PNG cache can retain and reuse a stale table render (web/src/components/assistant-ui/MarkdownTable.tsx:818).
  • [Minor] The table action is 11 px off the header center in the PR's own browser check (web/src/index.css:494).

Summary
Review mode: follow-up after new commits

Three issues found in the latest full diff. The prior bot review was against 830e0fa; the current head adds new table-viewer/export behavior and a mandatory browser-test step that is red.

Testing
Not run locally (automation/security policy). Current GitHub run 33064071048: typecheck and the earlier root E2E step passed; the added table Playwright step had 5 passed / 2 failed, so bun run test was not reached. The separate integration job also failed in unchanged CLI runner tests and is not attributable to this web-only diff.

HAPI Bot

- 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',
    ],
},

}, [copy, props.tableRef])

const getPreparedImage = useCallback((table: HTMLTableElement): Promise<Blob> => {
if (preparedImage) return Promise.resolve(preparedImage)

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] Do not reuse a blob unless it still matches the current table.

preparedImage is cleared by effects when the viewer closes or wrapping changes, but an in-flight renderTableAsImage() can resolve afterward and repopulate it. Streaming table children also do not invalidate it. Reopening or exporting again can therefore copy/download old rows or the previous wrap layout.

The minimal safe fix is to remove this cache; if caching is required, key it by the current DOM plus wrap state and invalidate in-flight generations.

Suggested fix:

const getPreparedImage = useCallback(
    (table: HTMLTableElement) => renderTableAsImage(table),
    [],
)

Comment thread web/src/index.css Outdated

.aui-md-table-actions {
position: absolute;
top: 0.1875rem;

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] Center the action against the header row instead of using a fixed top offset.

The PR's added browser assertion at web/e2e/markdown-table.spec.ts:223 currently receives an 11 px center delta (limit: 2 px), so the current test check is red and the control is visibly high relative to the header. Size the overlay from the same line-height and vertical padding as .aui-md-th.

Suggested fix:

.aui-md-table-actions {
    top: 0;
    min-height: calc(1lh + 1rem); /* header line box + py-2 */
    align-items: center;
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] The follow-up header-action alignment fix still fails the newly required Playwright check (web/src/index.css:496).
  • [Major] Copy Image performs the clipboard write only after asynchronous rasterization, outside the original click activation (web/src/components/assistant-ui/MarkdownTable.tsx:831).
  • [Minor] Automatic wrapping can latch the portrait measurement before mobile landscape rotation completes (web/src/components/assistant-ui/MarkdownTable.tsx:768).

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The current head still has a required browser-test failure and two browser-lifecycle regressions.

Testing

  • Not run locally (automation; PR code was not executed). Current GitHub Actions: test fails because the added geometry assertion receives 10 px (expected <=2 px). integration also fails in unchanged CLI tests and is not attributed to this web-only diff.

HAPI Bot

Comment thread web/src/index.css Outdated
position: absolute;
top: 0;
right: 0.1875rem;
min-height: calc(1.25rem + 1rem);

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] The alignment follow-up still fails the required browser test.

The current test job fails at web/e2e/markdown-table.spec.ts:223: actionHeaderCenterDelta is 10 px, with a limit of 2 px. The hard-coded 1.25rem does not match the inherited table line height, so this follow-up does not resolve the prior finding and leaves the PR's newly added required check red.

Suggested fix:

.aui-md-table-actions {
    min-height: calc(1lh + 1rem); /* inherited line box + py-2 */
}

setImageAction('copy')
void getPreparedImage(table)
.then(async (blob) => {
await copyTableImageToClipboard(blob)

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] Start the clipboard write inside the menu-item click.

renderTableAsImage() dynamically imports and rasterizes before navigator.clipboard.write() runs. Browsers that require transient user activation for image clipboard writes reject the later call; the catch then silently clears the spinner, so the new Copy Image action appears to do nothing. Pass the render promise into ClipboardItem and invoke write() synchronously from the click handler.

Suggested fix:

const imagePromise = renderTableAsImage(table)
void navigator.clipboard.write([
    new ClipboardItem({ 'image/png': imagePromise }),
])
    .then(markCopied)
    .catch(() => {/* surface the failure */})
    .finally(() => setImageAction(null))

const table = props.tableRef.current
if (!viewer || !table) return
const shouldWrap = shouldWrapTableByDefault(table, viewer)
if (shouldWrap) automaticWrapRef.current = false

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] Re-evaluate the automatic default after landscape rotation.

On first mobile open, the viewer can measure the portrait viewport before orientation.lock() finishes. If that measurement overflows, this line permanently disables automatic measurement; the later resize/ResizeObserver callbacks immediately return, so the landscape viewer remains wrapped based on portrait geometry. The mobile E2E changes the viewport after opening but accepts either wrap value, so it does not cover this regression.

Suggested fix:

const hasExplicitWrapRef = useRef(false)

// when opening
hasExplicitWrapRef.current = storedPreference !== null

const measureOverflow = () => {
    if (hasExplicitWrapRef.current) return
    const viewer = viewerRef.current
    const table = props.tableRef.current
    if (!viewer || !table) return
    setWrapEnabled(shouldWrapTableByDefault(table, viewer))
}

// in handleWrapToggle
hasExplicitWrapRef.current = true

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] The header-action alignment fix still leaves the required browser check red — line-height: 1.25rem makes 1lh equal the same hard-coded 1.25rem, so the computed minimum height is still 36px. Current-head CI still reports a 10px center delta (expected ≤2px), evidence web/src/index.css:496.
    Suggested fix:
    .aui-md-table-actions {
        /* inherit the markdown surface line height */
        min-height: calc(1lh + 1rem);
    }
  • [Major] Vertical tiling does not bound the final output canvas — the scale only caps total pixel area, while outputCanvas.height = ceil(tableHeight * scale) remains unbounded. A sufficiently narrow, tall table can exceed the browser's canvas-dimension limit and fail at toBlob, despite entering the tiled path, evidence web/src/components/assistant-ui/MarkdownTable.tsx:504 and web/src/components/assistant-ui/MarkdownTable.tsx:573.
    Suggested fix:
    const MAX_TABLE_EXPORT_DIMENSION = 16_384
    const dimensionScale =
        MAX_TABLE_EXPORT_DIMENSION / Math.max(tableWidth, tableHeight)
    
    return Math.min(
        devicePixelRatio || 1,
        2,
        Math.sqrt(MAX_TABLE_EXPORT_PIXELS / area),
        dimensionScale,
    )
  • [Minor] Automatic wrap remeasurement reads already-wrapped geometry — once wrapping is enabled, the new CSS forces the table to 100% width, so table.scrollWidth no longer exposes intrinsic overflow. A resize/orientation callback can therefore disable wrapping even when the new viewport is still too narrow; the added unit test mocks scrollWidth independently of this CSS and misses the case, evidence web/src/components/assistant-ui/MarkdownTable.tsx:778.
    Suggested fix:
    const wrapped = table.dataset.hapiTableWrap
    table.removeAttribute('data-hapi-table-wrap')
    const intrinsicWidth = table.scrollWidth
    if (wrapped) table.dataset.hapiTableWrap = wrapped
    setWrapEnabled(intrinsicWidth > viewer.clientWidth + 1)

Questions

  • None.

Summary
Review mode: follow-up after new commits

The current head still has one required browser-test failure plus long-table export and resize-lifecycle regressions.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current-head CI: the added table Playwright suite passed 6/7 and failed the geometry assertion; bun run test was not reached. CLI integration failures are outside this web-only diff and are not attributed here.
  • Add coverage for narrow-to-narrow resize while auto-wrapped and for maximum final canvas dimensions on a very tall table.

HAPI Bot

Comment thread web/src/index.css Outdated
position: absolute;
top: 0;
right: 0.1875rem;
line-height: 1.25rem;

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] The alignment fix still computes the previous height.

Setting line-height: 1.25rem makes the following 1lh exactly 1.25rem, so min-height remains 36px—the same geometry as the previous revision. Current-head CI still fails web/e2e/markdown-table.spec.ts:223 with a 10px center delta (expected ≤2px).

Suggested fix:

.aui-md-table-actions {
    /* inherit the markdown surface line height */
    min-height: calc(1lh + 1rem);
}

return Math.min(
devicePixelRatio || 1,
2,
Math.sqrt(MAX_TABLE_EXPORT_PIXELS / area),

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] Bound canvas dimensions as well as pixel area.

This scale limits area only. The tiled branch later allocates one final canvas with height = ceil(tableHeight * scale), so a narrow/tall table can still exceed the browser's finite canvas-dimension limit and make toBlob fail. Tiling the inputs does not bound that final allocation.

Suggested fix:

const MAX_TABLE_EXPORT_DIMENSION = 16_384
const dimensionScale =
    MAX_TABLE_EXPORT_DIMENSION / Math.max(tableWidth, tableHeight)

return Math.min(
    devicePixelRatio || 1,
    2,
    Math.sqrt(MAX_TABLE_EXPORT_PIXELS / area),
    dimensionScale,
)

const viewer = viewerRef.current
const table = props.tableRef.current
if (!viewer || !table) return
const shouldWrap = shouldWrapTableByDefault(table, viewer)

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] Measure intrinsic width, not the wrapped table.

When wrapEnabled is true, [data-hapi-table-wrap] forces the table to 100% width and breaks cell content. A resize/orientation callback therefore sees scrollWidth <= clientWidth and turns wrapping off even when the new viewport still cannot fit the unwrapped table. The new test mocks scrollWidth independently of the applied CSS, so it does not exercise this path.

Suggested fix:

const wrapped = table.dataset.hapiTableWrap
table.removeAttribute('data-hapi-table-wrap')
const intrinsicWidth = table.scrollWidth
if (wrapped) table.dataset.hapiTableWrap = wrapped
setWrapEnabled(intrinsicWidth > viewer.clientWidth + 1)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Header actions remain vertically misaligned when a header row wraps — the action box still derives a fixed height from one line, while the header row can grow to multiple lines. Current-head CI fails the required assertion with an 11px center delta, evidence web/src/index.css:496 and web/e2e/markdown-table.spec.ts:223.
    Suggested fix:
    useLayoutEffect(() => {
        const row = inlineTableRef.current?.tHead?.rows[0]
        const actions = inlineActionsRef.current
        if (!row || !actions) return
        const sync = () => { actions.style.height = `${row.getBoundingClientRect().height}px` }
        sync()
        const observer = new ResizeObserver(sync)
        observer.observe(row)
        return () => observer.disconnect()
    }, [children])
  • [Minor] Manual browser-fullscreen exit skips orientation cleanup — the fullscreenchange path closes the viewer and clears the mobile refs without calling the same unlock path used by the close button. Implementations that retain the explicit lock can leave the app in landscape, evidence web/src/components/assistant-ui/MarkdownTable.tsx:1077.
    Suggested fix:
    if (!document.fullscreenElement) {
        leaveMobileTableViewer(false) // already exited; release orientation only
        enteredFullscreenRef.current = false
        mobileViewerRef.current = false
        openRef.current = false
        setViewerOpen(false)
    }
  • [Minor] Fractional export scales can overlap or skip tile-boundary pixels — each tile canvas rounds its height up, but its destination offset is rounded independently. For example, a 1666px tile at scale 1.2 produces 2000px while the next tile starts at 1999px, duplicating a row in the PNG, evidence web/src/components/assistant-ui/MarkdownTable.tsx:599.
    Suggested fix:
    const destinationTop = Math.round(tileTop * scale)
    const destinationBottom = Math.round((tileTop + currentTileHeight) * scale)
    outputContext.drawImage(
        tileCanvas,
        0, 0, tileCanvas.width, tileCanvas.height,
        0, destinationTop, outputCanvas.width, destinationBottom - destinationTop,
    )

Questions

  • None.

Summary
Review mode: follow-up after new commits

Current head remains non-green: the added table Playwright suite passes 6/7 and still fails its header-action geometry assertion. Two additional mobile-lifecycle and large-export edge cases remain.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current-head CI: table Playwright 6/7; bun run test was not reached. CLI integration failures are outside this web-only diff and are not attributed here.
  • Add regression coverage for manual fullscreenchange cleanup and contiguous tile destinations at a fractional scale.

HAPI Bot

Comment thread web/src/index.css
position: absolute;
top: 0;
right: 0.1875rem;
line-height: normal;

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] Size the action overlay from the actual header row.

line-height: normal still gives this overlay a one-line fixed height, but the header row grows when any heading wraps. Current-head CI measures an 11px center delta at web/e2e/markdown-table.spec.ts:223 (expected ≤2px), so this required browser check remains red.

Suggested fix:

useLayoutEffect(() => {
    const row = inlineTableRef.current?.tHead?.rows[0]
    const actions = inlineActionsRef.current
    if (!row || !actions) return
    const sync = () => { actions.style.height = `${row.getBoundingClientRect().height}px` }
    sync()
    const observer = new ResizeObserver(sync)
    observer.observe(row)
    return () => observer.disconnect()
}, [children])

Attach inlineActionsRef to .aui-md-table-actions and let that measured height replace the one-line min-height assumption.

if (!openRef.current || !mobileViewerRef.current || !enteredFullscreenRef.current) return
if (document.fullscreenElement) return

enteredFullscreenRef.current = false

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] Unlock orientation on manual fullscreen exit.

This path closes the viewer and clears the mobile refs without calling the cleanup used by the close button. If the user exits browser fullscreen directly, the explicit landscape lock is therefore never released by HAPI.

Suggested fix:

if (!document.fullscreenElement) {
    leaveMobileTableViewer(false) // fullscreen already ended; unlock only
    enteredFullscreenRef.current = false
    mobileViewerRef.current = false
    openRef.current = false
    setViewerOpen(false)
}

Add a test that dispatches fullscreenchange with no fullscreen element and asserts orientation.unlock() is called.

height: currentTileHeight,
windowHeight: Math.max(document.documentElement.clientHeight, currentTileHeight),
})
outputContext.drawImage(tileCanvas, 0, Math.round(tileTop * scale))

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] Stitch fractional-scale tiles to shared rounded boundaries.

The tile canvas height is effectively ceil(currentTileHeight * scale), while the destination Y uses round(tileTop * scale). At fractional scales these disagree: a 1666px tile at 1.2x is 2000px tall, but the next tile begins at 1999px, duplicating a boundary row (other values can leave gaps).

Suggested fix:

const destinationTop = Math.round(tileTop * scale)
const destinationBottom = Math.round((tileTop + currentTileHeight) * scale)
outputContext.drawImage(
    tileCanvas,
    0, 0, tileCanvas.width, tileCanvas.height,
    0, destinationTop, outputCanvas.width, destinationBottom - destinationTop,
)

Cover this with a fractional-scale test that verifies every destination interval starts where the previous one ends.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Current head does not typecheck — the new wrapped-header test calls tHead on an HTMLElement, and current-head CI stops at this error before the added browser and unit suites run, evidence web/src/components/assistant-ui/MarkdownTable.test.tsx:124.
    Suggested fix:
    const table = screen.getByRole<HTMLTableElement>('table')
  • [Minor] Shared-preview tables retain the inline w-full sizing — the captured table class is passed after the viewer's w-max, so twMerge keeps w-full. Wide tables in the shared-turn viewer can therefore wrap/shrink even while the wrap toggle reports disabled, evidence web/src/components/assistant-ui/MarkdownTable.tsx:1021 (related inline class at web/src/components/assistant-ui/MarkdownTable.tsx:1138).
    Suggested fix:
    className: cn(props.table.getAttribute('class'), 'w-max min-w-full'),
  • [Minor] Standalone table viewers bypass localization — when no chat context exists, the hard-coded Table title prevents table.viewerTitle from being used, so Simplified Chinese file-preview viewers still show an English title and filename component, evidence web/src/components/assistant-ui/MarkdownTable.tsx:1049.
    Suggested fix:
    const imageTitle = chatContext?.sessionTitle?.trim() || t('table.viewerTitle')

Questions

  • None.

Summary
Review mode: follow-up after new commits

Current head remains non-green because of the added test's TypeScript error. The previous bot findings are addressed in the latest source, but the shared-preview sizing path and standalone localized fallback still need correction.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current-head CI: bun typecheck fails at MarkdownTable.test.tsx:124; table Playwright and unit suites were not reached.
  • CLI integration failures are outside this web-only diff and are not attributed here.
  • Add coverage using a shared-preview source table with w-full, plus a Simplified Chinese standalone viewer assertion.

HAPI Bot

try {
renderTable()
const table = screen.getByRole('table')
const row = table.tHead?.rows[0]

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] Keep the new test type-safe.

screen.getByRole() defaults to HTMLElement, so accessing table.tHead fails strict TypeScript. This is the current-head CI failure, and the workflow stops before the new Playwright and Vitest coverage runs.

Suggested fix:

const table = screen.getByRole<HTMLTableElement>('table')

}) {
const tableRef = useRef<HTMLTableElement | null>(null)
const tableProps: TableProps = {
className: props.table.getAttribute('class') ?? undefined,

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] Preserve max-content sizing in the shared-preview viewer.

The source element is the inline table rendered at line 1138, so its captured class list contains w-full. Passing that list here after TableViewer's w-max makes twMerge keep the later w-full; wide shared-preview tables can then shrink/wrap while the toolbar says wrapping is disabled, and the automatic overflow measurement sees the wrong layout.

Suggested fix:

className: cn(props.table.getAttribute('class'), 'w-max min-w-full'),

Cover this with a shared-preview table whose captured class includes w-full, asserting the viewer table retains w-max and can overflow before wrapping is enabled.

const openRef = useRef(false)
const mobileViewerRef = useRef(false)
const enteredFullscreenRef = useRef(false)
const imageTitle = chatContext?.sessionTitle ?? 'Table'

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] Use the localized fallback title outside chat sessions.

This hard-coded fallback is always non-empty, so TableViewer never reaches t('table.viewerTitle'). Standalone/file-preview tables in Simplified Chinese therefore display Table and use it in exported filenames despite the added 表格 locale entry.

Suggested fix:

const imageTitle = chatContext?.sessionTitle?.trim() || t('table.viewerTitle')

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Image actions silently discard failures — both PNG download and Copy image clear the progress state after a rejected render/clipboard operation without showing an error, so permission denial, unsupported clipboard behavior, or PNG encoding failure appears as a no-op, evidence web/src/components/assistant-ui/MarkdownTable.tsx:855 (same pattern at :867).
    Suggested fix:
    const [imageError, setImageError] = useState<string | null>(null)
    
    // Clear before starting either action.
    setImageError(null)
    void getPreparedImage(table)
        .then(/* existing action */)
        .catch(() => setImageError(t('table.imageActionFailed')))
    
    {imageError ? <div role="alert">{imageError}</div> : null}

Questions

  • None.

Summary
Review mode: follow-up after new commits

The latest commits resolve the prior typecheck, shared-preview sizing, and standalone localization findings. One remaining failure-path issue affects the new image actions.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current-head test job passed: typecheck, table Playwright suites, and repository unit tests.
  • Current-head CLI integration job failed in runner session tests; no CLI files are changed by this PR, so it is not attributed here.
  • Add rejected html2canvas and rejected clipboard-write tests asserting visible error feedback.

HAPI Bot

const filename = getShareImageFileName(props.imageTitle, 'table')
void getPreparedImage(table)
.then((blob) => downloadBlob(blob, filename))
.catch(() => undefined)

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] Surface image-action failures.

This catch discards every rendering or download error; the Copy image path does the same below. A denied clipboard write or failed PNG encode therefore only removes the spinner and leaves the user with no result or explanation.

Suggested fix:

const [imageError, setImageError] = useState<string | null>(null)

setImageError(null)
void getPreparedImage(table)
    .then((blob) => downloadBlob(blob, filename))
    .catch(() => setImageError(t('table.imageActionFailed')))

Render imageError with role="alert", and use the same handler for Copy image.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Awaiting the orientation lock delays fullscreen bookkeeping — after requestFullscreen() succeeds, the helper waits for orientation.lock() before callers set their enteredFullscreen refs. If the user exits browser fullscreen while that lock promise is pending, both fullscreenchange handlers ignore the exit because the refs are still false; when the promise later settles, the refs become true after the only exit event has passed, leaving the table viewer open and its cleanup path unrun. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:671 (callers at :1091 and web/src/components/AssistantChat/ShareTurnDialog.tsx:548).
    Suggested fix:
    if (orientation && typeof orientation.lock === 'function') {
        try {
            void orientation.lock('landscape').catch(() => undefined)
        } catch {
            // Unsupported browser; keep the viewer usable.
        }
    }
    return enteredFullscreen

Summary
Review mode: follow-up after new commits

The latest commit resolves the prior image-failure feedback finding. One mobile fullscreen lifecycle race remains.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current-head test job passed, including typecheck, table Playwright suites, and repository unit tests.
  • Current-head integration job failed only in unchanged cli/src/runner/runner.integration.test.ts; not attributed to this web-only diff.
  • Add a delayed orientation.lock() test that dispatches external fullscreenchange before the lock settles and asserts the viewer closes and orientation unlocks.

HAPI Bot

: undefined
if (orientation && typeof orientation.lock === 'function') {
try {
await orientation.lock('landscape')

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] Record fullscreen before waiting on the orientation lock.

After requestFullscreen() succeeds, this await prevents both callers from setting their enteredFullscreen refs until rotation finishes. If the user exits fullscreen while the lock promise is pending, the fullscreenchange handlers see a false ref and ignore the exit; the promise then settles and records fullscreen after the only exit event has passed, leaving the viewer open and cleanup unrun.

Suggested fix:

if (orientation && typeof orientation.lock === 'function') {
    try {
        void orientation.lock('landscape').catch(() => undefined)
    } catch {
        // Unsupported browser; keep the viewer usable.
    }
}
return enteredFullscreen

Add a regression test with a deferred lock promise and an external fullscreen exit before it resolves.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Copy Markdown drops inline links and formatting — every cell is flattened through rendered plain text, so a cell such as [Docs](https://example.com) becomes only Docs; inline code and emphasis are also lost. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:243
    Suggested fix:
    function serializeInlineMarkdown(node: Node): string {
        if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? String()
        if (node instanceof HTMLAnchorElement) {
            const label = Array.from(node.childNodes, serializeInlineMarkdown).join(String())
            return `[${label}](${node.getAttribute("href") ?? String()})`
        }
        if (node instanceof HTMLElement && node.tagName === "CODE") {
            return `\`${node.textContent ?? String()}\``
        }
        return Array.from(node.childNodes, serializeInlineMarkdown).join(String())
    }
    
    const rows = Array.from(table.rows).map((row) =>
        Array.from(row.cells).map((cell) =>
            Array.from(cell.childNodes, serializeInlineMarkdown).join(String()).trim(),
        ),
    )

Summary
Review mode: follow-up after new commits

The new commit removes the pending orientation-lock race identified in the prior review. One minor Markdown-export correctness issue remains. Residual browser risk remains around Clipboard/fullscreen implementations outside Chromium.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current test job passed, including typecheck, the added table Playwright suites, and repository tests.
  • Current integration job failed in unchanged cli/src/runner/runner.integration.test.ts runner/session cases; not attributed to this frontend-only diff.
  • Add coverage for table cells containing links, inline code, and emphasis.

HAPI Bot

}

export function serializeTableToMarkdown(table: HTMLTableElement): string {
const rows = Array.from(table.rows).map((row) =>

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] Preserve inline Markdown semantics in Copy Markdown.

This maps every cell through getTableCellText, so rich GFM cells are flattened before export. For example, [Docs](https://example.com) becomes Docs, losing the URL; inline code and emphasis lose their delimiters as well.

Suggested fix:

function serializeInlineMarkdown(node: Node): string {
    if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? String()
    if (node instanceof HTMLAnchorElement) {
        const label = Array.from(node.childNodes, serializeInlineMarkdown).join(String())
        return `[${label}](${node.getAttribute("href") ?? String()})`
    }
    if (node instanceof HTMLElement && node.tagName === "CODE") {
        return `\`${node.textContent ?? String()}\``
    }
    return Array.from(node.childNodes, serializeInlineMarkdown).join(String())
}

const rows = Array.from(table.rows).map((row) =>
    Array.from(row.cells).map((cell) =>
        Array.from(cell.childNodes, serializeInlineMarkdown).join(String()).trim(),
    ),
)

Please add a regression test with a linked cell and inline code.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Synchronous image-clipboard failures leave the action permanently busy — ClipboardItem construction and navigator.clipboard.write(...) can throw before this helper returns a promise. In that case, the caller's .catch(...).finally(...) chain is never installed, so imageAction remains "copy", the spinner stays visible, and image actions remain disabled even after closing/reopening this table. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:680

    Suggested fix:

    function writeTableImageToClipboard(
        mimeType: string,
        image: Blob | PromiseLike<Blob>,
    ): Promise<void> {
        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)
        }
    }

Summary
Review mode: follow-up after new commits

The latest commit resolves the prior Copy Markdown formatting finding. One cross-browser clipboard failure path remains. Residual browser risk remains around Clipboard/fullscreen implementations outside Chromium.

Testing

  • Not run locally (automation; PR code was not executed).
  • Current test job passed, including the added table Playwright suites and repository tests.
  • Current integration job failed in unchanged cli/src/runner/runner.integration.test.ts session/runner cases; not attributed to this frontend-only diff.
  • Add a unit test where the ClipboardItem constructor (and optionally clipboard.write) throws synchronously; assert the localized alert appears and the busy status clears.

HAPI Bot

return Promise.reject(new Error('Image clipboard is not supported in this browser'))
}
return navigator.clipboard.write([
new ClipboardItemCtor({ [mimeType]: image }),

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] Handle synchronous clipboard API exceptions.

ClipboardItem construction or navigator.clipboard.write(...) can throw synchronously. When that happens here, copyTableImagePromiseToClipboard(...) never returns a promise, so the caller cannot run its .catch(...).finally(...); imageAction stays "copy" and the UI remains stuck busy/disabled.

Suggested fix:

function writeTableImageToClipboard(
    mimeType: string,
    image: Blob | PromiseLike<Blob>,
): Promise<void> {
    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)
    }
}

Please add a regression test with a ClipboardItem stub whose constructor throws and verify the alert appears and the busy status clears.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Copy Markdown can lose original link destinations - the serializer reads the rendered DOM href. HAPI intentionally replaces unapproved custom-scheme links with #, and share-preview cleanup removes href entirely, so otherwise valid table links are copied as [label](#) or plain text. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:234 (related context web/src/components/assistant-ui/markdown-text.tsx:692 and web/src/components/AssistantChat/ShareTurnDialog.tsx:67).

    Suggested fix:

    // Preserve the source destination on rendered anchors.
    <a
        {...rest}
        data-hapi-markdown-href={href}
        href={domHref}
    />
    
    // Before share-preview cleanup removes href:
    const href = anchor.getAttribute('href')
    if (href && !anchor.dataset.hapiMarkdownHref) {
        anchor.dataset.hapiMarkdownHref = href
    }
    
    // Serializer:
    const href = element.dataset.hapiMarkdownHref ?? element.getAttribute('href')
  • [Minor] Inline-code serialization is not round-trip safe - every <code> is wrapped in a single backtick and the completed cell is then whitespace-collapsed. A valid source such as a double-fenced code span containing a backtick becomes invalid Markdown, and significant repeated spaces inside code are changed. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:239 and web/src/components/assistant-ui/MarkdownTable.tsx:264.

    Suggested fix:

    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)
        return `${fence}${needsPadding ? ' ' : ''}${value}${needsPadding ? ' ' : ''}${fence}`
    }
    
    // Normalize prose text nodes, not the fully serialized cell.
    if (node.nodeType === Node.TEXT_NODE) {
        return (node.textContent ?? '').replace(/\s+/g, ' ')
    }
    case 'code':
        return serializeCodeSpan(element.textContent ?? '')
    
    return Array.from(cell.childNodes, serializeInlineMarkdown).join('').trim()

Questions

  • None.

Summary
Review mode: follow-up after new commits

The latest commit addresses the prior synchronous clipboard-exception finding. Two Markdown-copy data-fidelity gaps remain.

Testing

  • Not run locally (automation/security policy; PR code was not executed).
  • Current test workflow passed, including typecheck, table Playwright suites, and repository tests.
  • Current integration workflow failed in unchanged CLI runner tests; no CLI files are changed, so it is not attributed to this PR diff.
  • Add regression tests for custom/sanitized links in both normal and shared-preview viewers, plus code spans containing backticks and repeated spaces.

HAPI Bot

const children = () => Array.from(element.childNodes, serializeInlineMarkdown).join('')
switch (element.tagName.toLowerCase()) {
case 'a': {
const href = element.getAttribute('href')

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] Preserve original link destinations when copying Markdown.

This reads the rendered DOM href, but HAPI deliberately changes some destinations before rendering: unapproved custom schemes become #, and share-preview cleanup removes href entirely. The new Copy Markdown action therefore emits [label](#) or only the label instead of the original valid target.

Suggested fix:

// On rendered anchors:
data-hapi-markdown-href={href}

// Before share-preview cleanup removes href:
const href = anchor.getAttribute('href')
if (href && !anchor.dataset.hapiMarkdownHref) {
    anchor.dataset.hapiMarkdownHref = href
}

// Here:
const href = element.dataset.hapiMarkdownHref ?? element.getAttribute('href')

Add a shared-preview regression test with an HTTPS link and a normal-viewer test for a custom-scheme link.

return href ? `[${label}](${href})` : label
}
case 'code':
return `\`${element.textContent ?? ''}\``

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] Serialize code spans with a safe backtick fence.

A single hard-coded backtick cannot represent code text that itself contains a backtick. The later cell-wide .replace(/\s+/g, ' ') also changes significant repeated spaces inside code. For example, a valid double-fenced source containing a\b` is copied as invalid Markdown.

Suggested fix:

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)
    return `${fence}${needsPadding ? ' ' : ''}${value}${needsPadding ? ' ' : ''}${fence}`
}

// Normalize prose at the text-node boundary instead of after serialization.
if (node.nodeType === Node.TEXT_NODE) {
    return (node.textContent ?? '').replace(/\s+/g, ' ')
}
case 'code':
    return serializeCodeSpan(element.textContent ?? '')

Then remove the cell-wide whitespace collapse and add cases for embedded backticks and repeated spaces.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Chat file links copy an internal HAPI URL — remarkFilePathLinks rewrites explicit relative file links before rendering, while the new source attribute records the rewritten hapi-file: destination. Copy Markdown therefore emits an app-internal URL and loses any original fragment, query, or line suffix. Evidence web/src/components/assistant-ui/markdown-text.tsx:534 (related context web/src/lib/remark-file-path-links.ts:214).
    Suggested fix:

    // Preserve the source URL before node.url is rewritten.
    node.data = {
        ...node.data,
        hProperties: {
            ...node.data?.hProperties,
            'data-hapi-markdown-href': url,
        },
    }
    node.url = createFileHref(target)
    
    // FilePathAnchor: prefer the preserved source URL.
    data-hapi-markdown-href={sourceHref ?? filePath}
  • [Minor] Standalone/file-preview links serialize as plain text — path-like links render through InertMarkdownHref as spans, but the new serializer only recognizes anchors. File previews use the standalone renderer, so Copy Markdown loses the destination and outputs only the label. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:233 (related context web/src/components/assistant-ui/markdown-text.tsx:592, web/src/routes/sessions/file.tsx:483).
    Suggested fix:

    <span data-hapi-markdown-href={props.href} {...rest}>
        {props.children}
    </span>
    
    case 'span': {
        const href = element.dataset.hapiMarkdownHref
        return href ? `[${children()}](${href})` : children()
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits

The follow-up addresses the prior custom-scheme/share-preview destination and code-fence findings. Two file-link rendering paths still do not round-trip through Copy Markdown.

Testing

  • Not run locally (automation/security policy; PR code was not executed).
  • Current GitHub test workflow passed, including typecheck, table Playwright suites, and repository tests.
  • Current integration workflow failed in unchanged cli/src/runner/runner.integration.test.ts coverage (4 failed, 9 passed, 1 skipped); no CLI/shared source is changed, so it is not attributed to this diff.
  • Add regression cases for a chat table link such as [Guide](docs/guide.md#setup) and the same link in the standalone file-preview renderer.

HAPI Bot

<a
{...anchorProps}
href={href}
data-hapi-markdown-href={anchorProps.href}

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] Preserve the pre-rewrite file-link destination.

remarkFilePathLinks changes an explicit relative link such as docs/foo.md#setup into hapi-file:docs%2Ffoo.md before this component runs, and it strips the fragment/query/line suffix for navigation. Storing anchorProps.href here therefore makes Copy Markdown emit the internal HAPI target rather than the authored destination.

Suggested fix:

// In rewriteFileLinkNode, preserve the original URL as an hProperty
// before assigning node.url = createFileHref(target).
node.data = {
    ...node.data,
    hProperties: {
        ...node.data?.hProperties,
        'data-hapi-markdown-href': url,
    },
}

// Here, prefer that preserved prop; fall back to the decoded file path.
data-hapi-markdown-href={sourceHref ?? filePath}

Add a regression case for [Guide](docs/guide.md#setup) and assert the copied Markdown keeps the original URL.

const element = node as HTMLElement
const children = () => Array.from(element.childNodes, serializeInlineMarkdown).join('')
switch (element.tagName.toLowerCase()) {
case 'a': {

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] Serialize links that the renderer intentionally makes inert.

The serializer only recognizes <a>. HAPI renders path-like links through InertMarkdownHref as <span> when navigation is unavailable; the file-preview route uses this standalone renderer. Copy Markdown from those tables therefore returns only the label and drops the destination.

Suggested fix:

// In InertMarkdownHref:
<span data-hapi-markdown-href={props.href} ...>
    {props.children}
</span>

// In serializeInlineMarkdown:
case 'span': {
    const href = element.dataset.hapiMarkdownHref
    return href ? `[${children()}](${href})` : children()
}

Add a standalone MarkdownRenderer regression test with [Guide](docs/guide.md).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Copy Markdown doubles backslashes inside inline code - the serializer constructs a code span and then applies a cell-wide backslash escape. For a rendered <code>C:\tmp</code>, the copied source becomes `C:\\tmp`, and code-span backslashes are literal, so the copied table renders different content. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:221.
    Suggested fix:

    function serializeMarkdownText(value: string): string {
        return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\')
    }
    
    if (node.nodeType === Node.TEXT_NODE) {
        return serializeMarkdownText(node.textContent ?? '')
    }
    
    function escapeMarkdownTableCell(value: string): string {
        return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
    }
  • [Minor] Pending mobile fullscreen work survives component unmount - cleanup releases only the currently recorded state but leaves openRef true. If requestFullscreen() resolves after the message/session unmounts, the continuation starts the orientation lock and treats the removed viewer as still open, leaving no later cleanup to exit fullscreen. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:1182.
    Suggested fix:

    useEffect(() => () => {
        const wasMobile = mobileViewerRef.current
        const enteredFullscreen = enteredFullscreenRef.current
        openRef.current = false
        mobileViewerRef.current = false
        enteredFullscreenRef.current = false
        if (wasMobile) leaveMobileTableViewer(enteredFullscreen)
    }, [])

Questions

  • None.

Summary
Review mode: follow-up after new commits

The new commits address the prior file-link round-trip findings. The full current diff still has two Minor correctness issues: code-span content can change during Markdown copy, and a deferred mobile fullscreen request can outlive its table component.

Testing

  • Not run locally (automation/security policy; PR code was not executed).
  • Current GitHub test workflow passed, including typecheck, table Playwright suites, and repository tests.
  • Current integration workflow failed in unchanged cli/src/runner/runner.integration.test.ts coverage (4 failed, 9 passed, 1 skipped); no CLI/shared source is changed, so it is not attributed to this diff.
  • Add regressions for a table code span containing C:\tmp and for unmounting while requestFullscreen() is deferred.

HAPI Bot

}

function escapeMarkdownTableCell(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')

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] Do not escape code-span backslashes as ordinary cell text.

serializeInlineMarkdown constructs the code span first, then this final pass doubles every backslash. A rendered <code>C:\tmp</code> becomes `C:\\tmp`; Markdown code spans treat backslashes literally, so Copy Markdown changes the cell content.

Suggested fix:

function serializeMarkdownText(value: string): string {
    return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\')
}

if (node.nodeType === Node.TEXT_NODE) {
    return serializeMarkdownText(node.textContent ?? '')
}

function escapeMarkdownTableCell(value: string): string {
    return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
}

Add a regression with an inline code cell containing C:\tmp.

return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])

useEffect(() => () => {

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] Invalidate pending fullscreen work during unmount.

If this component unmounts while requestFullscreen() is pending, cleanup unlocks only the current state but leaves openRef true. After the await, enterMobileTableViewer starts the orientation lock and the continuation treats the removed viewer as still open, so no later cleanup exits fullscreen.

Suggested fix:

useEffect(() => () => {
    const wasMobile = mobileViewerRef.current
    const enteredFullscreen = enteredFullscreenRef.current
    openRef.current = false
    mobileViewerRef.current = false
    enteredFullscreenRef.current = false
    if (wasMobile) leaveMobileTableViewer(enteredFullscreen)
}, [])

Add a test with deferred requestFullscreen(): unmount, resolve it, then assert exit/unlock cleanup.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Copy Markdown does not escape literal Markdown metacharacters in plain text — serializeMarkdownText escapes backslashes only. For example, an authored \*literal\* renders as the DOM text *literal*, then Copy Markdown emits *literal*, which reparses as emphasis instead of literal text. The same round-trip corruption affects literal brackets, backticks, tildes, and angle brackets. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:225.
    Suggested fix:
    function serializeMarkdownText(value: string): string {
        return value
            .replace(/\s+/g, ' ')
            .replace(/\\/g, '\\\\')
            .replace(/([`*_\[\]<>~])/g, '\\$1')
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits

The latest commits address both prior HAPI Bot findings. The full current diff still has one Minor Markdown round-trip issue on the new Copy Markdown path.

Testing

  • Not run locally (automation/security policy; untrusted PR code was not executed).
  • GitHub test workflow passed, including typecheck, existing browser coverage, the new table Playwright suites, and repository tests.
  • GitHub integration workflow failed in unchanged cli/src/runner/runner.integration.test.ts coverage (4 failed, 9 passed, 1 skipped); no CLI/shared source changed, so it is not attributed to this diff.
  • Add a render → copy → render regression covering literal *, _, [ ], backticks, ~~, and <...> in plain table text.

HAPI Bot

}

function serializeMarkdownText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\')

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] Escape literal Markdown syntax in plain text nodes.

This helper escapes backslashes only. An authored \*literal\* renders as DOM text *literal*; Copy Markdown emits that unchanged, so the copied table reparses it as emphasis. Literal brackets, backticks, tildes, and angle brackets have the same semantic round-trip problem.

Suggested fix:

function serializeMarkdownText(value: string): string {
    return value
        .replace(/\s+/g, ' ')
        .replace(/\\/g, '\\\\')
        .replace(/([`*_\[\]<>~])/g, '\\$1')
}

Add a render → copy → render regression for escaped literal Markdown punctuation in a table cell.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Auto-linked file paths copy HAPI-internal destinations — the serializer prefers data-hapi-markdown-href, but bare path autolinks receive an internal hapi-file: / hapi-file-candidate: fallback from FilePathAnchor. Copy Markdown therefore emits a non-portable link such as [web/src/router.tsx](hapi-file:web%2Fsrc%2Frouter.tsx) instead of the original bare path. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:241 (related context web/src/components/assistant-ui/markdown-text.tsx:508).
    Suggested fix:
    const label = children()
    if (href?.startsWith('hapi-file:') || href?.startsWith('hapi-file-candidate:')) {
        return label
    }
    return href ? `[${label}](${href})` : label
  • [Minor] Image alt text is not escaped before rebuilding Markdown — an authored alt such as a\]b renders as a]b, then copies as ![a]b](...), which no longer reparses as the same image. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:260.
    Suggested fix:
    const alt = serializeMarkdownText(element.getAttribute('alt') ?? '')
    return src ? `![${alt}](${src})` : alt

Questions

  • None.

Summary
Review mode: follow-up after new commits

The latest commit fixes the prior plain-text metacharacter finding. The full current diff still has two Minor Copy Markdown round-trip defects.

Testing

  • Not run locally (automation/security policy; untrusted PR code was not executed).
  • GitHub test check is still in progress at review time.
  • GitHub integration failed in bun run test:cli:integration; this PR changes web/workflow files only, so the failure is not attributed to the diff.
  • Add render → copy → render coverage for bare/inline-code file-path autolinks and image alt text containing ], \, and |.

HAPI Bot

const children = () => Array.from(element.childNodes, serializeInlineMarkdown).join('')
switch (element.tagName.toLowerCase()) {
case 'a': {
const href = element.dataset.hapiMarkdownHref ?? element.getAttribute('href')

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] Keep internal file-link schemes out of copied Markdown.

Bare file paths are auto-linked through hapi-file: / hapi-file-candidate:. FilePathAnchor stores that internal fallback in data-hapi-markdown-href, so this branch emits a non-portable destination such as [web/src/router.tsx](hapi-file:web%2Fsrc%2Frouter.tsx) rather than restoring the original bare path. Explicit authored file links already carry their original destination and should remain links.

Suggested fix:

const label = children()
if (href?.startsWith('hapi-file:') || href?.startsWith('hapi-file-candidate:')) {
    return label
}
return href ? `[${label}](${href})` : label

Add a chat-context regression for both a plain file-path autolink and an inline-code file-path autolink.

return ' '
case 'img': {
const src = element.getAttribute('src')
const alt = element.getAttribute('alt') ?? ''

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] Escape image alt text before rebuilding Markdown.

alt comes from the rendered DOM and is interpolated raw. For example, authored a\]b becomes DOM alt text a]b, then Copy Markdown emits ![a]b](...), which reparses with a broken image label.

Suggested fix:

const alt = serializeMarkdownText(element.getAttribute('alt') ?? '')
return src ? `![${alt}](${src})` : alt

Add a render → copy → render regression with ], \, and | in image alt text.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Per-cell header colors are overwritten in PNG exports — copyTableImageStyles first preserves each header cell’s computed background, but applyTableImageHeaderBackground then assigns the same thead/theme color to every cloned header cell. Tables with independently styled headers therefore export differently from the live table. Evidence web/src/components/assistant-ui/MarkdownTable.tsx:514.
    Suggested fix:
    cloneHead.querySelectorAll<HTMLElement>(':is(th, td)').forEach((cell) => {
        if (!isTransparentColor(cell.style.getPropertyValue('background-color'))) return
        cell.style.setProperty('background-color', background, 'important')
    })

Questions

  • None.

Summary
Review mode: follow-up after new commits

The two prior Copy Markdown findings are resolved. The full current diff has one remaining Minor PNG fidelity defect.

Testing

  • Not run locally (automation/security policy; untrusted PR code was not executed).
  • GitHub test passed.
  • GitHub integration failed; this PR changes web/workflow files only, so the failure is not attributed to the diff.
  • Add a PNG-clone regression with two header cells using different background colors.

HAPI Bot

cloneHead.style.setProperty('background-color', background, 'important')
cloneHead.querySelectorAll(':is(th, td)').forEach((cell) => {
if (!(cell instanceof HTMLElement)) return
cell.style.setProperty('background-color', background, 'important')

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] Preserve per-cell header colors in PNG exports.

copyTableImageStyles has already copied each header cell’s computed background, but this loop overwrites every cloned cell with one thead/theme color. A table with differently colored header cells therefore exports with a uniform header.

Suggested fix:

cloneHead.querySelectorAll<HTMLElement>(':is(th, td)').forEach((cell) => {
    if (!isTransparentColor(cell.style.getPropertyValue('background-color'))) return
    cell.style.setProperty('background-color', background, 'important')
})

Add a regression with two header cells using distinct background colors.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • None.

Summary
Review mode: follow-up after new commits

No new high-confidence issues found in the full current diff. The prior per-cell PNG header-color finding is resolved by preserving non-transparent cloned cell backgrounds and adding a two-color regression test.

Residual risk: image export remains browser-dependent (canvas, clipboard, fullscreen/orientation APIs), but the PR includes focused Vitest and Chromium coverage for those paths.

Testing

  • Not run locally (automation/security policy; untrusted PR code was not executed).
  • GitHub test passed, including the new required table Playwright suites.
  • GitHub integration failed in CLI runner integration tests; this PR changes web/workflow files only, so the failure is not attributed to the diff.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant