diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml index 47a432a7dd..754ca8bbd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: fa2347e5e1b8d41da020db69840e1dcf32cfc4c3 -2026-08-04-conversation-column-one-axis-scroll.zh.md: aba34e304b8e6a349bd1e295ceb00d5ad809f6dd +2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d +2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index fa2347e5e1..9a487c506a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -30,7 +30,7 @@ Clipping does not change. `overflow-y: auto` had already made the box a scroll c [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. -Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to the full bleed — without it a `scrollLeft` of 0 could equally mean the wheel never arrived. +Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index aba34e304b..23441a7c86 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -30,7 +30,7 @@ [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 -两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到完整的外溢量——没有它,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 +两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 ## 后果 diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index 03ba4f9572..ae140bfd66 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -110,7 +110,7 @@ function measureColumn(page: Page, width: number): Promise { * programmatically scrollable and leaves `scrollWidth` untouched, so every * property reading agrees across the fix. Only refusing an actual input event * differs — measured at the 1200px stop, the shipped column stays at 0 while - * the same page with `overflow-x: auto` forced on lands at the full 66px bleed. + * the same page with `overflow-x: auto` forced on lands at its scroll boundary. * @param page - the page under test. * @returns `scrollLeft` after one horizontal wheel over the column. */ @@ -143,6 +143,28 @@ async function wheelHorizontally(page: Page): Promise { })) } +/** + * Measure the positive horizontal scroll boundary without changing the + * shipped overflow mode. This is distinct from `scrollWidth - clientWidth` + * when a stable scrollbar gutter leaves part of the overflow on the negative + * side of the scroll origin. + * @param page - the page under test. + * @returns the greatest positive `scrollLeft` reachable by the control gesture. + */ +async function horizontalScrollLimit(page: Page): Promise { + return page.evaluate((delta) => { + const scroller = document.querySelector('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + const previousScrollBehavior = scroller.style.scrollBehavior + scroller.style.scrollBehavior = 'auto' + scroller.scrollLeft = delta + const limit = scroller.scrollLeft + scroller.scrollLeft = 0 + scroller.style.scrollBehavior = previousScrollBehavior + return limit + }, WHEEL_DELTA) +} + /** A stop's readings plus where a horizontal wheel over it landed. */ type ColumnStop = ColumnMetrics & { /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ @@ -259,8 +281,8 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // The mutation control, run in the page rather than against a second // build: it restores exactly what the fix changed — the initial `visible` // that a one-axis scroller computes to `auto` — and shows the same gesture, - // at the same timing, carrying the column to the full bleed. Without it a - // `scrollLeft` of 0 could equally mean the wheel never arrived. + // at the same timing, carrying the column to its positive scroll boundary. + // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. // Settle the resize first: this test runs at 1680 on its own and after the // sweep's 600 in a full run, and an unsettled column reports the previous // viewport's bleed. @@ -278,15 +300,15 @@ describe('web e2e: the conversation column scrolls on one axis', () => { const before = await measureColumn(page, 1200) expect(before.overflowX).toBe('auto') expect(before.bleedRange).toBeGreaterThan(0) - // The gesture has to be able to reach the far edge, or the equality below - // would fail on the clamp and read as a broken fix. Stated as its own - // assertion so that failure names itself. - expect(before.bleedRange).toBeLessThan(WHEEL_DELTA) + const scrollLimit = await horizontalScrollLimit(page) + // The control has a reachable horizontal range, and the gesture exceeds + // it so the equality below proves that the wheel reached the far edge. + expect(scrollLimit).toBeGreaterThan(0) + expect(scrollLimit).toBeLessThan(WHEEL_DELTA) // Rounded: `scrollLeft` is fractional under a fractional layout while - // `scrollWidth - clientWidth` is integral, and the claim is that the - // column travelled the whole bleed — not that two engines agree on a - // sub-pixel. - expect(Math.round(await wheelHorizontally(page))).toBe(before.bleedRange) + // the claim is that the column reached the positive boundary, not that + // two engines agree on a sub-pixel. + expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit)) } finally { await page.evaluate((id: string) => { document.getElementById(id)?.remove() diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md index b226cb845d..9735019508 100644 --- a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -2,7 +2,7 @@ ## Wide viewport (1680px, card at its cap) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -14,7 +14,7 @@ ## Narrow viewport (800px, card shrinking with the column) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -26,7 +26,7 @@ ## Wide viewport, reservation removed in the page (control) -- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat: scrollbar-gutter auto, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/hidden