feat(web): share conversation turns as images#1047
Conversation
There was a problem hiding this comment.
Findings
- [Major] Fallback prompt snapshots are dropped when assistant DOM exists —
prependMissingUserSnapshotcan add a text-only user fallback for long conversations where the prompt is no longer mounted, but the dialog only turnssnapshot.htmlinto DOM here. When the snapshot list is[fallback user, assistant DOM], the fallback has no HTML children, the assistant DOM makesbodynon-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 typecheckandbun run testonly.
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') |
There was a problem hiding this comment.
[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)There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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,
stripCaptureOnlyControlsremoves thedata-hapi-share-excludetool DOM, the snapshot becomes empty, and this line appendssnapshot.textcaptured 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) |
There was a problem hiding this comment.
[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)
}
}
}There was a problem hiding this comment.
Findings
- [Major] Text fallback still re-adds stripped tool/reasoning content —
appendTextFallbackruns for any snapshot whose cloned DOM contributes no nodes. A tool-only assistant message hasouterHTMLandtext, thenstripCaptureOnlyControlsremoves thedata-hapi-share-excludetool 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) |
There was a problem hiding this comment.
[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)
}
}
}There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Findings
- [Minor] Localized/stateful copy controls can survive the export —
stripCaptureOnlyControlsonly removesbutton[title="Copy"], butMessageActionssets 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'))) { |
There was a problem hiding this comment.
[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()
}There was a problem hiding this comment.
Findings
- [Minor] Localized/stateful copy controls can still survive the export —
stripCaptureOnlyControlsremovesbutton[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 fromweb/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'))) { |
There was a problem hiding this comment.
[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()
}There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Unblocks typecheck after tiann#1036 made execStartedAt/execCompletedAt required on ChatToolCall; fixture was missing both fields. Co-authored-by: Cursor <cursoragent@cursor.com>
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:
Implementation
html2canvas-pro.Validation
bun typecheckbun run test:webbun run build:webbun run --cwd hub generate:embedded-web-assetsbun run build:hubbunx playwright test e2e/share-turn.spec.tsThe 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 testwas 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).