fix(web): keep the input card in place across view tabs
The composer seat is one node laid out against two different edges: in Chat it is a sticky child of the column's scroller and rides its content box, which a space-consuming scrollbar shortens, while a view declaring a composer overlay gets an absolutely positioned seat against the padding box, which no bar reduces. With the transcript scrolling the two tabs disagreed by the bar's width, so the centred input card moved 4px sideways on every switch — and the same 4px inside Chat when a growing transcript began to scroll. The column now reserves its scrollbar gutter unconditionally and states the overlay branch a scroll container on the same axes, so both states measure against the same width.
This commit is contained in:
388
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
388
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
// Web e2e scenario: the input card holds one horizontal position across the
|
||||
// Chat and Trajectory tabs.
|
||||
//
|
||||
// The composer seat is the same node in both tabs, but it measures itself
|
||||
// against a different edge in each (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
|
||||
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
|
||||
// scroller's content box — the box a space-consuming scrollbar shortens. A view
|
||||
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
|
||||
// Trajectory declares and which moves the column's own scrolling into the view)
|
||||
// gets an absolutely positioned seat instead, laid out against the padding box,
|
||||
// which the scrollbar never reduces.
|
||||
//
|
||||
// So the two tabs disagreed by exactly the bar's width for as long as the
|
||||
// transcript overflowed: the card jumped sideways on every tab switch, and
|
||||
// inside Chat alone at the moment a growing transcript started to scroll. The
|
||||
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
|
||||
// and states the overlay branch as a scroll container on the same axes, so both
|
||||
// edges are the same edge.
|
||||
//
|
||||
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
|
||||
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
|
||||
// can assert the declarations exist but not that the two states land in the same
|
||||
// place. What is asserted here is the user-visible fact — the card does not move
|
||||
// — measured as the distance between the two tabs' card rectangles.
|
||||
//
|
||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||
// container's bar consumes no layout width at all, so the two tabs agree before
|
||||
// this change as much as after it and every comparison below holds vacuously —
|
||||
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
|
||||
// and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||
// and a bar that occupies layout space is what the product actually draws.
|
||||
//
|
||||
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
|
||||
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
|
||||
// same two tabs through it, which is what keeps the equal rectangles above from
|
||||
// being explained by a tab switch that never reached the layout. It is the
|
||||
// reported symptom as a number: the card moves 4px, half the 8px band, on each
|
||||
// edge.
|
||||
//
|
||||
// Zero model calls: a seeded cold session renders from its log, and switching
|
||||
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
|
||||
/**
|
||||
* Committed golden of where the input card sits in each tab, at a wide viewport
|
||||
* (card at its width cap) and a narrow one (card shrinking with the column).
|
||||
*
|
||||
* Absolute coordinates are deliberately absent: they depend on the sidebar's
|
||||
* laid-out width and on font metrics, so committing them would produce a fixture
|
||||
* that has to be re-recorded per platform. What is recorded is the distance
|
||||
* between the two tabs' rectangles, which is zero when the reservation holds and
|
||||
* the bar's width when it does not — including under the control, so the golden
|
||||
* carries the difference the fix removes rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TAB_GEOMETRY',
|
||||
title: 'COMPOSER_TAB_GEOMETRY long session',
|
||||
turns: 24,
|
||||
})
|
||||
const SEED_ID = 'composer-tab-geometry-web-e2e'
|
||||
|
||||
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
|
||||
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
|
||||
const NARROW_VIEWPORT = { width: 900, height: 1000 }
|
||||
|
||||
/**
|
||||
* The pre-fix cascade, injected into the page: the reservation dropped and the
|
||||
* overlay branch back to a hidden box. `!important` beats the module rules
|
||||
* without a rebuild, and the id lets the control be lifted again in the same
|
||||
* session.
|
||||
*/
|
||||
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
|
||||
const CONTROL_CSS = `
|
||||
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
|
||||
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
|
||||
`
|
||||
|
||||
/** The column scroller and the input card as the browser lays them out, in one tab. */
|
||||
interface TabMetrics {
|
||||
/** Resolved `scrollbar-gutter` on the column's scroller. */
|
||||
gutter: string
|
||||
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
|
||||
overflowX: string
|
||||
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
|
||||
overflowY: string
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** True when the column's scroller actually scrolls — only Chat does. */
|
||||
scrolls: boolean
|
||||
/** Left edge of the input card in viewport coordinates. */
|
||||
cardLeft: number
|
||||
/** Right edge of the input card. */
|
||||
cardRight: number
|
||||
/** Width of the input card, capped at the composer card max width. */
|
||||
cardWidth: number
|
||||
}
|
||||
|
||||
/** One tab's metrics beside the other's, plus the distances between them. */
|
||||
interface TabComparison {
|
||||
chat: TabMetrics
|
||||
trajectory: TabMetrics
|
||||
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
|
||||
leftShift: number
|
||||
/** Distance between the two tabs' card right edges. */
|
||||
rightShift: number
|
||||
/** Difference between the two tabs' card widths. */
|
||||
widthShift: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the column scroller and the input card in the tab currently shown.
|
||||
* @param page - the page under test.
|
||||
* @returns the scroller's resolved overflow style and the card's rectangle.
|
||||
*/
|
||||
function measureTab(page: Page): Promise<TabMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (host === null) throw new Error('conversation column scroller not in the DOM')
|
||||
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
|
||||
if (card === null) throw new Error('no input card inside the composer seat')
|
||||
const style = getComputedStyle(host)
|
||||
const hostRect = host.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
overflowX: style.overflowX,
|
||||
overflowY: style.overflowY,
|
||||
band: hostRect.width - host.clientWidth,
|
||||
scrolls: host.scrollHeight > host.clientHeight,
|
||||
cardLeft: cardRect.left,
|
||||
cardRight: cardRect.right,
|
||||
cardWidth: cardRect.width,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one tab and wait for the view that owns it to be laid out.
|
||||
* @param page - the page under test.
|
||||
* @param tab - the tab to show.
|
||||
*/
|
||||
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
|
||||
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
|
||||
// Both measurements are taken after a paint, so a rectangle read mid-transition
|
||||
// cannot be reported as a shift the cascade did not cause.
|
||||
await page.evaluate(() => new Promise<void>((settle) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure both tabs and the distances between them, leaving Chat shown.
|
||||
* @param page - the page under test.
|
||||
* @returns each tab's metrics and the card's displacement between them.
|
||||
*/
|
||||
async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
await showTab(page, 'Chat')
|
||||
const chat = await measureTab(page)
|
||||
await showTab(page, 'Trajectory')
|
||||
const trajectory = await measureTab(page)
|
||||
await showTab(page, 'Chat')
|
||||
return {
|
||||
chat,
|
||||
trajectory,
|
||||
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
|
||||
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
|
||||
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* @param page - the page under test.
|
||||
* @returns the comparison as the column laid out before this change.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
const style = document.createElement('style')
|
||||
style.id = id
|
||||
style.textContent = css
|
||||
document.head.append(style)
|
||||
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
|
||||
try {
|
||||
return await compareTabs(page)
|
||||
} finally {
|
||||
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the seeded session from the sidebar search.
|
||||
*
|
||||
* Cold summaries carry the temp workspace's basename, so the persisted first
|
||||
* message is the stable identity to search for, and the query itself drives the
|
||||
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
|
||||
* is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
const deadline = Date.now() + 60_000
|
||||
for (;;) {
|
||||
if (await results.count() === 1) break
|
||||
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
await results.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body.
|
||||
* @param wide - comparison at the viewport where the card sits at its width cap.
|
||||
* @param narrow - comparison at the viewport where the card shrinks with the column.
|
||||
* @param control - comparison at the wide viewport with the reservation removed.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
|
||||
const section = (name: string, comparison: TabComparison): string[] => [
|
||||
`## ${name}`,
|
||||
'',
|
||||
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
|
||||
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
|
||||
`- Chat reserved band: ${String(comparison.chat.band)}px`,
|
||||
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
|
||||
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
|
||||
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
|
||||
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
|
||||
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
|
||||
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
|
||||
'',
|
||||
]
|
||||
return [
|
||||
'# Input card position across the Chat and Trajectory tabs',
|
||||
'',
|
||||
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
|
||||
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
|
||||
...section('Wide viewport, reservation removed in the page (control)', control),
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe('web e2e: input card position across view tabs', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, FIXTURE.log, SEED_ID)
|
||||
// Scrollbars must take layout space here or the scenario proves nothing;
|
||||
// see the file header for the measurement behind dropping this argument.
|
||||
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
|
||||
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSeededSession(page)
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
// Vacuity guard, in two parts. A transcript that does not overflow gives
|
||||
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
|
||||
// would make the tabs agree without the reservation doing anything.
|
||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||
const comparison = await compareTabs(page)
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
// The reservation reaches both states, which is the whole change: the same
|
||||
// band, on a box that scrolls and on one that only holds a view.
|
||||
expect(comparison.chat.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.band).toBe(comparison.chat.band)
|
||||
// Declared as a scroll container on both axes rather than left to compute:
|
||||
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
|
||||
// horizontal axis computes to `auto` beside a scrolling one.
|
||||
expect(comparison.trajectory.overflowY).toBe('auto')
|
||||
expect(comparison.trajectory.overflowX).toBe('hidden')
|
||||
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
|
||||
expect(comparison.trajectory.scrolls).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place when the tab changes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
const comparison = await compareTabs(page)
|
||||
// The reported symptom as a number. At this viewport the card sits at its
|
||||
// width cap, so the pre-fix shift showed up as a centring difference — half
|
||||
// the band on each edge — rather than as a width change.
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
const capped = await measureTab(page)
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
const comparison = await compareTabs(page)
|
||||
// The other geometry, and a different failure: below the cap the card takes
|
||||
// the column's width, so an unreserved gutter changed its WIDTH by the whole
|
||||
// band instead of shifting it by half. Asserted against the capped
|
||||
// measurement rather than against the cap's pixel value, which belongs to
|
||||
// the stylesheet.
|
||||
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('moves the card again once the reservation is removed in the page', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
// The control: without it, equal rectangles could also mean the tab switch
|
||||
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
|
||||
// its bar and the Trajectory branch goes back to a hidden box with none, and
|
||||
// the card moves by half the band on each edge.
|
||||
const comparison = await compareTabsWithoutReservation(page)
|
||||
expect(comparison.chat.gutter).toBe('auto')
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
expect(comparison.trajectory.band).toBe(0)
|
||||
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
|
||||
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
|
||||
// Restoring the sheet restores the fix, so the control cannot leak into the
|
||||
// remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed tab geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
const wide = await compareTabs(page)
|
||||
await page.setViewportSize(NARROW_VIEWPORT)
|
||||
const narrow = await compareTabs(page)
|
||||
await page.setViewportSize(WIDE_VIEWPORT)
|
||||
const control = await compareTabsWithoutReservation(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// The seeded session is generated in-process, so the geometry golden is the
|
||||
// whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
# Input card position across the Chat and Trajectory tabs
|
||||
|
||||
## Wide viewport (1680px, card at its cap)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Narrow viewport (900px, card shrinking with the column)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Wide viewport, reservation removed in the page (control)
|
||||
|
||||
- Chat: scrollbar-gutter auto, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 0px
|
||||
- input card left edge moves between tabs: 4px
|
||||
- input card right edge moves between tabs: 4px
|
||||
- input card width changes between tabs: 0px
|
||||
@@ -61,6 +61,7 @@
|
||||
"tests/chat-scroll-contract.e2e.ts",
|
||||
"tests/chat-long-interactions.e2e.ts",
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/composer-tab-geometry.e2e.ts",
|
||||
"tests/complex-history.perf.ts"
|
||||
],
|
||||
"references": [
|
||||
|
||||
Reference in New Issue
Block a user