diff --git a/bun.lock b/bun.lock index 1ad1b19a..4fe8c67e 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,7 @@ "clsx": "^2.1.1", "diff": "^8.0.2", "hast-util-to-jsx-runtime": "^2.3.6", + "html2canvas-pro": "^2.0.4", "katex": "^0.16.45", "mermaid": "^11.12.0", "react": "^19.2.3", @@ -1380,6 +1381,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg=="], @@ -1508,6 +1511,8 @@ "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -1926,6 +1931,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "html2canvas-pro": ["html2canvas-pro@2.2.4", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fo/VOiQZ3oWykAPvnMNHymUHzLWJE7c7C7VWPh+ydb+NquFTwqY5rHQRoxkgsaXo/iciVryPNffTvLzYg98xhg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -2874,6 +2881,8 @@ "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], @@ -3002,6 +3011,8 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], diff --git a/e2e/share-turn.spec.ts b/e2e/share-turn.spec.ts new file mode 100644 index 00000000..f03e9f17 --- /dev/null +++ b/e2e/share-turn.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' + +function pngSize(bytes: Buffer): { width: number; height: number } { + expect(bytes.subarray(1, 4).toString()).toBe('PNG') + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } +} + +for (const viewport of [ + { name: 'desktop', width: 1280, height: 2200, theme: 'light' }, + { name: 'mobile', width: 390, height: 844, theme: 'light' }, + { name: 'mobile-dark', width: 390, height: 844, theme: 'dark' } +]) { + test(`exports a populated wide PNG on ${viewport.name}`, async ({ page }, testInfo) => { + let stylesheetRequests = 0 + await page.route('**/share-turn-extra.css', async (route) => { + stylesheetRequests += 1 + if (stylesheetRequests === 1) { + await route.fulfill({ + contentType: 'text/css', + headers: { 'cache-control': 'no-store' }, + body: '.share-turn-network-style{border-left:5px solid rgb(124 58 237);border-radius:14px;background:rgb(124 58 237 / 10%);padding:12px 16px}' + }) + return + } + await route.abort() + }) + await page.setViewportSize(viewport) + await page.goto(`/e2e-fixtures/share-turn-fixture.html?theme=${viewport.theme}`) + + await expect(page.getByText('Complex response fixture')).toBeVisible() + await expect(page.getByText('Excluded tool output')).toBeVisible() + await expect(page.getByText(/type ExportResult/)).toBeVisible() + await page.getByTestId('source-turn').screenshot({ path: testInfo.outputPath(`source-${viewport.name}.png`) }) + await page.getByRole('button', { name: 'Open share preview' }).click() + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByRole('dialog').getByText('Excluded tool output')).toHaveCount(0) + await expect(page.getByRole('dialog').locator('.happy-message-actions')).toHaveCount(0) + if (viewport.name === 'desktop') { + const styles = await page.evaluate(() => { + const source = document.querySelector('[data-testid="source-turn"]') + const preview = document.querySelector('[data-hapi-share-body="true"]') + if (!source || !preview) throw new Error('Missing comparison roots') + const selectors = ['.happy-user-bubble', 'h2', 'blockquote', 'table', 'pre'] + const properties = ['fontFamily', 'fontSize', 'fontWeight', 'color', 'backgroundColor', 'borderRadius'] as const + return selectors.map((selector) => { + const sourceElement = source.querySelector(selector) + const previewElement = preview.querySelector(selector) + if (!sourceElement || !previewElement) throw new Error(`Missing ${selector}`) + const sourceStyle = getComputedStyle(sourceElement) + const previewStyle = getComputedStyle(previewElement) + return properties.map((property) => [sourceStyle[property], previewStyle[property]]) + }) + }) + for (const pairs of styles) { + for (const [sourceValue, previewValue] of pairs) { + expect(previewValue).toBe(sourceValue) + } + } + } + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /Download PNG|下载 PNG/ }).click() + const download = await downloadPromise + const path = testInfo.outputPath(`share-turn-${viewport.name}.png`) + await download.saveAs(path) + + const bytes = await readFile(path) + const size = pngSize(bytes) + expect(download.suggestedFilename()).toMatch(/^HAPI-\d{14}\.png$/) + expect(bytes.byteLength).toBeGreaterThan(80_000) + expect(size.width).toBe(1920) + expect(size.height).toBeGreaterThan(1_000) + expect(stylesheetRequests).toBe(1) + }) +} + +test('exports a text-only user fallback alongside assistant DOM', async ({ page }, testInfo) => { + await page.goto('/e2e-fixtures/share-turn-fixture.html?fallback=user') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText(/请导出这一轮复杂对话/)).toBeVisible() + await expect(dialog.getByText('Complex response fixture')).toBeVisible() + await dialog.screenshot({ path: testInfo.outputPath('fallback-preview.png') }) + + const downloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: /Download PNG|下载 PNG/ }).click() + const download = await downloadPromise + const path = testInfo.outputPath('fallback-export.png') + await download.saveAs(path) + + const bytes = await readFile(path) + const size = pngSize(bytes) + expect(bytes.byteLength).toBeGreaterThan(80_000) + expect(size.width).toBe(1920) + expect(size.height).toBeGreaterThan(1_000) +}) + +test('keeps a stripped tool-only assistant snapshot out of the export', async ({ page }, testInfo) => { + await page.goto('/e2e-fixtures/share-turn-fixture.html?toolOnly=assistant') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('Complex response fixture')).toBeVisible() + await expect(dialog.getByText('TOOL_ONLY_SECRET_SHOULD_NOT_EXPORT')).toHaveCount(0) + + const downloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: /Download PNG|下载 PNG/ }).click() + const download = await downloadPromise + const path = testInfo.outputPath('tool-only-excluded-export.png') + await download.saveAs(path) + + const bytes = await readFile(path) + const size = pngSize(bytes) + expect(bytes.byteLength).toBeGreaterThan(80_000) + expect(size.width).toBe(1920) + expect(size.height).toBeGreaterThan(1_000) +}) + +test('localizes the share dialog and exported badge in Chinese', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('hapi-lang', 'zh-CN')) + await page.goto('/e2e-fixtures/share-turn-fixture.html') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: '将本轮对话分享为图片' })).toBeVisible() + await expect(dialog.getByText('分享会话', { exact: true })).toBeVisible() + await expect(dialog.getByText('Generated by HAPI', { exact: true })).toBeVisible() + await expect(dialog.getByRole('button', { name: '取消' })).toBeVisible() + await expect(dialog.getByRole('button', { name: '复制图片' })).toBeVisible() + await expect(dialog.getByRole('button', { name: '分享文件' })).toBeVisible() + await expect(dialog.getByRole('button', { name: '下载 PNG' })).toBeVisible() +}) + +test('uses a prepared PNG while native share still has click activation', async ({ page }) => { + await page.addInitScript(() => { + const state = { calls: 0, active: false, fileType: '' } + Object.defineProperty(window, '__hapiShareTest', { value: state, configurable: true }) + Object.defineProperty(navigator, 'canShare', { + configurable: true, + value: () => true + }) + Object.defineProperty(navigator, 'share', { + configurable: true, + value: (data: ShareData) => { + state.calls += 1 + state.active = navigator.userActivation?.isActive ?? false + state.fileType = data.files?.[0]?.type ?? '' + return Promise.resolve() + } + }) + }) + await page.goto('/e2e-fixtures/share-turn-fixture.html') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const shareButton = page.getByRole('dialog').getByRole('button', { name: 'Share file' }) + await expect(shareButton).toBeEnabled() + await shareButton.click() + + await expect.poll(() => page.evaluate(() => { + return (window as typeof window & { __hapiShareTest: { calls: number } }).__hapiShareTest.calls + })).toBe(1) + const result = await page.evaluate(() => { + return (window as typeof window & { + __hapiShareTest: { active: boolean; fileType: string } + }).__hapiShareTest + }) + expect(result.active).toBe(true) + expect(result.fileType).toBe('image/png') +}) diff --git a/web/e2e-fixtures/share-turn-extra.css b/web/e2e-fixtures/share-turn-extra.css new file mode 100644 index 00000000..40b6b86d --- /dev/null +++ b/web/e2e-fixtures/share-turn-extra.css @@ -0,0 +1,6 @@ +.share-turn-network-style { + border-left: 5px solid rgb(124 58 237); + border-radius: 14px; + background: rgb(124 58 237 / 10%); + padding: 12px 16px; +} diff --git a/web/e2e-fixtures/share-turn-fixture.html b/web/e2e-fixtures/share-turn-fixture.html new file mode 100644 index 00000000..3437cd41 --- /dev/null +++ b/web/e2e-fixtures/share-turn-fixture.html @@ -0,0 +1,13 @@ + + + + + + HAPI share turn e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/share-turn-fixture.tsx b/web/e2e-fixtures/share-turn-fixture.tsx new file mode 100644 index 00000000..f318344a --- /dev/null +++ b/web/e2e-fixtures/share-turn-fixture.tsx @@ -0,0 +1,120 @@ +import { useRef, useState } from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { ShareTurnDialog } from '../src/components/AssistantChat/ShareTurnDialog' +import { getUserBubbleClassName } from '../src/components/AssistantChat/messages/user-bubble' +import { MarkdownRenderer } from '../src/components/MarkdownRenderer' +import { I18nProvider } from '../src/lib/i18n-context' + +const fixtureImage = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(` + + + + + HAPI export + image attachment fixture + +`) + +const markdown = `## Complex response fixture + +This paragraph contains **bold text**, *emphasis*, ~~strikethrough~~, inline \`code\`, a [safe link](https://example.com), 中文内容,以及一段足够长的文字,用于验证换行、行高和宽屏导出效果是否与原始 HAPI 页面保持一致。 + +> A multi-line blockquote used to verify borders, indentation, colors, and wrapping. \n> 第二行引用包含中文。 + +### Lists + +- First unordered item + - Nested item with \`inline code\` +- Second unordered item with a deliberately long sentence that must wrap without being clipped on narrow preview surfaces. + +1. First ordered item +2. Second ordered item + +| Feature | Desktop | Mobile export | +| --- | ---: | ---: | +| Markdown | ✅ | ✅ | +| Wide code | 960px | 960px | +| Dark theme | ✅ | ✅ | + +\`\`\`typescript +type ExportResult = { width: number; theme: 'light' | 'dark'; content: string[] } +const result: ExportResult = { width: 960, theme: 'dark', content: ['markdown', 'table', 'image', 'long-code-line-that-must-not-disappear-from-the-right-hand-side'] } +console.log(result) +\`\`\` + +--- + +Final paragraph after the divider. +` + +if (new URLSearchParams(window.location.search).get('theme') === 'dark') { + document.documentElement.dataset.theme = 'dark' +} + +type Snapshot = { html: string; text: string } + +function App() { + const sourceRef = useRef(null) + const [snapshots, setSnapshots] = useState([]) + const [open, setOpen] = useState(false) + + const openShare = () => { + const searchParams = new URLSearchParams(window.location.search) + const textOnlyUserFallback = searchParams.get('fallback') === 'user' + const includeToolOnlySnapshot = searchParams.get('toolOnly') === 'assistant' + const messages = Array.from(sourceRef.current?.children ?? []) + .filter((node): node is HTMLElement => node instanceof HTMLElement) + .map((node, index) => ({ + html: textOnlyUserFallback && index === 0 ? '' : node.outerHTML, + text: node.innerText, + role: index === 0 ? 'user' as const : 'assistant' as const + })) + if (includeToolOnlySnapshot) { + messages.push({ + html: '
TOOL_ONLY_SECRET_SHOULD_NOT_EXPORT
', + text: 'TOOL_ONLY_SECRET_SHOULD_NOT_EXPORT', + role: 'assistant' + }) + } + setSnapshots(messages) + setOpen(true) + } + + return ( + +
+
+
+
+
+ plan + {'请导出这一轮复杂对话,并确保代码、表格、图片附件和长文本的样式全部保留。\n第二行用于验证换行。'} +
+ HAPI export fixture +
+
+
+ +
Excluded tool output
+
+ +
+
+
+ +
+ setOpen(false)} + /> +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/web/package.json b/web/package.json index c6822aba..b1102676 100644 --- a/web/package.json +++ b/web/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "diff": "^8.0.2", "hast-util-to-jsx-runtime": "^2.3.6", + "html2canvas-pro": "^2.0.4", "katex": "^0.16.45", "mermaid": "^11.12.0", "react": "^19.2.3", diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 7a99c9be..98ac0986 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -7,6 +7,7 @@ import { captureScrollAnchor, getScrollIntent, locateOutlineTargetMessage, + prependMissingUserSnapshot, restoreScrollAnchor, shouldCancelInitialScrollSettling, } from '@/components/AssistantChat/HappyThread' @@ -261,3 +262,19 @@ describe('outline target loading', () => { expect(loadOlderPreservingScroll).toHaveBeenCalledTimes(1) }) }) + +describe('share turn snapshots', () => { + it('restores the preceding user prompt when a long rendered turn only contains assistant DOM', () => { + const assistant = { html: '
answer
', text: 'answer', role: 'assistant' as const } + const user = { html: '', text: 'original long-conversation prompt', role: 'user' as const } + + expect(prependMissingUserSnapshot([assistant], user)).toEqual([user, assistant]) + }) + + it('does not duplicate a user prompt already captured from the DOM', () => { + const user = { html: '
prompt
', text: 'prompt' } + const fallback = { html: '', text: 'prompt', role: 'user' as const } + + expect(prependMissingUserSnapshot([user], fallback)).toEqual([user]) + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 2d53575f..d961ba0d 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -13,6 +13,7 @@ import { Spinner } from '@/components/Spinner' import { useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { useTranslation } from '@/lib/use-translation' import { CloseIcon } from '@/components/icons' +import { ShareTurnDialog } from '@/components/AssistantChat/ShareTurnDialog' type ScrollAnchor = { id: string @@ -25,6 +26,57 @@ type PendingScrollRestore = { scrollHeight: number } +type ShareTurnState = { + id: number + snapshots: ShareTurnSnapshot[] + title: string + subtitle: string +} | null + +type ShareTurnSnapshot = { + html: string + text: string + role?: 'user' | 'assistant' +} + +function findNearestMessageElement(content: HTMLElement, clientY?: number): HTMLElement | null { + const messages = Array.from(content.querySelectorAll('.happy-thread-messages > [id]')) + .filter((element): element is HTMLElement => element instanceof HTMLElement) + if (messages.length === 0) return null + if (typeof clientY !== 'number' || !Number.isFinite(clientY)) { + return messages[messages.length - 1] ?? null + } + + let best: HTMLElement | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const message of messages) { + const rect = message.getBoundingClientRect() + const distance = clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0 + if (distance < bestDistance) { + best = message + bestDistance = distance + } + } + return best +} + +export function prependMissingUserSnapshot( + snapshots: ShareTurnSnapshot[], + fallbackSnapshot?: ShareTurnSnapshot +): ShareTurnSnapshot[] { + const hasUser = snapshots.some((snapshot) => + snapshot.role === 'user' || snapshot.html.includes('data-hapi-message-role="user"') + ) + if (hasUser || fallbackSnapshot?.role !== 'user' || fallbackSnapshot.text.trim().length === 0) { + return snapshots + } + return [fallbackSnapshot, ...snapshots] +} + const MESSAGE_ANCHOR_SELECTOR = '.happy-thread-messages > [id]' const AUTO_SCROLL_RESUME_THRESHOLD_PX = 120 const MANUAL_SCROLL_EPSILON_PX = 1 @@ -304,6 +356,8 @@ export function HappyThread(props: { const { terminalToolDisplayMode } = useTerminalToolDisplayMode() const viewportRef = useRef(null) const contentRef = useRef(null) + const [shareTurn, setShareTurn] = useState(null) + const shareTurnIdRef = useRef(0) const topSentinelRef = useRef(null) const loadLockRef = useRef(false) const pendingScrollRef = useRef(null) @@ -716,6 +770,72 @@ export function HappyThread(props: { }, [props.isLoadingMoreMessages, settlePendingLoad]) const showSkeleton = props.isLoadingMessages && props.rawMessagesCount === 0 && props.pendingCount === 0 + const handleShareTurn = useCallback(( + messageTarget: HTMLElement | string | null, + clientY?: number, + fallbackSnapshot?: ShareTurnSnapshot + ) => { + const content = contentRef.current + if (!content) return + + let target: HTMLElement | null = typeof messageTarget === 'string' + ? document.getElementById(messageTarget) + : messageTarget + if (!(target instanceof HTMLElement) || !content.contains(target)) { + target = findNearestMessageElement(content, clientY) + } + if (!(target instanceof HTMLElement) || !content.contains(target)) { + setShareTurn({ + id: ++shareTurnIdRef.current, + snapshots: fallbackSnapshot ? [fallbackSnapshot] : [], + title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8), + subtitle: [props.metadata?.flavor, props.metadata?.host].filter(Boolean).join(' · ') || props.sessionId + }) + return + } + + let start: Element | null = target + while (start?.previousElementSibling && start.getAttribute('data-hapi-message-role') !== 'user') { + start = start.previousElementSibling + } + if (!start || start.getAttribute('data-hapi-message-role') !== 'user') { + start = target + } + + const snapshots: ShareTurnSnapshot[] = [] + let current: Element | null = start + while (current instanceof HTMLElement) { + if (current !== start && current.getAttribute('data-hapi-message-role') === 'user') { + break + } + const role = current.getAttribute('data-hapi-message-role') + if (role === 'user' || role === 'assistant') { + snapshots.push({ + html: current.outerHTML, + text: (current.innerText || current.textContent || '').trim() + }) + } + current = current.nextElementSibling + } + + if (snapshots.length === 0 && target instanceof HTMLElement) { + snapshots.push({ + html: target.outerHTML, + text: (target.innerText || target.textContent || '').trim() + }) + } + if (snapshots.length === 0 && fallbackSnapshot) { + snapshots.push(fallbackSnapshot) + } + const completeSnapshots = prependMissingUserSnapshot(snapshots, fallbackSnapshot) + + setShareTurn({ + id: ++shareTurnIdRef.current, + snapshots: completeSnapshots, + title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8), + subtitle: [props.metadata?.flavor, props.metadata?.host].filter(Boolean).join(' · ') || props.sessionId + }) + }, [props.metadata, props.sessionId]) return ( ) : null} + setShareTurn(null)} + /> ) diff --git a/web/src/components/AssistantChat/ShareTurnDialog.tsx b/web/src/components/AssistantChat/ShareTurnDialog.tsx new file mode 100644 index 00000000..4ce684d9 --- /dev/null +++ b/web/src/components/AssistantChat/ShareTurnDialog.tsx @@ -0,0 +1,594 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' + +type ShareTurnDialogProps = { + isOpen: boolean + title: string + subtitle: string + sourceSnapshots: Array<{ + html: string + text: string + role?: 'user' | 'assistant' + }> + onClose: () => void +} + +type ShareTurnSnapshot = ShareTurnDialogProps['sourceSnapshots'][number] + +const SHARE_EXPORT_WIDTH = 960 +const SHARE_EXPORT_SCALE = 2 +const MAX_EXPORT_PIXELS = 24_000_000 + +function nextFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) +} + +function stripCaptureOnlyControls(root: HTMLElement): void { + 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, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) { + element.remove() + } + for (const anchor of Array.from(root.querySelectorAll('a'))) { + anchor.removeAttribute('href') + anchor.removeAttribute('target') + anchor.removeAttribute('rel') + } + for (const element of Array.from(root.querySelectorAll('[role="button"], [contenteditable="true"]'))) { + if (element.tagName.toLowerCase() !== 'a') { + element.removeAttribute('role') + } + element.removeAttribute('contenteditable') + element.removeAttribute('tabindex') + } +} + +function formatShareTimestamp(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, '0') + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()) + ].join('') +} + +function getShareFileName(): string { + return `HAPI-${formatShareTimestamp()}.png` +} + +function prepareExportElement(element: HTMLElement): HTMLElement { + const captureElement = element.cloneNode(true) + if (!(captureElement instanceof HTMLElement)) { + throw new Error('Failed to prepare shared image') + } + + const elementStyle = getComputedStyle(element) + const backgroundColor = elementStyle.backgroundColor && elementStyle.backgroundColor !== 'rgba(0, 0, 0, 0)' + ? elementStyle.backgroundColor + : (getComputedStyle(document.documentElement).getPropertyValue('--app-bg').trim() || '#ffffff') + const color = elementStyle.color || getComputedStyle(document.documentElement).getPropertyValue('--app-fg').trim() || '#111827' + + captureElement.classList.add('hapi-share-export-root') + captureElement.style.cssText += [ + 'position:absolute', + 'left:0', + 'top:0', + 'z-index:-1', + `width:${SHARE_EXPORT_WIDTH}px`, + `max-width:${SHARE_EXPORT_WIDTH}px`, + 'box-sizing:border-box', + 'transform:none', + 'pointer-events:none', + 'overflow:visible', + '-webkit-text-size-adjust:100%', + 'text-size-adjust:100%', + 'font-size:14px', + 'line-height:1.6', + `background:${backgroundColor}`, + `color:${color}` + ].join(';') + + const style = document.createElement('style') + style.textContent = ` + .hapi-share-export-root { + background: ${backgroundColor} !important; + color: ${color} !important; + box-sizing: border-box !important; + -webkit-text-size-adjust: 100% !important; + text-size-adjust: 100% !important; + font-size: 14px !important; + line-height: 1.6 !important; + } + .hapi-share-export-root [data-hapi-share-exclude="true"], + .hapi-share-export-root .aui-reasoning-group, + .hapi-share-export-root button[aria-expanded], + .hapi-share-export-root button[title="Copy"] { + display: none !important; + } + .hapi-share-export-root img, + .hapi-share-export-root video, + .hapi-share-export-root canvas, + .hapi-share-export-root svg { + max-width: 100% !important; + height: auto !important; + object-fit: contain !important; + } + .hapi-share-export-root button:has(img), + .hapi-share-export-root img { + max-height: 16rem !important; + } + .hapi-share-export-root button:has(img) { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: auto !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: 100% !important; + overflow: hidden !important; + border: 0 !important; + padding: 0 !important; + background: transparent !important; + color: transparent !important; + vertical-align: top !important; + } + .hapi-share-export-root button:has(img) img { + display: block !important; + max-width: min(100%, 18rem) !important; + max-height: 12rem !important; + border-radius: 0.75rem !important; + } + .hapi-share-export-root button:has(img) > :not(img) { + display: none !important; + } + .hapi-share-export-root .sr-only { + display: none !important; + } + .hapi-share-export-root pre, + .hapi-share-export-root code { + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; + } + ` + captureElement.prepend(style) + return captureElement +} + +function waitForFrameLoad(frame: HTMLIFrameElement): Promise { + return new Promise((resolve) => { + if (frame.contentDocument?.readyState === 'complete') { + resolve() + return + } + frame.addEventListener('load', () => resolve(), { once: true }) + window.setTimeout(() => resolve(), 1000) + }) +} + +function waitForStyleSheets(document: Document): Promise { + const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')) + return Promise.all(links.map((link) => { + if (link.sheet) return Promise.resolve() + return new Promise((resolve) => { + link.addEventListener('load', () => resolve(), { once: true }) + link.addEventListener('error', () => resolve(), { once: true }) + window.setTimeout(() => resolve(), 2500) + }) + })).then(() => undefined) +} + +function appendTextFallback(target: DocumentFragment | HTMLElement, snapshot: ShareTurnSnapshot): void { + 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) +} + +function resolveCssUrls(cssText: string, styleSheetUrl: string | null): string { + if (!styleSheetUrl) return cssText + return cssText.replace(/url\(\s*(['"]?)(.*?)\1\s*\)/gi, (match, quote: string, rawUrl: string) => { + const url = rawUrl.trim() + if (!url || /^(?:data:|blob:|#|[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return match + try { + return `url(${quote}${new URL(url, styleSheetUrl).href}${quote})` + } catch { + return match + } + }) +} + +function copyLoadedStyleSheets(source: Document, target: Document): void { + const copiedOwners = new Set() + const styleSheets = [ + ...Array.from(source.styleSheets), + ...Array.from(source.adoptedStyleSheets ?? []) + ] + + for (const sheet of styleSheets) { + if (sheet.disabled) continue + try { + const cssText = resolveCssUrls( + Array.from(sheet.cssRules, (rule) => rule.cssText).join('\n'), + sheet.href + ) + if (!cssText) continue + const style = target.createElement('style') + style.dataset.hapiShareStyles = 'inlined' + style.textContent = cssText + target.head.appendChild(style) + if (sheet.ownerNode) copiedOwners.add(sheet.ownerNode) + } catch { + // Cross-origin sheets cannot expose cssRules. Clone their owner as a + // network fallback; same-origin app CSS always takes the inline path. + } + } + + for (const node of Array.from(source.head.querySelectorAll('link[rel="stylesheet"], style'))) { + if (copiedOwners.has(node)) continue + const clone = node.cloneNode(true) + if (clone instanceof HTMLLinkElement && node instanceof HTMLLinkElement) { + clone.href = node.href + clone.crossOrigin = node.crossOrigin + } + target.head.appendChild(clone) + } +} + +async function waitForImages(root: HTMLElement): Promise { + const images = Array.from(root.querySelectorAll('img')) + await Promise.all(images.map(async (image) => { + if (image.complete && image.naturalWidth > 0) return + try { + if ('decode' in image) { + await image.decode() + return + } + } catch { + // Fall through to load/error listeners. + } + await new Promise((resolve) => { + image.addEventListener('load', () => resolve(), { once: true }) + image.addEventListener('error', () => resolve(), { once: true }) + window.setTimeout(() => resolve(), 15000) + }) + })) +} + +async function waitForExportReady(root: HTMLElement): Promise { + const ownerDocument = root.ownerDocument + await waitForStyleSheets(ownerDocument) + if (ownerDocument.fonts) { + await ownerDocument.fonts.ready.catch(() => undefined) + } + await waitForImages(root) + await nextFrame() + await nextFrame() +} + +async function elementToPngBlob(element: HTMLElement): Promise { + const { default: html2canvas } = await import('html2canvas-pro') + const frame = document.createElement('iframe') + frame.setAttribute('aria-hidden', 'true') + frame.style.cssText = [ + 'position:fixed', + 'left:-10000px', + 'top:0', + `width:${SHARE_EXPORT_WIDTH}px`, + 'height:1000px', + 'border:0', + 'opacity:0', + 'pointer-events:none' + ].join(';') + + document.body.appendChild(frame) + const frameDocument = frame.contentDocument + if (!frameDocument) { + frame.remove() + throw new Error('Failed to prepare shared image') + } + frameDocument.open() + frameDocument.write('') + frameDocument.close() + await waitForFrameLoad(frame) + frameDocument.documentElement.className = document.documentElement.className + frameDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '') + for (const attr of Array.from(document.documentElement.attributes)) { + if (attr.name === 'class' || attr.name === 'style') continue + frameDocument.documentElement.setAttribute(attr.name, attr.value) + } + frameDocument.body.className = document.body.className + frameDocument.body.setAttribute('style', [ + document.body.getAttribute('style') ?? '', + 'margin:0', + `width:${SHARE_EXPORT_WIDTH}px`, + 'min-height:1000px', + 'overflow:visible', + 'background:transparent' + ].join(';')) + + const base = frameDocument.createElement('base') + base.href = document.baseURI + frameDocument.head.appendChild(base) + + copyLoadedStyleSheets(document, frameDocument) + + const captureElement = prepareExportElement(element) + captureElement.style.position = 'static' + captureElement.style.left = 'auto' + captureElement.style.top = 'auto' + captureElement.style.zIndex = 'auto' + frameDocument.body.appendChild(captureElement) + let canvas: HTMLCanvasElement + try { + await waitForExportReady(captureElement) + const captureWidth = captureElement.scrollWidth + const captureHeight = captureElement.scrollHeight + const maxScale = Math.sqrt(MAX_EXPORT_PIXELS / Math.max(1, captureWidth * captureHeight)) + const scale = Math.min(SHARE_EXPORT_SCALE, maxScale) + canvas = await html2canvas(captureElement, { + backgroundColor: getComputedStyle(captureElement).backgroundColor || '#ffffff', + foreignObjectRendering: false, + imageTimeout: 15000, + logging: false, + removeContainer: true, + scale, + useCORS: true, + width: captureWidth, + height: captureHeight, + windowWidth: Math.max(SHARE_EXPORT_WIDTH, captureWidth), + windowHeight: Math.max(document.documentElement.clientHeight, captureHeight), + }) + } finally { + frame.remove() + } + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + if (!blob) throw new Error('Failed to encode shared image') + return blob +} + +async function copyImageBlob(blob: Blob): Promise { + const ClipboardItemCtor = window.ClipboardItem + if (!navigator.clipboard?.write || !ClipboardItemCtor) { + throw new Error('Image clipboard is not supported in this browser') + } + await navigator.clipboard.write([ + new ClipboardItemCtor({ [blob.type]: blob }) + ]) +} + +function downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = fileName + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +async function shareImageBlob(blob: Blob): Promise { + const file = new File([blob], getShareFileName(), { type: blob.type }) + if (!navigator.share) { + throw new Error('Image sharing is not supported in this browser') + } + if (!navigator.canShare || navigator.canShare({ files: [file] })) { + await navigator.share({ files: [file], title: 'HAPI shared turn' }) + return + } + throw new Error('File sharing is not supported in this browser') +} + +export function ShareTurnDialog(props: ShareTurnDialogProps) { + const { t } = useTranslation() + const captureRef = useRef(null) + const bodyRef = useRef(null) + const [busy, setBusy] = useState<'copy' | 'download' | 'share' | null>(null) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + const [restoreTick, setRestoreTick] = useState(0) + const [ready, setReady] = useState(false) + const [preparedBlob, setPreparedBlob] = useState(null) + const showNativeShareButton = true + + useLayoutEffect(() => { + setReady(false) + if (!props.isOpen) return undefined + const body = bodyRef.current + if (!body) { + const frame = window.requestAnimationFrame(() => { + setRestoreTick((tick) => tick + 1) + }) + return () => window.cancelAnimationFrame(frame) + } + body.replaceChildren() + + const fragment = document.createDocumentFragment() + let textLength = 0 + for (const snapshot of props.sourceSnapshots) { + const isTextOnlySnapshot = snapshot.html.trim().length === 0 + const template = document.createElement('template') + template.innerHTML = snapshot.html + textLength += snapshot.text.length + let appendedSnapshot = false + for (const node of Array.from(template.content.children)) { + if (!(node instanceof HTMLElement)) continue + node.removeAttribute('id') + node.classList.remove('scroll-mt-4') + if (node.matches('[data-hapi-share-exclude="true"]')) continue + stripCaptureOnlyControls(node) + if ((node.innerText || node.textContent || '').trim().length === 0 && node.querySelector('img, video, canvas, svg') == null) continue + fragment.appendChild(node) + appendedSnapshot = true + } + if (!appendedSnapshot && isTextOnlySnapshot) appendTextFallback(fragment, snapshot) + } + body.replaceChildren(fragment) + + 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) + } + } + setReady(true) + + setError(null) + setCopied(false) + return undefined + }, [props.isOpen, props.sourceSnapshots, restoreTick]) + + useEffect(() => { + const capture = captureRef.current + if (!props.isOpen || !ready || !capture) { + setPreparedBlob(null) + return undefined + } + + let cancelled = false + setPreparedBlob(null) + void elementToPngBlob(capture).then((blob) => { + if (!cancelled) setPreparedBlob(blob) + }).catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to create image') + }) + return () => { + cancelled = true + } + }, [props.isOpen, props.sourceSnapshots, ready, restoreTick]) + + const runBlobAction = ( + blob: Blob, + action: (prepared: Blob) => Promise | void, + mode: 'copy' | 'download' | 'share' + ) => { + // Invoke the action before yielding so navigator.share() stays in the + // direct click activation window. PNG rendering happens eagerly above. + setBusy(mode) + setError(null) + let result: Promise | void + try { + result = action(blob) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create image') + setBusy(null) + return + } + void Promise.resolve(result).then(() => { + if (mode === 'copy') setCopied(true) + }).catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to create image') + }).finally(() => { + setBusy(null) + }) + } + + const withPng = async (action: (blob: Blob) => Promise | void, mode: 'copy' | 'download') => { + if (preparedBlob) { + runBlobAction(preparedBlob, action, mode) + return + } + const capture = captureRef.current + if (!capture || !ready) return + setBusy(mode) + setError(null) + try { + const blob = await elementToPngBlob(capture) + await action(blob) + if (mode === 'copy') { + setCopied(true) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create image') + } finally { + setBusy(null) + } + } + + return ( + { if (!open) props.onClose() }}> + + + {t('shareTurn.title')} + + +
+
+
+
+
HAPI
+
{props.title}
+
{props.subtitle}
+
+
+ {t('shareTurn.badge')} +
+
+
+
+ {t('shareTurn.generated')} +
+
+
+ + {error ? ( +
{error}
+ ) : null} + +
+ + + {showNativeShareButton ? ( + + ) : null} + +
+ +
+ ) +} diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index 4cf9fb81..a6de7eca 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -12,6 +12,11 @@ export type HappyChatContextValue = { disabled: boolean onRefresh: () => void onRetryMessage?: (localId: string) => void + onShareTurn?: ( + messageElement: HTMLElement | string | null, + clientY?: number, + fallbackSnapshot?: { html: string; text: string; role?: 'user' | 'assistant' } + ) => void hasMoreMessages: boolean isLoadingMoreMessages: boolean loadOlderMessagesPreservingScroll: () => Promise diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 4391f9b9..b8e52f60 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -22,6 +22,7 @@ const MESSAGE_PART_COMPONENTS = { export function HappyAssistantMessage() { const messageId = useAssistantState(({ message }) => message.id) + const elementId = getConversationMessageAnchorId(messageId) const isCliOutput = useAssistantState(({ message }) => { const custom = message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' @@ -58,7 +59,8 @@ export function HappyAssistantMessage() { return ( {isCliOutput @@ -66,7 +68,7 @@ export function HappyAssistantMessage() { : codexReview ? : } - + ) } diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index 9dc7f2d3..d3960b6b 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -5,14 +5,16 @@ import { useTranslation } from '@/lib/use-translation' import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps } from './MessageMetadata' import { MessageTimestamp } from './MessageTimestamp' import { cn } from '@/lib/utils' +import { ShareTurnButton } from './ShareTurnButton' type MessageActionsProps = { align: 'start' | 'end' copyText?: string metadata?: Omit + messageElementId?: string } -export function MessageActions({ align, copyText, metadata }: MessageActionsProps) { +export function MessageActions({ align, copyText, metadata, messageElementId }: MessageActionsProps) { const { copied, copy } = useCopyToClipboard() const { t } = useTranslation() const canCopy = Boolean(copyText) @@ -38,6 +40,13 @@ export function MessageActions({ align, copyText, metadata }: MessageActionsProp ) : null} {hasMetadata && metadata ? : null} + {messageElementId ? ( + + ) : null} {align === 'start' ? : null} ) diff --git a/web/src/components/AssistantChat/messages/ShareTurnButton.tsx b/web/src/components/AssistantChat/messages/ShareTurnButton.tsx new file mode 100644 index 00000000..513f7386 --- /dev/null +++ b/web/src/components/AssistantChat/messages/ShareTurnButton.tsx @@ -0,0 +1,75 @@ +import { useAssistantState } from '@assistant-ui/react' +import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' +import { useTranslation } from '@/lib/use-translation' + +function ShareIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + +export function ShareTurnButton(props: { + messageElementId: string + className?: string + fallbackText?: string +}) { + const ctx = useOptionalHappyChatContext() + const { t } = useTranslation() + const fallbackUserText = useAssistantState(({ message, thread }) => { + const currentIndex = thread.messages.findIndex((candidate) => candidate.id === message.id) + if (currentIndex < 0) return '' + for (let index = currentIndex; index >= 0; index -= 1) { + const candidate = thread.messages[index] + if (candidate?.role !== 'user') continue + return candidate.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n') + .trim() + } + return '' + }) + const onShareTurn = ctx?.onShareTurn + if (!onShareTurn) return null + const fallbackText = props.fallbackText?.trim() ?? '' + + return ( + + ) +} diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 871b463b..3aa8e443 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -199,7 +199,7 @@ function HappyNestedBlockList(props: { const taskChildren = isTask ? splitTaskChildren(block) : null return ( -
+
+
+
@@ -310,7 +310,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { const taskChildren = isTask ? splitTaskChildren(block) : null return ( -
+
message.role) const messageId = useAssistantState(({ message }) => message.id) + const elementId = getConversationMessageAnchorId(messageId) const text = useAssistantState(({ message }) => { if (message.role !== 'user') return '' return message.content.find((part) => part.type === 'text')?.text ?? '' @@ -48,12 +49,13 @@ export function HappyUserMessage() { if (isCliOutput) { return (
- +
) @@ -64,7 +66,8 @@ export function HappyUserMessage() { return (
@@ -80,7 +83,7 @@ export function HappyUserMessage() { )}
- + ) } diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx index 546d1e2e..04c78d54 100644 --- a/web/src/components/assistant-ui/reasoning.tsx +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -71,7 +71,7 @@ export const ReasoningGroup: FC = ({ children }) => { }, [isStreaming]) return ( -
+