diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index 887e3a3..e7553fc 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -411,6 +411,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (draggingRef.current) return
const col = centerColRef.current
if (col === null) return
+ if (!col.isConnected) {
+ // The observed column was detached (HMR re-render swapped the node
+ // in place): its rect is stale garbage. Drop the ref — the locate
+ // chain re-runs on the next mutation/interval tick and picks up the
+ // new column node (issue #248).
+ centerColRef.current = null
+ return
+ }
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
@@ -429,15 +437,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
- // query may miss it. Never give up: watch #root's children (the swap
- // mutates them) and re-run this locator — querying once and bailing
- // would strand the panel at the zero-size fallback forever (observed:
- // a 1px sliver at the viewport's left edge).
+ // query may miss it. Never give up: watch #root's subtree (the swap and
+ // HMR re-renders mutate it) and re-run this locator — querying once and
+ // bailing would strand the panel at the zero-size fallback forever
+ // (observed: a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
- if (col === undefined) {
+ if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current = null
observer?.disconnect()
@@ -446,17 +454,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return
}
if (centerColRef.current !== col) {
+ // A NEW column node (boot swap, HMR re-render, or a previous locate
+ // that found nothing): attach the ResizeObserver to THIS node and
+ // measure it once. Same-node size changes are the ResizeObserver's
+ // job — no forced measurement here, because a forced
+ // getBoundingClientRect per mutation would reflow the shell at
+ // mutation cadence.
centerColRef.current = col
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
+ measureCenter()
}
- measureCenter()
}
locate()
- const watcher = new MutationObserver(locate)
+ // rAF-debounce the mutation watchers: #root's subtree changes at chat
+ // cadence (streaming turns), and locate() itself must stay cheap.
+ let locateFrame: number | null = null
+ const scheduleLocate = (): void => {
+ if (locateFrame !== null) return
+ locateFrame = requestAnimationFrame(() => {
+ locateFrame = null
+ locate()
+ })
+ }
+ const watcher = new MutationObserver(scheduleLocate)
const root = document.getElementById('root')
- if (root !== null) watcher.observe(root, { childList: true })
+ if (root !== null) watcher.observe(root, { childList: true, subtree: true })
// The layout push writes --dsh-sidebar-* on . A HMR re-activation
// clears those variables on teardown and re-writes them on setup — and
// that is also the moment the shell may have re-created the center
@@ -465,10 +489,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Watching 's style attribute catches that re-sync: the push
// rewrite re-locates and re-measures, so the bottom panel recovers
// instead of staying hidden on a stale {0,0} center rect.
- const htmlStyleWatcher = new MutationObserver(locate)
+ const htmlStyleWatcher = new MutationObserver(scheduleLocate)
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
+ // Last-resort safety net (issue #248): no watcher is guaranteed to fire
+ // for every HMR teardown/setup interleaving (e.g. the style attribute
+ // may end up byte-identical, and the col may be swapped before the
+ // subtree watcher attaches). A slow unconditional re-locate makes the
+ // panel converge on the real column within a couple of seconds no
+ // matter what sequence the shell used. locate() is cheap when nothing
+ // changed (one querySelector + an identity compare; no forced layout).
+ const retry = window.setInterval(locate, 1500)
return () => {
disposed = true
+ if (locateFrame !== null) cancelAnimationFrame(locateFrame)
+ window.clearInterval(retry)
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
@@ -556,12 +590,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (issue #106: skins that inset the panels must not fight JS coords).
}
+ /** Last size a drag actually applied to the DOM (updated by applyDrag).
+ * When a pointer stream dies without any position info (issue #247: an
+ * ultra-fast flick whose release events carried no usable coordinates),
+ * the abort path adopts this instead of rolling back to the pre-drag
+ * value — the DOM's current size is the only truthful record left. */
+ const lastDragSize = useRef<{ width: number; height: number } | null>(null)
+
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag.
* The layout push rides the shared writer (writeGeometry). */
const applyDrag = (width: number, height: number): void => {
+ lastDragSize.current = { width, height }
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
// centerRect.right is the center column's right edge at the committed
@@ -640,9 +682,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
- * position (pointercancel / lostpointercapture still carry coordinates),
- * and only a drag that produced neither (pure down+up at the same spot)
- * reverts to the store's committed sizes.
+ * position (only pointercancel is trusted to carry coordinates —
+ * lostpointercapture's coordinates are not guaranteed, so the handlers
+ * pass the event only from pointercancel), and finally the size the drag
+ * last APPLIED to the DOM (lastDragSize). A drag that produced none of
+ * those (pure down+up at the same spot) commits the store's own sizes —
+ * a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
+ * an interrupted fast flick; the abort path added in the unified-host
+ * refactor did, and that regression is what this ordering removes).
+ *
+ * Every commit path marks the drag committed, so the interrupt
+ * double-fire (pointercancel → lostpointercapture) cannot commit once
+ * and then roll the same drag back.
*/
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
if (dragCommitted.current) return
@@ -653,8 +704,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
width = pending.width
height = pending.height
} else if (event !== undefined) {
- // No move ever landed: the cancel/lost-capture position is all we
- // have — commit it (clamped) instead of rolling back the flick.
+ // No move ever landed: the cancel position is all we have — commit it
+ // (clamped) instead of rolling back the flick.
if (draggingWidth) {
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
@@ -667,6 +718,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
}
if (width !== undefined && height !== undefined) {
+ dragCommitted.current = true
pendingDrag.current = null
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
@@ -677,11 +729,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, width), height))
} else {
+ // No pending write and no usable event coordinates: keep the size the
+ // drag last applied instead of rolling back to the pre-drag value
+ // (the flick's moves may have been consumed by the rAF just before
+ // the stream died — the DOM already shows the dragged size). Clamp to
+ // the current geometry like the layout-push effect (closed/narrow
+ // panels are written 0 by the push).
+ dragCommitted.current = true
stopDragScheduling()
- applyDrag(
- !narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
- !narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
- )
+ const last = lastDragSize.current
+ const adoptedWidth = !narrow && state?.panelOpen === true
+ ? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
+ : 0
+ const adoptedHeight = !narrow && state?.bottomOpen === true
+ ? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
+ : 0
+ applyDrag(adoptedWidth, adoptedHeight)
+ draggingRef.current = false
+ measureCenter()
+ store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
}
reset()
}
@@ -955,22 +1021,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
+ if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
- // A very fast release can merge/lose pointermove bursts:
- // prefer the LAST KNOWN dragged width (already clamped by
- // the move handler) over the up position, which may equal
- // the down position when no move landed.
- const pending = pendingDrag.current
- const width = pending !== null
- ? pending.width
- : clampWidth(startWidth + (startX - event.clientX))
+ // The up position is the pointer's FINAL position — a fast
+ // flick's tail is coalesced into the up event, so the last
+ // pointermove (the rAF pending value) can be stale. Commit
+ // from the up position (v0.13.1 semantics; issue #247).
+ const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
commitDrag(width, height, s => setWidth(s, width))
setDraggingWidth(false)
}}
- onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
+ onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
/>
)}
@@ -1021,22 +1085,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
+ if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
- // Last known dragged size wins over the up position (fast
- // releases can merge/lose pointermove bursts).
- const pending = pendingDrag.current
- const width = pending !== null
- ? pending.width
- : clampWidth(startWidth + (startX - event.clientX))
- const height = pending !== null
- ? pending.height
- : clampHeight(startHeight + (startY - event.clientY))
+ // Up position wins over the rAF pending value (see the width
+ // strip handler — issue #247).
+ const width = clampWidth(startWidth + (startX - event.clientX))
+ const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false)
}}
- onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
+ onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
/>
)}
@@ -1061,6 +1121,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
{
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
+ if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
- // Last known dragged height wins over the up position (fast
- // releases can merge/lose pointermove bursts).
- const pending = pendingDrag.current
- const height = pending !== null
- ? pending.height
- : clampHeight(startHeight + (startY - event.clientY))
+ // Up position wins over the rAF pending value (see the width
+ // strip handler — issue #247).
+ const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
setDraggingBottom(false)
}}
- onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
+ onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
/>
{/*
diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts
index 422593e..d44e3bf 100644
--- a/tests/e2e/drag-layout.e2e.ts
+++ b/tests/e2e/drag-layout.e2e.ts
@@ -29,7 +29,7 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { test, expect, request, type APIRequestContext } from '@playwright/test'
+import { test, expect, request, type APIRequestContext, type Locator, type Page } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
@@ -304,24 +304,9 @@ test('a very fast width drag still commits the dragged position (no rollback on
})
const widthBefore = await readWidth()
- const stripBox = await page.evaluate(() => {
- const host = document.querySelector('[data-dsh-better-sidebar]')
- if (host === null) return null
- const boxes = [...host.querySelectorAll('*')]
- .filter(el => getComputedStyle(el).cursor === 'col-resize')
- .map(el => el.getBoundingClientRect())
- .filter(r => r.width > 0 && r.height > 0)
- .sort((a, b) => a.x - b.x)
- const r = boxes[0]
- if (r === undefined) return null
- const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
- return {
- x: r.x, y: r.y, width: r.width, height: r.height,
- varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
- innerWidth: window.innerWidth,
- }
- })
- expect(stripBox, 'the width drag strip must be present').not.toBeNull()
+ // The panel slides in from the right on expand: read the strip box only
+ // AFTER it settles at the pushed layout edge (a mid-animation read sits
+ // off-viewport and the drag never reaches the strip).
await expect
.poll(async () => {
const box = await page.evaluate(() => {
@@ -338,6 +323,19 @@ test('a very fast width drag still commits the dragged position (no rollback on
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
+ const stripBox = await page.evaluate(() => {
+ const host = document.querySelector('[data-dsh-better-sidebar]')
+ if (host === null) return null
+ const boxes = [...host.querySelectorAll('*')]
+ .filter(el => getComputedStyle(el).cursor === 'col-resize')
+ .map(el => el.getBoundingClientRect())
+ .filter(r => r.width > 0 && r.height > 0)
+ .sort((a, b) => a.x - b.x)
+ const r = boxes[0]
+ if (r === undefined) return null
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
+ })
+ expect(stripBox, 'the width drag strip must be present').not.toBeNull()
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
@@ -359,3 +357,257 @@ test('a very fast width drag still commits the dragged position (no rollback on
await page.waitForTimeout(600)
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
})
+
+/* ── issue #247: interrupted fast drags must not roll back ───────────── */
+
+/** Dismiss whatever onboarding takeover is present (same dance as above). */
+async function dismissOnboarding(page: Page): Promise {
+ try {
+ await expect
+ .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
+ .toBeGreaterThan(0)
+ } catch {
+ console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
+ }
+ for (let round = 0; round < 8; round++) {
+ let dismissed = false
+ for (const name of ['Continue', 'Configure later']) {
+ const button = page.getByRole('button', { name, exact: true }).first()
+ if ((await button.count()) === 0) continue
+ try {
+ await button.click({ timeout: 4_000 })
+ dismissed = true
+ await page.waitForTimeout(1_000)
+ } catch {
+ // Masked by a takeover stacked above; retry in the next round.
+ }
+ }
+ if (!dismissed) break
+ }
+}
+
+/** Open the sidebar panel and wait for the layout push to go live. */
+async function expandSidebar(page: Page, sidebar: Locator): Promise {
+ const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
+ await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
+ await expandButton.click()
+ await expect
+ .poll(async () => {
+ const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
+ return value !== '' && value !== '0px'
+ }, { timeout: 90_000 })
+ .toBe(true)
+}
+
+/** Locate the width drag strip (the leftmost col-resize element inside the
+ * sidebar host), wait for it to settle at the pushed layout edge (the
+ * panel slides in from the right on expand — a strip read during the
+ * animation sits off-viewport and no pointer event ever reaches it), then
+ * read its FINAL box and return a drag start point on it. */
+async function settleWidthStrip(page: Page): Promise<{ startX: number; startY: number }> {
+ await expect
+ .poll(async () => {
+ const box = await page.evaluate(() => {
+ const host = document.querySelector('[data-dsh-better-sidebar]')
+ const el = [...host!.querySelectorAll('*')]
+ .filter(e => getComputedStyle(e).cursor === 'col-resize')
+ .map(e => e.getBoundingClientRect())
+ .filter(r => r.width > 0 && r.height > 0)
+ .sort((a, b) => a.x - b.x)[0]
+ return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
+ })
+ if (box === null) return false
+ const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
+ return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
+ }, { timeout: 30_000 })
+ .toBe(true)
+ const stripBox = await page.evaluate(() => {
+ const host = document.querySelector('[data-dsh-better-sidebar]')
+ if (host === null) return null
+ const boxes = [...host.querySelectorAll('*')]
+ .filter(el => getComputedStyle(el).cursor === 'col-resize')
+ .map(el => el.getBoundingClientRect())
+ .filter(r => r.width > 0 && r.height > 0)
+ .sort((a, b) => a.x - b.x)
+ const r = boxes[0]
+ if (r === undefined) return null
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
+ })
+ expect(stripBox, 'the width drag strip must be present').not.toBeNull()
+ return {
+ startX: stripBox!.x + stripBox!.width / 2,
+ startY: stripBox!.y + Math.min(120, stripBox!.height / 2 + 60),
+ }
+}
+
+/** Dispatch a synthetic pointer event (pointercancel / lostpointercapture)
+ * onto the width strip. Synthetic events bypass the real pointer-capture
+ * pipeline, which is exactly the point: the handlers must survive streams
+ * that end without a component-visible pointerup. */
+async function dispatchPointerOnStrip(
+ page: Page,
+ type: 'pointercancel' | 'lostpointercapture',
+ init: { clientX?: number; clientY?: number },
+): Promise {
+ await page.evaluate(({ type, init }) => {
+ const host = document.querySelector('[data-dsh-better-sidebar]')
+ if (host === null) throw new Error('sidebar host missing')
+ const el = [...host.querySelectorAll('*')]
+ .filter(el => getComputedStyle(el).cursor === 'col-resize')
+ .map(el => ({ el, r: el.getBoundingClientRect() }))
+ .filter(({ r }) => r.width > 0 && r.height > 0)
+ .sort((a, b) => a.r.x - b.r.x)[0]?.el
+ if (el === undefined) throw new Error('width drag strip not found')
+ el.dispatchEvent(new PointerEvent(type, {
+ pointerId: 1,
+ bubbles: true,
+ cancelable: true,
+ pointerType: 'mouse',
+ isPrimary: true,
+ ...init,
+ }))
+ }, { type, init })
+}
+
+test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
+ await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
+ const sidebar = page.locator('[data-dsh-better-sidebar]')
+ await expect(sidebar).toBeAttached({ timeout: 90_000 })
+ await dismissOnboarding(page)
+ await expandSidebar(page, sidebar)
+ const readWidth = (): Promise => page.evaluate(() => {
+ const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
+ return Number.isNaN(value) ? 0 : value
+ })
+ const widthBefore = await readWidth()
+ const { startX, startY } = await settleWidthStrip(page)
+
+ // Ultra-fast flick whose stream the browser then CANCELS (touchpad /
+ // gesture interception): pointercancel carries the final position and
+ // lostpointercapture follows it. The double fire must commit ONCE — the
+ // second interrupt event used to roll the drag back to the pre-drag
+ // width (the abort path never marked the drag committed).
+ await page.mouse.move(startX, startY)
+ await page.mouse.down()
+ await page.mouse.move(startX - 140, startY, { steps: 1 })
+ await dispatchPointerOnStrip(page, 'pointercancel', { clientX: startX - 140, clientY: startY })
+ await dispatchPointerOnStrip(page, 'lostpointercapture', {})
+
+ await expect
+ .poll(async () => await readWidth(), { timeout: 10_000 })
+ .toBeGreaterThan(widthBefore + 100)
+ await page.waitForTimeout(600)
+ expect(await readWidth(), 'the interrupted fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
+
+ // Release the real pointer stream the synthetic events bypassed.
+ await page.mouse.up()
+})
+
+test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
+ await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
+ const sidebar = page.locator('[data-dsh-better-sidebar]')
+ await expect(sidebar).toBeAttached({ timeout: 90_000 })
+ await dismissOnboarding(page)
+ await expandSidebar(page, sidebar)
+ const readWidth = (): Promise => page.evaluate(() => {
+ const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
+ return Number.isNaN(value) ? 0 : value
+ })
+ const widthBefore = await readWidth()
+ const { startX, startY } = await settleWidthStrip(page)
+
+ // A flick whose move DID reach the DOM (the rAF flushed it), followed by
+ // capture loss with NO coordinates (lostpointercapture does not carry a
+ // position). The abort must adopt the last applied size — the old code
+ // explicitly reverted the DOM to the pre-drag width here.
+ await page.mouse.move(startX, startY)
+ await page.mouse.down()
+ await page.mouse.move(startX - 120, startY, { steps: 1 })
+ await page.waitForTimeout(100) // let the rAF consume the pending write
+ await dispatchPointerOnStrip(page, 'lostpointercapture', {})
+
+ await expect
+ .poll(async () => await readWidth(), { timeout: 10_000 })
+ .toBeGreaterThan(widthBefore + 80)
+ await page.waitForTimeout(600)
+ expect(await readWidth(), 'the no-coordinate capture loss must not roll back after settling').toBeGreaterThan(widthBefore + 80)
+ await page.mouse.up()
+})
+
+/* ── issue #248: HMR center-column replacement must be re-located ────── */
+
+test('bottom panel re-locates a center column replaced deep inside #root (issue #248)', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
+ await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
+ const sidebar = page.locator('[data-dsh-better-sidebar]')
+ await expect(sidebar).toBeAttached({ timeout: 90_000 })
+ await dismissOnboarding(page)
+ await expandSidebar(page, sidebar)
+
+ // Open the bottom panel; it must become visible with the center column
+ // measured (its right edge tracking the column's right edge).
+ const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
+ await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
+ await bottomExpand.click()
+ await expect
+ .poll(async () => {
+ const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
+ return value !== '' && value !== '0px'
+ }, { timeout: 90_000 })
+ .toBe(true)
+
+ const probe = (): Promise<{ visible: boolean; drift: number } | null> => page.evaluate(() => {
+ const el = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
+ const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
+ if (el === null || col == null) return null
+ const panel = el.getBoundingClientRect()
+ const center = col.getBoundingClientRect()
+ return {
+ visible: getComputedStyle(el).visibility !== 'hidden',
+ drift: Math.abs(panel.right - center.right),
+ }
+ })
+ await expect
+ .poll(async () => {
+ const p = await probe()
+ return p !== null && p.visible && p.drift <= 8
+ }, { timeout: 30_000 })
+ .toBe(true)
+
+ // Simulate the HMR re-render: the shell swaps the center column node
+ // DEEP inside #root (a mutation the old childList-only watcher never
+ // saw — #root's own children don't change), first with a zero-size stub
+ // (mid-teardown), then with a re-created column of different geometry.
+ await page.evaluate(() => {
+ const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
+ if (col == null || col.parentElement === null) throw new Error('center column not found')
+ const stub = document.createElement('div')
+ const slot = document.createElement('div')
+ slot.setAttribute('data-slot', 'conversation')
+ stub.appendChild(slot)
+ col.parentElement.replaceChild(stub, col)
+ })
+ await page.evaluate(() => {
+ const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
+ if (col == null || col.parentElement === null) throw new Error('center column stub not found')
+ const fresh = document.createElement('div')
+ fresh.style.width = '300px'
+ fresh.style.height = '200px'
+ const slot = document.createElement('div')
+ slot.setAttribute('data-slot', 'conversation')
+ fresh.appendChild(slot)
+ col.parentElement.replaceChild(fresh, col)
+ })
+
+ // The locate chain (subtree watcher / html-style watcher / 1.5s retry)
+ // must converge on the new column within a couple of seconds; the old
+ // code kept observing the detached column and never recovered.
+ await expect
+ .poll(async () => {
+ const p = await probe()
+ return p !== null && p.visible && p.drift <= 8
+ }, { timeout: 15_000 })
+ .toBe(true)
+})