Skip to content

Commit ce76f14

Browse files
authored
fix(chat): preserve panel resize gestures (#9065)
* fix(chat): retain resized panels when pointer capture ends * fix(chat): handle native focus and touch resize gestures
1 parent 21c4f73 commit ce76f14

3 files changed

Lines changed: 102 additions & 41 deletions

File tree

‎apps/desktop/e2e/chat-panel.spec.ts‎

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
113113
})
114114
const shell = app
115115
const page = await shell.firstWindow()
116+
const inputSession = await page.context().newCDPSession(page)
117+
// Playwright otherwise keeps the document focused when its native window loses focus.
118+
await inputSession.send('Emulation.setFocusEmulationEnabled', { enabled: false })
116119
page.on('pageerror', (error) => errors.push(error.message))
117120
page.on('crash', () => {
118121
rendererCrashed = true
@@ -253,41 +256,102 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
253256
'detach',
254257
'chat switch',
255258
] as const) {
256-
await check(`${interruption} keeps the previous saved width`, async () => {
259+
const keepsDraggedWidth = interruption === 'capture loss' || interruption === 'blur'
260+
await check(
261+
`${interruption} keeps the ${keepsDraggedWidth ? 'dragged' : 'previous saved'} width`,
262+
async () => {
263+
const before = await page.evaluate(() => JSON.stringify(localStorage))
264+
const rect = await beginDrag()
265+
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
266+
await expectWidth(598)
267+
if (interruption === 'pointercancel') {
268+
await divider.dispatchEvent('pointercancel', { pointerId: 1 })
269+
} else if (interruption === 'capture loss') {
270+
await divider.evaluate((element) => element.releasePointerCapture(1))
271+
await page.mouse.move(rect.x + 100, rect.y + 100)
272+
} else if (interruption === 'blur') {
273+
await shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].blur())
274+
await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(false)
275+
} else if (interruption === 'chat switch') {
276+
await page
277+
.getByRole('button', { name: 'organization-chat-a', exact: true })
278+
.evaluate((element: HTMLButtonElement) => element.click())
279+
await expectWidth(560)
280+
} else {
281+
await page
282+
.getByRole('button', { name: 'Settings', exact: true })
283+
.evaluate((element: HTMLButtonElement) => element.click())
284+
await expect(panel).toHaveCount(0)
285+
}
286+
if (keepsDraggedWidth) {
287+
await expectWidth(598)
288+
expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before)
289+
if (interruption === 'blur') {
290+
await shell.evaluate(({ app, BrowserWindow }) => {
291+
app.focus({ steal: true })
292+
BrowserWindow.getAllWindows()[0].focus()
293+
})
294+
}
295+
await page.mouse.move(rect.x + 150, rect.y + 100)
296+
await expectWidth(598)
297+
}
298+
await page.mouse.up()
299+
if (interruption === 'chat switch') {
300+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
301+
}
302+
if (interruption === 'detach') {
303+
await page.getByRole('button', { name: 'Back', exact: true }).click()
304+
}
305+
await expectWidth(keepsDraggedWidth ? 598 : 698)
306+
if (keepsDraggedWidth) {
307+
await page.reload()
308+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
309+
await expectWidth(598)
310+
await dragTo(698)
311+
} else {
312+
expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before)
313+
}
314+
}
315+
)
316+
}
317+
318+
for (const button of ['middle', 'right'] as const) {
319+
await check(`${button}-button drags leave the panel width unchanged`, async () => {
257320
const before = await page.evaluate(() => JSON.stringify(localStorage))
258-
const rect = await beginDrag()
321+
await divider.hover({ position: { x: 4, y: 100 } })
322+
const rect = await panel.boundingBox()
323+
if (!rect) throw new Error('Missing panel bounds')
324+
await page.mouse.down({ button })
259325
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
260-
await expectWidth(598)
261-
if (interruption === 'pointercancel') {
262-
await divider.dispatchEvent('pointercancel', { pointerId: 1 })
263-
} else if (interruption === 'capture loss') {
264-
await divider.evaluate((element) => element.releasePointerCapture(1))
265-
await page.mouse.move(rect.x + 101, rect.y + 100)
266-
} else if (interruption === 'blur') {
267-
await page.evaluate(() => window.dispatchEvent(new Event('blur')))
268-
} else if (interruption === 'chat switch') {
269-
await page
270-
.getByRole('button', { name: 'organization-chat-a', exact: true })
271-
.evaluate((element: HTMLButtonElement) => element.click())
272-
await expectWidth(560)
273-
} else {
274-
await page
275-
.getByRole('button', { name: 'Settings', exact: true })
276-
.evaluate((element: HTMLButtonElement) => element.click())
277-
await expect(panel).toHaveCount(0)
278-
}
279-
await page.mouse.up()
280-
if (interruption === 'chat switch') {
281-
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
282-
}
283-
if (interruption === 'detach') {
284-
await page.getByRole('button', { name: 'Back', exact: true }).click()
285-
}
326+
await page.mouse.up({ button })
286327
await expectWidth(698)
287328
expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before)
288329
})
289330
}
290331

332+
await check('a touch drag resizes the panel without becoming a scroll gesture', async () => {
333+
const rect = await panel.boundingBox()
334+
if (!rect) throw new Error('Missing panel bounds')
335+
await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: true })
336+
await inputSession.send('Input.dispatchTouchEvent', {
337+
type: 'touchStart',
338+
touchPoints: [{ x: rect.x, y: rect.y + 100 }],
339+
})
340+
for (let offset = 10; offset <= 100; offset += 10) {
341+
await inputSession.send('Input.dispatchTouchEvent', {
342+
type: 'touchMove',
343+
touchPoints: [{ x: rect.x + offset, y: rect.y + 100 }],
344+
})
345+
}
346+
await inputSession.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
347+
await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: false })
348+
await expectWidth(598)
349+
await page.reload()
350+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
351+
await expectWidth(598)
352+
await dragTo(698)
353+
})
354+
291355
await check('a viewport change during a drag clamps the committed display width', async () => {
292356
await expectWidth(698)
293357
const rect = await beginDrag()

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function ChatPanelLayout({
5252
{!collapsed && (
5353
<div className='relative z-20 w-0 flex-none max-md:hidden'>
5454
<div
55-
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--selection)]'
55+
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize touch-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--selection)]'
5656
role='separator'
5757
tabIndex={0}
5858
aria-orientation='vertical'

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export function useMothershipResize(
205205

206206
const handleResizePointerDown = useCallback(
207207
(e: React.PointerEvent) => {
208+
if (e.button !== 0) return
208209
e.preventDefault()
209210

210211
const el = mothershipRef.current
@@ -285,6 +286,10 @@ export function useMothershipResize(
285286
const cancelPointer = (event: PointerEvent) => {
286287
if (event.pointerId === pointerId) cancel()
287288
}
289+
const complete = () => finish(true)
290+
const completePointer = (event: PointerEvent) => {
291+
if (event.pointerId === pointerId) complete()
292+
}
288293
cleanupRef.current = cancel
289294

290295
handle.addEventListener(
@@ -307,22 +312,14 @@ export function useMothershipResize(
307312
{ signal }
308313
)
309314

310-
handle.addEventListener(
311-
'pointerup',
312-
(upEvent: PointerEvent) => {
313-
if (upEvent.pointerId !== pointerId) return
314-
finish(true)
315-
},
316-
{ signal }
317-
)
315+
handle.addEventListener('pointerup', completePointer, { signal })
318316

319317
// Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.)
320318
// Without this, body cursor/userSelect and transition would be permanently stuck
321319
handle.addEventListener('pointercancel', cancelPointer, { signal })
322-
handle.addEventListener('lostpointercapture', cancelPointer, { signal })
323-
// A blur mid-drag (cmd-tab, window switch) would otherwise strand the
324-
// body cursor/userSelect overrides with no pointerup coming
325-
window.addEventListener('blur', cancel, { signal })
320+
// Capture or focus can be lost before pointerup reaches the handle; retain the user's resize.
321+
handle.addEventListener('lostpointercapture', completePointer, { signal })
322+
window.addEventListener('blur', complete, { signal })
326323
},
327324
[desktopScopeId, rememberWidth]
328325
)

0 commit comments

Comments
 (0)