diff --git a/packages/components/src/sender/index.vue b/packages/components/src/sender/index.vue
index a18f4bc85d..4c682e1456 100644
--- a/packages/components/src/sender/index.vue
+++ b/packages/components/src/sender/index.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ file.name)
+ .join(',')
+ "
+ @sender-paste="senderPasteCount++"
+ >
+
+
+
+
+ Attachment
+ {{ label }}
+
+ {{ label }}
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
diff --git a/packages/test/component/chat/ChatEnhancements.spec.ts b/packages/test/component/chat/ChatEnhancements.spec.ts
new file mode 100644
index 0000000000..ae630f7504
--- /dev/null
+++ b/packages/test/component/chat/ChatEnhancements.spec.ts
@@ -0,0 +1,142 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+import ChatEnhancements from './ChatEnhancements.fixture.vue'
+
+for (const path of ['custom', 'center', 'footer'] as const) {
+ for (const mode of ['single', 'multiple'] as const) {
+ test(`${path}/${mode}: updates slots without replacing the editor`, async ({ mount, page }) => {
+ if (path === 'footer') await page.setViewportSize({ width: 600, height: 800 })
+ const component = await mount(ChatEnhancements, { props: { path, mode } })
+ const editor = component.locator('.ProseMirror')
+ await expect(editor).toHaveCount(1)
+ await expect(component.getByTestId('extension-creates')).toHaveText('1')
+ await editor.fill('draft')
+ await editor.evaluate((el) => {
+ el.setAttribute('data-editor-identity', 'original')
+ })
+ await component.getByTestId('add').click()
+ await expect(component.getByTestId('tag')).toHaveText('Auto')
+ if (mode === 'multiple') {
+ await expect(component.getByTestId('footer-tag')).toHaveText('Auto')
+ await expect(component.getByTestId('right-tag')).toHaveText('Auto')
+ await expect(component.getByTestId('footer-insert')).toBeEnabled()
+ await expect(component.getByTestId('footer-scope-state')).toBeEnabled()
+ }
+ await component.getByTestId('update').click()
+ await expect(component.getByTestId('tag')).toHaveText('Updated')
+ if (mode === 'multiple') {
+ await expect(component.getByTestId('footer-tag')).toHaveText('Updated')
+ await expect(component.getByTestId('right-tag')).toHaveText('Updated')
+ }
+ await component.getByTestId('visible').click()
+ await expect(component.locator('.tr-sender-header')).toHaveCount(0)
+ await component.getByTestId('visible').click()
+ await expect(component.locator('.tr-sender-header')).toHaveCount(1)
+ await component.getByTestId('add').click()
+ await expect(component.locator('.tr-sender-header')).toHaveCount(0)
+ await expect(component.getByTestId('footer-tag')).toHaveCount(0)
+ await expect(component.getByTestId('right-tag')).toHaveCount(0)
+ await expect(editor).toHaveAttribute('data-editor-identity', 'original')
+ await expect(editor).toHaveText('draft')
+ await expect(component.getByTestId('extension-creates')).toHaveText('1')
+ await expect(component.getByTestId('extension-destroys')).toHaveText('0')
+ })
+ }
+
+ for (const entry of ['ui', 'runtime'] as const) {
+ test(`${entry}/${path}: public input actions preserve attachments and do not send`, async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { entry, path } })
+ await component.getByTestId('fill').click()
+ await expect(component.locator('.ProseMirror')).toHaveText('host prompt')
+ await component.getByTestId('clear').click()
+ await expect(component.locator('.ProseMirror')).toHaveText('')
+ await expect(component.getByTestId('attachment')).toBeVisible()
+ await component.getByTestId('focus').click()
+ await expect(component.getByTestId('focus-result')).toHaveText('true')
+ await expect(component.locator('.ProseMirror')).toBeFocused()
+ await expect(component.getByTestId('sends')).toHaveText('0')
+ })
+ }
+
+ test(`${path}: slot updates preserve an active composition`, async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { path } })
+ const editor = component.locator('.ProseMirror')
+ await editor.focus()
+ await editor.dispatchEvent('compositionstart', { data: '中' })
+ await component.getByTestId('add').dispatchEvent('click')
+ await expect(component.getByTestId('tag')).toHaveText('Auto')
+ await component.getByTestId('composition').dispatchEvent('click')
+ await expect(component.getByTestId('composing')).toHaveText('true')
+ await expect(editor).toBeFocused()
+ await expect(component.getByTestId('extension-creates')).toHaveText('1')
+ await editor.dispatchEvent('compositionend', { data: '中' })
+ await expect(component.getByTestId('sends')).toHaveText('0')
+ })
+}
+
+test('controlled empty state governs Header, welcome and composer placement together', async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { path: 'custom', controlledEmpty: false } })
+ await expect(component.getByTestId('empty')).toHaveText('false')
+ await expect(component.getByTestId('custom-welcome')).toHaveCount(0)
+ await expect(component.locator('.chat-panel-content--footer .ProseMirror')).toHaveCount(1)
+ await component.update({ props: { path: 'custom', controlledEmpty: true } })
+ await expect(component.getByTestId('empty')).toHaveText('true')
+ await expect(component.getByTestId('custom-welcome').locator('.ProseMirror')).toHaveCount(1)
+ await expect(component.locator('.chat-panel-content--footer')).toHaveCount(0)
+})
+
+test('disabled input reports unsuccessful focus', async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { disabled: true } })
+ await component.getByTestId('focus').click()
+ await expect(component.getByTestId('focus-result')).toHaveText('false')
+})
+
+test('missing input reports unsuccessful focus', async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { hideSender: true } })
+ await component.getByTestId('focus').click()
+ await expect(component.getByTestId('focus-result')).toHaveText('false')
+})
+
+test('Sender drag and paste events are exposed by TrChat', async ({ mount }) => {
+ const component = await mount(ChatEnhancements, { props: { entry: 'runtime' } })
+ const editor = component.locator('.ProseMirror')
+ await editor.evaluate((element) => {
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(new File(['image'], 'demo.png', { type: 'image/png' }))
+ element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, dataTransfer }))
+ element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer }))
+ element.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }))
+ element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: new DataTransfer() }))
+ })
+ await expect(component.getByTestId('sender-drag-target')).toContainText('tr-sender')
+ await expect(component.getByTestId('sender-drag-over-prevented')).toHaveText('true')
+ await expect(component.getByTestId('sender-drop-files')).toHaveText('demo.png')
+ await expect(component.getByTestId('sender-paste-count')).toHaveText('1')
+})
+
+test('hidden system messages keep the default welcome and can be overridden by an existing empty conversation', async ({
+ mount,
+}) => {
+ const component = await mount(ChatEnhancements, { props: { path: 'custom', systemOnly: true } })
+ await expect(component.getByTestId('empty')).toHaveText('true')
+ await expect(component.getByTestId('custom-welcome')).toBeVisible()
+ await component.update({ props: { path: 'custom', systemOnly: true, controlledEmpty: false } })
+ await expect(component.getByTestId('empty')).toHaveText('false')
+ await expect(component.getByTestId('custom-welcome')).toHaveCount(0)
+ await expect(component.locator('.tr-bubble[data-role="system"]')).not.toBeVisible()
+ await expect(component.locator('.chat-panel-content--footer .ProseMirror')).toHaveCount(1)
+})
+
+test('inherited CSS variables override layout defaults', async ({ mount }) => {
+ const component = await mount(ChatEnhancements)
+ await expect(component.locator('.chat-panel-content--header')).toHaveCSS('padding', '3px 5px 7px')
+ await expect(component.locator('.chat-panel-content--main')).toHaveCSS('padding', '9px')
+ await expect(component.locator('.chat-panel-content--footer')).toHaveCSS('padding', '11px 13px 15px')
+ await expect(component.locator('.chat-panel-content--footer')).toHaveCSS('max-width', '700px')
+})
+
+test('layout.showScrollToBottom controls the built-in button and defaults to visible', async ({ mount }) => {
+ const component = await mount(ChatEnhancements)
+ await expect(component.locator('.chat-scroll-actions')).toHaveCount(1)
+ await component.update({ props: { showScrollToBottom: false } })
+ await expect(component.locator('.chat-scroll-actions')).toHaveCount(0)
+})
diff --git a/packages/test/component/chat/ChatRenderContext.fixture.vue b/packages/test/component/chat/ChatRenderContext.fixture.vue
new file mode 100644
index 0000000000..060eeef2d5
--- /dev/null
+++ b/packages/test/component/chat/ChatRenderContext.fixture.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Custom main
+ Attachment preview
+
+
+
+
+
diff --git a/packages/test/component/chat/ChatRenderContext.spec.ts b/packages/test/component/chat/ChatRenderContext.spec.ts
new file mode 100644
index 0000000000..e5fee6279d
--- /dev/null
+++ b/packages/test/component/chat/ChatRenderContext.spec.ts
@@ -0,0 +1,294 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+import ChatRenderContext from './ChatRenderContext.fixture.vue'
+
+test('Renderer context preserves identity through copies and exposes the real scroll root', async ({ mount }) => {
+ const component = await mount(ChatRenderContext)
+ const probes = component.locator('.context-probe')
+ const last = probes.last()
+ await expect(last).toHaveAttribute('data-state', 'streaming')
+ await expect(last).toHaveAttribute('data-root', 'true')
+ await expect(probes.first()).toHaveAttribute('data-index', '0')
+ await expect(probes.nth(1)).toHaveAttribute('data-index', '1')
+ await expect(probes.nth(2)).toHaveAttribute('data-state', 'finished')
+ const key = await last.getAttribute('data-key')
+ const instance = await last.getAttribute('data-instance')
+ await component.getByTestId('grow').click()
+ await expect(last).toHaveAttribute('data-key', key!)
+ await expect(last).toHaveAttribute('data-instance', instance!)
+ await component.getByTestId('finish').click()
+ await expect(last).toHaveAttribute('data-state', 'finished')
+ await component.getByTestId('thread').click()
+ await expect(last).toHaveAttribute('data-thread', 'thread-b')
+ await expect(last).not.toHaveAttribute('data-key', key!)
+})
+
+test('Renderer context infers automatic streaming only from a tracked request', async ({ mount }) => {
+ const component = await mount(ChatRenderContext)
+ await component.getByTestId('auto-start').click()
+ const automatic = component.locator('.context-probe').filter({ hasText: 'first' })
+ await expect(automatic).toHaveAttribute('data-state', 'streaming')
+ const instance = await automatic.getAttribute('data-instance')
+ await component.getByTestId('auto-grow').click()
+ await expect(automatic).toContainText('first update')
+ await expect(automatic).toHaveAttribute('data-instance', instance!)
+ await expect(automatic).toHaveAttribute('data-state', 'streaming')
+ await component.getByTestId('auto-table').click()
+ await expect(automatic).toContainText('column')
+ await expect(automatic).toHaveAttribute('data-state', 'streaming')
+ await component.getByTestId('auto-finish').click()
+ await expect(automatic).toHaveAttribute('data-state', 'finished')
+ await component.getByTestId('auto-switch').click()
+ await expect(component.locator('.context-probe').filter({ hasText: 'other' })).toHaveAttribute(
+ 'data-state',
+ 'unknown',
+ )
+})
+
+test('Anchor uses display questions, targets only the first split and retains caller attributes', async ({
+ mount,
+ page,
+}) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext)
+ const items = component.locator('.tr-anchor__item')
+ await expect(items).toHaveCount(6)
+ await expect(items.first()).toContainText('Displayed question 0')
+ const userBubbles = component.locator('.tr-bubble[data-role="user"]')
+ await expect(userBubbles.first().locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(1)
+ await expect(userBubbles.first().locator('[data-business="kept"]')).toHaveCount(2)
+ await expect(userBubbles.first().locator('.business-box')).toHaveCount(2)
+ await expect(userBubbles.first().locator('.business-box').first()).toHaveCSS('border-top-width', '3px')
+ await expect(userBubbles.first().locator('[data-business-content="kept"]')).toHaveCount(2)
+})
+
+test('Anchor reuses caller target IDs and preserves callbacks for every split', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext, { props: { callerAnchors: true } })
+ const user = component.locator('.tr-bubble[data-role="user"]').first()
+ await expect(user.locator('[data-anchor-id="business-user-0-0"]')).toHaveCount(1)
+ await expect(user.locator('[data-anchor-id="business-user-0-1"]')).toHaveCount(1)
+ await expect(component.locator('.tr-anchor__item').first()).toHaveAttribute('data-item-id', 'business-user-0-0')
+ await expect(user.locator('[data-business-index="0"]')).toHaveCount(1)
+ await expect(user.locator('[data-business-index="1"]')).toHaveCount(1)
+ await component.locator('.tr-anchor__item').first().click()
+ await expect(user.locator('[data-anchor-id="business-user-0-0"]')).toHaveClass(/tr-chat-anchor-target-active/)
+})
+
+test('Anchor gives every message in a consecutive user group its own target', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext, { props: { consecutiveUsers: true } })
+ const items = component.locator('.tr-anchor__item')
+ const targets = component.locator('.tr-chat-anchor-target[data-anchor-id]')
+
+ await expect(items).toHaveCount(2)
+ await expect(targets).toHaveCount(2)
+ expect(await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-item-id')))).toEqual(
+ await targets.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-anchor-id'))),
+ )
+})
+
+test('Anchor uses actual custom group results for targets', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext, { props: { consecutiveUsers: true, customConsecutiveUsers: true } })
+ const items = component.locator('.tr-anchor__item')
+ const targets = component.locator('.tr-chat-anchor-target[data-anchor-id]')
+ await expect(items).toHaveCount(2)
+ await expect(targets).toHaveCount(2)
+ await items.nth(1).click()
+ await expect(targets.nth(1)).toHaveClass(/tr-chat-anchor-target-active/)
+})
+
+test('Anchor navigates every user message grouped by an assistant divider', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext, { props: { consecutiveUsers: true, assistantDivider: true } })
+ const items = component.locator('.tr-anchor__item')
+ const targets = component.locator('.tr-chat-anchor-target[data-anchor-id]')
+ const scrollRoot = component.locator('.chat-main-scroll-host')
+
+ await expect(items).toHaveCount(2)
+ await expect(targets).toHaveCount(2)
+ expect(await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-item-id')))).toEqual(
+ await targets.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-anchor-id'))),
+ )
+
+ await scrollRoot.evaluate((element) => {
+ element.scrollTop = element.scrollHeight
+ })
+ const initialScrollTop = await scrollRoot.evaluate((element) => element.scrollTop)
+ expect(initialScrollTop).toBeGreaterThan(0)
+
+ await items.nth(1).click()
+ await expect(targets.nth(1)).toHaveClass(/tr-chat-anchor-target-active/)
+ await expect.poll(() => scrollRoot.evaluate((element) => element.scrollTop)).toBeLessThan(initialScrollTop)
+})
+
+test('Anchor combines callback attributes and injects only the first split target', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext, { props: { callbackAttributes: true } })
+ const user = component.locator('.tr-bubble[data-role="user"]').first()
+ await expect(user.locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(1)
+ await expect(user.locator('.business-box')).toHaveCount(2)
+ await expect(user.locator('[data-business-index="1"]')).toHaveCount(1)
+})
+
+test('disabled Anchor leaves box attributes unchanged', async ({ mount }) => {
+ const component = await mount(ChatRenderContext, { props: { anchor: false } })
+ await expect(component.locator('.tr-chat-anchor-target')).toHaveCount(0)
+ await expect(component.locator('.tr-chat-anchor-target[data-anchor-id]')).toHaveCount(0)
+ await expect(component.locator('.business-box')).toHaveCount(18)
+})
+
+test('Anchor navigation pauses growth following and back-to-bottom resumes it', async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ const component = await mount(ChatRenderContext)
+ const root = component.locator('.chat-main-scroll-host')
+ const distance = () => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)
+ await expect.poll(distance).toBeLessThanOrEqual(2)
+ await component.locator('.tr-anchor__item').first().click()
+ await expect(component.locator('.tr-chat-anchor-target-active')).toHaveCount(1)
+ await expect.poll(() => root.evaluate((el) => el.scrollTop)).toBeLessThan(30)
+ await component.getByTestId('grow').click()
+ await expect.poll(distance).toBeGreaterThan(500)
+ await component.locator('.tr-chat-scroll-to-bottom').click()
+ await expect.poll(distance).toBeLessThanOrEqual(2)
+ await component.getByTestId('grow').click()
+ await expect.poll(distance).toBeLessThanOrEqual(2)
+})
+
+test('Anchor follows the viewport rule and stays absent with custom main or disabled configuration', async ({
+ mount,
+ page,
+}) => {
+ await page.setViewportSize({ width: 767, height: 800 })
+ const component = await mount(ChatRenderContext)
+ await expect(component.locator('.tr-anchor')).toHaveCount(0)
+ await page.setViewportSize({ width: 768, height: 800 })
+ await expect(component.locator('.tr-anchor')).toHaveCount(1)
+ await component.update({ props: { customMain: true } })
+ await expect(component.locator('.tr-anchor')).toHaveCount(0)
+ await component.update({ props: { customMain: false, anchor: false } })
+ await expect(component.locator('.tr-anchor')).toHaveCount(0)
+})
+
+for (const theme of ['light', 'dark'] as const) {
+ test(`Anchor uses the documented 1.8s flash in ${theme} mode`, async ({ mount, page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 })
+ await page.evaluate((mode) => document.documentElement.setAttribute('data-tr-color-mode', mode), theme)
+ const component = await mount(ChatRenderContext)
+ const target = component.locator('.tr-chat-anchor-target').first()
+ const restingColor = await target.evaluate((el) => getComputedStyle(el).backgroundColor)
+ await page.clock.install()
+ await component.locator('.tr-anchor__item').first().click()
+ await expect(target).toHaveClass(/tr-chat-anchor-target-active/)
+ await expect(target).toHaveCSS('animation-duration', '1.8s')
+ await expect(target).toHaveCSS('animation-timing-function', 'ease-out')
+ await expect(target).toHaveCSS('outline-style', 'none')
+ const frames = await target.evaluate((el) => el.getAnimations()[0].effect!.getKeyframes())
+ expect(frames.map((frame) => frame.offset)).toEqual([0, 0.25, 0.45, 0.65, 0.85, 1])
+ expect(frames[0].backgroundColor).not.toBe(restingColor)
+ expect(frames[2].backgroundColor).toBe(restingColor)
+ expect(frames[5].backgroundColor).toBe(restingColor)
+ await page.clock.fastForward(900)
+ await expect(target).toHaveClass(/tr-chat-anchor-target-active/)
+ await page.clock.fastForward(1000)
+ await expect(target).not.toHaveClass(/tr-chat-anchor-target-active/)
+ })
+}
+
+for (const layout of [
+ { name: 'desktop', viewport: 1200, props: { contentMaxWidth: 640 } },
+ { name: 'mobile', viewport: 600, props: { width: 420 } },
+ { name: 'CSS override', viewport: 1200, props: { contentMaxWidth: 640, contentPaddingInline: '37px' } },
+ { name: 'floating', viewport: 1200, props: { floating: true, width: 600 } },
+] as const) {
+ test(`${layout.name}: messages, Sender and back-to-bottom share their content edges`, async ({ mount, page }) => {
+ await page.setViewportSize({ width: layout.viewport, height: 800 })
+ const props = layout.props
+ const component = await mount(ChatRenderContext, { props: { anchor: false, ...props } })
+ const chat = page.locator('.tr-chat-ui')
+ const root = chat.locator('.chat-main-scroll-host')
+ const button = chat.locator('.tr-chat-scroll-to-bottom')
+ await expect
+ .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
+ .toBeLessThanOrEqual(2)
+ await root.evaluate((el) => {
+ el.scrollTop = 0
+ })
+ await expect(button).toBeVisible()
+
+ const aligned = async () => {
+ const list = (await chat.locator('.tr-bubble-list__content').boundingBox())!
+ const sender = (await chat.locator('.tr-sender').boundingBox())!
+ const action = (await button.boundingBox())!
+ const composer = (await chat.locator('.chat-footer').boundingBox())!
+ expect(Math.abs(list.x - sender.x)).toBeLessThanOrEqual(1)
+ expect(Math.abs(list.x + list.width - sender.x - sender.width)).toBeLessThanOrEqual(1)
+ expect(Math.abs(action.x + action.width - sender.x - sender.width)).toBeLessThanOrEqual(1)
+ expect(Math.abs(composer.y - action.y - action.height - 8)).toBeLessThanOrEqual(1)
+ }
+ await expect(aligned).toPass()
+
+ const composerHeight = (await chat.locator('.chat-footer').boundingBox())!.height
+ await component.getByTestId('sender-header').dispatchEvent('click')
+ await expect
+ .poll(async () => (await chat.locator('.chat-footer').boundingBox())!.height)
+ .toBeGreaterThan(composerHeight)
+ await expect(aligned).toPass()
+ if ('floating' in props) {
+ await component.update({ props: { anchor: false, ...props, width: 420 } })
+ await expect(chat).toHaveCSS('width', '420px')
+ await expect(aligned).toPass()
+ }
+ })
+}
+
+test('back-to-bottom remains available when the Sender is hidden', async ({ mount }) => {
+ const component = await mount(ChatRenderContext, { props: { hideSender: true, anchor: false } })
+ const root = component.locator('.chat-main-scroll-host')
+ await expect
+ .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
+ .toBeLessThanOrEqual(2)
+ await root.evaluate((el) => {
+ el.scrollTop = 0
+ })
+ await expect(component.locator('.chat-scroll-actions--main .tr-chat-scroll-to-bottom')).toBeVisible()
+ await component.locator('.tr-chat-scroll-to-bottom').click()
+ await expect
+ .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
+ .toBeLessThanOrEqual(2)
+})
+
+for (const hideSender of [false, true]) {
+ test(`CSS controls back-to-bottom alignment ${hideSender ? 'without' : 'with'} a Sender`, async ({ mount }) => {
+ const props = { anchor: false, hideSender, contentMaxWidth: 640, contentPaddingInline: '37px' }
+ const component = await mount(ChatRenderContext, { props })
+ const root = component.locator('.chat-main-scroll-host')
+ const button = component.locator('.tr-chat-scroll-to-bottom')
+ await expect
+ .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
+ .toBeLessThanOrEqual(2)
+ await root.evaluate((el) => {
+ el.scrollTop = 0
+ })
+ await expect(button).toBeVisible()
+
+ for (const scrollButtonAlign of ['flex-start', 'center', 'flex-end', undefined] as const) {
+ await component.update({ props: { ...props, scrollButtonAlign } })
+ await expect(async () => {
+ const content = (await component.locator('.tr-bubble-list__content').boundingBox())!
+ const action = (await button.boundingBox())!
+ const expectedX =
+ scrollButtonAlign === 'flex-start'
+ ? content.x
+ : scrollButtonAlign === 'center'
+ ? content.x + (content.width - action.width) / 2
+ : content.x + content.width - action.width
+ expect(Math.abs(action.x - expectedX)).toBeLessThanOrEqual(1)
+ }).toPass()
+ }
+ await button.click()
+ await expect
+ .poll(() => root.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
+ .toBeLessThanOrEqual(2)
+ })
+}
diff --git a/packages/test/component/chat/ChatToolbar.spec.ts b/packages/test/component/chat/ChatToolbar.spec.ts
index 873a16d5c7..dfd30e3974 100644
--- a/packages/test/component/chat/ChatToolbar.spec.ts
+++ b/packages/test/component/chat/ChatToolbar.spec.ts
@@ -108,6 +108,16 @@ for (const entry of ['ui', 'runtime'] as const) {
await expect(custom.locator('.tr-chat-toolbar-button__label')).toBeVisible()
})
+ test('keeps toolbar actions on one row in a narrow sender footer', async ({ mount }) => {
+ const component = await mount(ChatToolbarFixture, { props: { entry, preset: 'mixed', hostWidth: 360 } })
+ const toolbar = component.locator('.tr-chat-toolbar')
+ const rows = await toolbar
+ .locator(':scope > *')
+ .evaluateAll((items) => items.map((item) => Math.round(item.getBoundingClientRect().top)))
+
+ expect(new Set(rows).size).toBe(1)
+ })
+
test('transforms defaults before applying visibility, duplicate and capability rules', async ({ mount }) => {
const component = await mount(ChatToolbarFixture, { props: { entry, preset: 'transform' } })
const toolbar = component.locator('.tr-chat-toolbar')
diff --git a/packages/test/component/chat/RenderContextProbe.vue b/packages/test/component/chat/RenderContextProbe.vue
new file mode 100644
index 0000000000..8069f1568b
--- /dev/null
+++ b/packages/test/component/chat/RenderContextProbe.vue
@@ -0,0 +1,28 @@
+
+
+
+
+ {{ contentText }}
+
+
diff --git a/packages/test/component/sender/SenderHeader.fixture.vue b/packages/test/component/sender/SenderHeader.fixture.vue
new file mode 100644
index 0000000000..4ef91a01ed
--- /dev/null
+++ b/packages/test/component/sender/SenderHeader.fixture.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/packages/test/component/sender/SenderHeader.spec.ts b/packages/test/component/sender/SenderHeader.spec.ts
new file mode 100644
index 0000000000..8d32d5c08b
--- /dev/null
+++ b/packages/test/component/sender/SenderHeader.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from '@playwright/experimental-ct-vue'
+import SenderHeader from './SenderHeader.fixture.vue'
+
+for (const mode of ['single', 'multiple'] as const) {
+ test(`${mode}: omits structurally empty headers and preserves valid VNodes`, async ({ mount }) => {
+ const component = await mount(SenderHeader, { props: { mode } })
+ for (const name of ['comment', 'fragment', 'whitespace', 'empty']) {
+ await component.getByRole('button', { name, exact: true }).click()
+ await expect(component.locator('.tr-sender-header')).toHaveCount(0)
+ }
+ for (const name of ['zero', 'element', 'component']) {
+ await component.getByRole('button', { name, exact: true }).click()
+ await expect(component.locator('.tr-sender-header')).toHaveCount(1)
+ }
+ await component.getByRole('button', { name: 'zero', exact: true }).click()
+ await expect(component.locator('.tr-sender-header')).toHaveText('0')
+ })
+}