Skip to content

feat(web): share conversation turns as images#1047

Open
techotaku39 wants to merge 8 commits into
tiann:mainfrom
techotaku39:feat/web-share-turn-image
Open

feat(web): share conversation turns as images#1047
techotaku39 wants to merge 8 commits into
tiann:mainfrom
techotaku39:feat/web-share-turn-image

Conversation

@techotaku39

@techotaku39 techotaku39 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a per-message action that exports a complete conversation turn — the user prompt and its following assistant responses — as a styled PNG.

The export reuses the rendered HAPI message DOM and the page's already-loaded CSS so the result preserves the current theme and message presentation without depending on stylesheets being fetched again inside the capture iframe. This is especially important for mobile, reverse-proxy, and higher-latency deployments.

Motivation

HAPI currently makes it easy to copy message text, but sharing a useful coding-agent exchange still requires manual screenshots. A single assistant message is often not meaningful without the prompt that produced it, so this feature treats the user message plus all following assistant messages up to the next user message as one shareable turn.

Compared with a normal viewport screenshot, the generated image:

  • captures the complete turn rather than only the visible screen;
  • keeps HAPI's light/dark theme, typography, Markdown, tables, code highlighting, and attachments;
  • uses a consistent 960 px desktop layout, including on mobile;
  • renders at up to 2x resolution with a pixel-budget guard for long turns;
  • omits interactive controls, reasoning panels, and tool output that should not appear in the shared image.

Implementation

  • Add a localized share action to the existing message action row.
  • Locate the containing turn from the rendered thread and snapshot its user/assistant message DOM.
  • Fall back to assistant-ui thread state when a long conversation no longer has the preceding user message in the rendered DOM window.
  • Render the snapshot in an off-screen 960 px iframe and export it with html2canvas-pro.
  • Inline already-loaded CSS rules into the capture document, preserving stylesheet-relative asset URLs and avoiding a second CSS request through proxies such as FRP.
  • Support clipboard copy, native file sharing, and PNG download.
  • Add English and Simplified Chinese strings.

Validation

  • bun typecheck
  • bun run test:web
  • bun run build:web
  • bun run --cwd hub generate:embedded-web-assets
  • bun run build:hub
  • bunx playwright test e2e/share-turn.spec.ts

The Playwright coverage exercises desktop, mobile, and mobile dark mode with a complex turn containing long Chinese/English text, an image attachment, nested lists, a blockquote, a table, long highlighted TypeScript, and excluded tool content. It also blocks every stylesheet request after the initial page load to verify that export styling does not depend on the capture iframe refetching CSS.

bun run test was also attempted locally. The Web suite passes; the full command remains blocked on this Windows environment by 7 pre-existing Hub Cursor ACP/PATH migration tests unrelated to this Web-only change.

AI disclosure

This implementation was developed with assistance from OpenAI Codex (GPT-5.6).

@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] Fallback prompt snapshots are dropped when assistant DOM exists — prependMissingUserSnapshot can add a text-only user fallback for long conversations where the prompt is no longer mounted, but the dialog only turns snapshot.html into DOM here. When the snapshot list is [fallback user, assistant DOM], the fallback has no HTML children, the assistant DOM makes body non-empty, and the whole-body fallback at line 422 never runs; the exported turn loses the prompt that this feature is meant to include. Evidence: web/src/components/AssistantChat/ShareTurnDialog.tsx:407.
    Suggested fix:
    const appendTextFallback = (target: DocumentFragment, snapshot: ShareTurnDialogProps['sourceSnapshots'][number]) => {
        if (snapshot.text.trim().length === 0) return
        const fallback = document.createElement('div')
        fallback.className = snapshot.role === 'user'
            ? 'happy-user-bubble happy-chat-text ml-auto w-fit min-w-0 max-w-[92%] whitespace-pre-wrap break-words rounded-2xl bg-[var(--app-chat-user-surface-bg)] px-4 py-2.5 text-[var(--app-chat-user-fg)] shadow-none'
            : 'whitespace-pre-wrap break-words rounded-2xl border border-[var(--app-border)] px-4 py-2.5 text-sm leading-6 text-[var(--app-fg)]'
        if (snapshot.role) fallback.dataset.hapiMessageRole = snapshot.role
        fallback.textContent = snapshot.text
        target.appendChild(fallback)
    }
    
    // Inside the snapshot loop:
    let appendedSnapshot = false
    for (const node of Array.from(template.content.children)) {
        // existing filtering...
        fragment.appendChild(node)
        appendedSnapshot = true
    }
    if (!appendedSnapshot) appendTextFallback(fragment, snapshot)

Summary

  • Review mode: initial
  • Latest diff reviewed in full. Found one major correctness issue in the long-conversation fallback path for shared turn exports.
  • Residual testing gap: the new Playwright spec is not run by the default CI workflow, which currently runs bun typecheck and bun run test only.

Testing

  • Not run (automation); static review only.

HAPI Bot

const fragment = document.createDocumentFragment()
let textLength = 0
for (const snapshot of props.sourceSnapshots) {
const template = document.createElement('template')

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] prependMissingUserSnapshot can add a text-only user fallback for long conversations where the prompt is no longer mounted, but this loop only renders snapshot.html. In the intended fallback case ([{ html: '', text: prompt, role: 'user' }, assistantDomSnapshot]), the user snapshot contributes no nodes, the assistant DOM makes body non-empty, and the fallback block at line 422 never runs. The exported turn drops the prompt.

Suggested fix:

const appendTextFallback = (target: DocumentFragment, snapshot: ShareTurnDialogProps['sourceSnapshots'][number]) => {
    if (snapshot.text.trim().length === 0) return
    const fallback = document.createElement('div')
    fallback.className = snapshot.role === 'user'
        ? 'happy-user-bubble happy-chat-text ml-auto w-fit min-w-0 max-w-[92%] whitespace-pre-wrap break-words rounded-2xl bg-[var(--app-chat-user-surface-bg)] px-4 py-2.5 text-[var(--app-chat-user-fg)] shadow-none'
        : 'whitespace-pre-wrap break-words rounded-2xl border border-[var(--app-border)] px-4 py-2.5 text-sm leading-6 text-[var(--app-fg)]'
    if (snapshot.role) fallback.dataset.hapiMessageRole = snapshot.role
    fallback.textContent = snapshot.text
    target.appendChild(fallback)
}

let appendedSnapshot = false
for (const node of Array.from(template.content.children)) {
    // existing filtering...
    fragment.appendChild(node)
    appendedSnapshot = true
}
if (!appendedSnapshot) appendTextFallback(fragment, snapshot)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c2187ea. Text fallback is now applied per snapshot, so a text-only user prompt is rendered even when assistant DOM snapshots are present. Added a Playwright regression covering a text-only user fallback alongside assistant DOM; all 4 share-turn E2E cases and the full Web test suite pass.

@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] Text fallback re-adds stripped tool-only content — the new per-snapshot fallback runs whenever a cloned snapshot contributes no DOM nodes. For a tool-only assistant message, stripCaptureOnlyControls removes the data-hapi-share-exclude tool DOM, the snapshot becomes empty, and this line appends snapshot.text captured before stripping, so tool output that should be omitted appears in the shared image. The whole-body fallback below has the same leak when every snapshot is stripped. Evidence: web/src/components/AssistantChat/ShareTurnDialog.tsx:434.
    Suggested fix:
    const isTextOnlySnapshot = snapshot.html.trim().length === 0
    // ... after the template loop
    if (!appendedSnapshot && isTextOnlySnapshot) {
        appendTextFallback(fragment, snapshot)
    }
    
    if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) {
        for (const snapshot of props.sourceSnapshots) {
            if (snapshot.html.trim().length === 0) {
                appendTextFallback(body, snapshot)
            }
        }
    }

Summary

  • Review mode: follow-up after new commits
  • Latest diff reviewed in full. Found one major correctness issue introduced by the follow-up fallback handling: excluded tool/reasoning-only snapshots can be reintroduced as plain text.
  • Residual testing gap: the Playwright fixture covers excluded tool content mixed with visible assistant markdown, but not a tool-only assistant snapshot that strips to empty.

Testing

  • Not run (automation); static review only.

HAPI Bot

fragment.appendChild(node)
appendedSnapshot = true
}
if (!appendedSnapshot) appendTextFallback(fragment, snapshot)

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 per-snapshot fallback now runs for any snapshot whose cloned DOM contributes no nodes. A tool-only assistant message hits that path: stripCaptureOnlyControls removes the data-hapi-share-exclude tool DOM, then appendTextFallback re-adds snapshot.text captured before stripping, so tool output that should be omitted appears in the shared image. The whole-body fallback below has the same leak when every snapshot is stripped.

Suggested fix:

const isTextOnlySnapshot = snapshot.html.trim().length === 0
// ... after the template loop
if (!appendedSnapshot && isTextOnlySnapshot) {
    appendTextFallback(fragment, snapshot)
}

if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) {
    for (const snapshot of props.sourceSnapshots) {
        if (snapshot.html.trim().length === 0) {
            appendTextFallback(body, snapshot)
        }
    }
}

@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] Text fallback still re-adds stripped tool/reasoning content — appendTextFallback runs for any snapshot whose cloned DOM contributes no nodes. A tool-only assistant message has outerHTML and text, then stripCaptureOnlyControls removes the data-hapi-share-exclude tool DOM, so this fallback re-inserts the tool output as plain text even though the export is meant to omit it. The follow-up test only covers an intentional text-only user fallback, not a DOM snapshot that becomes empty after stripping. Evidence: web/src/components/AssistantChat/ShareTurnDialog.tsx:434.
    Suggested fix:
    const isTextOnlySnapshot = snapshot.html.trim().length === 0
    
    // ... after processing template.content.children
    if (!appendedSnapshot && isTextOnlySnapshot) {
        appendTextFallback(fragment, snapshot)
    }
    
    if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) {
        for (const snapshot of props.sourceSnapshots) {
            if (snapshot.html.trim().length === 0) {
                appendTextFallback(body, snapshot)
            }
        }
    }

Summary

  • Review mode: follow-up after new commits
  • Latest PR diff reviewed in full. The prior Major remains unresolved at the current head: stripped tool/reasoning-only snapshots can still be exported through the generic text fallback.
  • Residual testing gap: add a case where an assistant snapshot contains only data-hapi-share-exclude="true" DOM plus text, then assert the preview/download does not contain that text.

Testing

  • Not run (automation); static review only.

HAPI Bot

fragment.appendChild(node)
appendedSnapshot = true
}
if (!appendedSnapshot) appendTextFallback(fragment, snapshot)

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] appendTextFallback still runs for any snapshot whose cloned DOM contributes no nodes. A tool-only assistant message has outerHTML and text, then stripCaptureOnlyControls removes the data-hapi-share-exclude tool DOM, so this fallback re-inserts the tool output as plain text even though the export is meant to omit it. The new fallback export test covers an intentional text-only user snapshot, but not a DOM snapshot that becomes empty only after stripping.

Suggested fix:

const isTextOnlySnapshot = snapshot.html.trim().length === 0

// ... after processing template.content.children
if (!appendedSnapshot && isTextOnlySnapshot) {
    appendTextFallback(fragment, snapshot)
}

if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) {
    for (const snapshot of props.sourceSnapshots) {
        if (snapshot.html.trim().length === 0) {
            appendTextFallback(body, snapshot)
        }
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9d51759. Text fallback now runs only for intentionally text-only snapshots (html is empty), so DOM snapshots emptied by share-exclusion filtering cannot leak tool/reasoning text back into the preview or PNG. Added a tool-only assistant regression and performed a real PNG download; all 5 share-turn Playwright cases, Web typecheck, and all 1,185 Web tests pass.

@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] Localized/stateful copy controls can survive the export — stripCaptureOnlyControls only removes button[title="Copy"], but MessageActions sets that title from translation and from copy state (Copy, Copied, 复制, 已复制). In non-English UI, or after a message was copied, the copy button remains in the cloned message DOM and can show up in the shared image even though the feature is meant to omit interactive controls. Evidence: web/src/components/AssistantChat/ShareTurnDialog.tsx:33.
    Suggested fix:
    for (const element of Array.from(root.querySelectorAll('.happy-message-actions, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) {
        element.remove()
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
Latest PR diff reviewed in full. The prior Major fallback issue appears addressed in this head; remaining risk is the action-row stripping path above. The current e2e fixture does not render the real MessageActions row, so it does not cover localized/copied action controls leaking into preview/export.

Testing

  • Not run (automation); static review only.

HAPI Bot

for (const element of Array.from(root.querySelectorAll('[data-hapi-share-exclude="true"], .aui-reasoning-group'))) {
element.remove()
}
for (const element of Array.from(root.querySelectorAll('.happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], button[title="Copy"], input, textarea, select'))) {

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] stripCaptureOnlyControls only removes button[title="Copy"], but the real copy button title is localized and stateful (Copy, Copied, 复制, 已复制). In Chinese UI, or after the user has copied a message, the copy control remains in the cloned DOM and can appear in the shared image even though interactive controls are supposed to be omitted.

Suggested fix:

for (const element of Array.from(root.querySelectorAll('.happy-message-actions, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) {
    element.remove()
}

@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] Localized/stateful copy controls can still survive the export — stripCaptureOnlyControls removes button[title="Copy"], but the real copy button title comes from translation and copy state (Copy, Copied, 复制, 已复制). In Chinese UI, or after the user copied a message, that copy button remains in the cloned message DOM and can show up in the shared image. Evidence: web/src/components/AssistantChat/ShareTurnDialog.tsx:33, with localized titles coming from web/src/components/AssistantChat/messages/MessageActions.tsx:35.
    Suggested fix:
    for (const element of Array.from(root.querySelectorAll('.happy-message-actions, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) {
        element.remove()
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
Latest PR diff reviewed in full. The only high-confidence issue I found is the unresolved action-row stripping path above. The new Chinese e2e case checks dialog/export labels, but the fixture builds snapshots manually and does not render the real MessageActions row, so it does not cover localized or Copied message controls leaking into preview/export.

Testing

  • Not run (automation); static review only.

HAPI Bot

for (const element of Array.from(root.querySelectorAll('[data-hapi-share-exclude="true"], .aui-reasoning-group'))) {
element.remove()
}
for (const element of Array.from(root.querySelectorAll('.happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], button[title="Copy"], input, textarea, select'))) {

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] stripCaptureOnlyControls still keys the copy control cleanup on button[title="Copy"], but the real message action title is localized and stateful (Copy, Copied, 复制, 已复制). In Chinese UI, or after the user has copied a message, the copy button remains in the cloned DOM and can appear in the shared image even though interactive controls are supposed to be omitted.

Suggested fix:

for (const element of Array.from(root.querySelectorAll('.happy-message-actions, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) {
    element.remove()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 19f0e8c. Export cleanup now removes the entire .happy-message-actions row instead of matching the English Copy title, so localized and stateful controls such as 复制 / 已复制 cannot survive. Added the localized copied-state action row to the complex fixture and asserts it is absent from the preview; Web typecheck and all 6 share-turn Playwright cases pass.

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

Questions

  • None.

Summary
Review mode: follow-up after new commits
No new high-confidence issues found in the latest PR diff. The previous HAPI Bot finding about localized/stateful message action controls leaking into the export appears addressed at the current head by removing the full .happy-message-actions row and adding follow-up e2e coverage.

Residual risk: static review only; browser-specific html2canvas-pro, clipboard, and native share API behavior still depends on the submitted e2e/build automation.

Testing

  • Not run (automation); static review only.

HAPI Bot

techotaku39 and others added 2 commits July 16, 2026 19:58
Unblocks typecheck after tiann#1036 made execStartedAt/execCompletedAt
required on ChatToolCall; fixture was missing both fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
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