From fad61866442e41a431b0d077335b06fae9cb161e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 14 Oct 2026 23:22:07 -0700 Subject: [PATCH 1/2] fix(chat): retain resized panels when pointer capture ends --- apps/desktop/e2e/chat-panel.spec.ts | 75 +++++++++++-------- .../home/hooks/use-mothership-resize.ts | 20 ++--- 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/apps/desktop/e2e/chat-panel.spec.ts b/apps/desktop/e2e/chat-panel.spec.ts index 69bc391dde7..9c182bbf526 100644 --- a/apps/desktop/e2e/chat-panel.spec.ts +++ b/apps/desktop/e2e/chat-panel.spec.ts @@ -253,39 +253,50 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c 'detach', 'chat switch', ] as const) { - await check(`${interruption} keeps the previous saved width`, async () => { - const before = await page.evaluate(() => JSON.stringify(localStorage)) - const rect = await beginDrag() - await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) - await expectWidth(598) - if (interruption === 'pointercancel') { - await divider.dispatchEvent('pointercancel', { pointerId: 1 }) - } else if (interruption === 'capture loss') { - await divider.evaluate((element) => element.releasePointerCapture(1)) - await page.mouse.move(rect.x + 101, rect.y + 100) - } else if (interruption === 'blur') { - await page.evaluate(() => window.dispatchEvent(new Event('blur'))) - } else if (interruption === 'chat switch') { - await page - .getByRole('button', { name: 'organization-chat-a', exact: true }) - .evaluate((element: HTMLButtonElement) => element.click()) - await expectWidth(560) - } else { - await page - .getByRole('button', { name: 'Settings', exact: true }) - .evaluate((element: HTMLButtonElement) => element.click()) - await expect(panel).toHaveCount(0) - } - await page.mouse.up() - if (interruption === 'chat switch') { - await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() - } - if (interruption === 'detach') { - await page.getByRole('button', { name: 'Back', exact: true }).click() + const keepsDraggedWidth = interruption === 'capture loss' || interruption === 'blur' + await check( + `${interruption} keeps the ${keepsDraggedWidth ? 'dragged' : 'previous saved'} width`, + async () => { + const before = await page.evaluate(() => JSON.stringify(localStorage)) + const rect = await beginDrag() + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await expectWidth(598) + if (interruption === 'pointercancel') { + await divider.dispatchEvent('pointercancel', { pointerId: 1 }) + } else if (interruption === 'capture loss') { + await divider.evaluate((element) => element.releasePointerCapture(1)) + await page.mouse.move(rect.x + 100, rect.y + 100) + } else if (interruption === 'blur') { + await page.evaluate(() => window.dispatchEvent(new Event('blur'))) + } else if (interruption === 'chat switch') { + await page + .getByRole('button', { name: 'organization-chat-a', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expectWidth(560) + } else { + await page + .getByRole('button', { name: 'Settings', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expect(panel).toHaveCount(0) + } + await page.mouse.up() + if (interruption === 'chat switch') { + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + } + if (interruption === 'detach') { + await page.getByRole('button', { name: 'Back', exact: true }).click() + } + await expectWidth(keepsDraggedWidth ? 598 : 698) + if (keepsDraggedWidth) { + await page.reload() + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(598) + await dragTo(698) + } else { + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) + } } - await expectWidth(698) - expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) - }) + ) } await check('a viewport change during a drag clamps the committed display width', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts index 7b476baa9ad..126c7d1475c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts @@ -285,6 +285,10 @@ export function useMothershipResize( const cancelPointer = (event: PointerEvent) => { if (event.pointerId === pointerId) cancel() } + const complete = () => finish(true) + const completePointer = (event: PointerEvent) => { + if (event.pointerId === pointerId) complete() + } cleanupRef.current = cancel handle.addEventListener( @@ -307,22 +311,14 @@ export function useMothershipResize( { signal } ) - handle.addEventListener( - 'pointerup', - (upEvent: PointerEvent) => { - if (upEvent.pointerId !== pointerId) return - finish(true) - }, - { signal } - ) + handle.addEventListener('pointerup', completePointer, { signal }) // Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.) // Without this, body cursor/userSelect and transition would be permanently stuck handle.addEventListener('pointercancel', cancelPointer, { signal }) - handle.addEventListener('lostpointercapture', cancelPointer, { signal }) - // A blur mid-drag (cmd-tab, window switch) would otherwise strand the - // body cursor/userSelect overrides with no pointerup coming - window.addEventListener('blur', cancel, { signal }) + // Capture or focus can be lost before pointerup reaches the handle; retain the user's resize. + handle.addEventListener('lostpointercapture', completePointer, { signal }) + window.addEventListener('blur', complete, { signal }) }, [desktopScopeId, rememberWidth] ) From 7ac1e59e31a02c233b1b2643d2095e5b41cf44a4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 14 Oct 2026 23:42:14 -0700 Subject: [PATCH 2/2] fix(chat): handle native focus and touch resize gestures --- apps/desktop/e2e/chat-panel.spec.ts | 55 ++++++++++++++++++- .../home/components/chat-panel-layout.tsx | 2 +- .../home/hooks/use-mothership-resize.ts | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/chat-panel.spec.ts b/apps/desktop/e2e/chat-panel.spec.ts index 9c182bbf526..446f791db24 100644 --- a/apps/desktop/e2e/chat-panel.spec.ts +++ b/apps/desktop/e2e/chat-panel.spec.ts @@ -113,6 +113,9 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c }) const shell = app const page = await shell.firstWindow() + const inputSession = await page.context().newCDPSession(page) + // Playwright otherwise keeps the document focused when its native window loses focus. + await inputSession.send('Emulation.setFocusEmulationEnabled', { enabled: false }) page.on('pageerror', (error) => errors.push(error.message)) page.on('crash', () => { rendererCrashed = true @@ -267,7 +270,8 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c await divider.evaluate((element) => element.releasePointerCapture(1)) await page.mouse.move(rect.x + 100, rect.y + 100) } else if (interruption === 'blur') { - await page.evaluate(() => window.dispatchEvent(new Event('blur'))) + await shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].blur()) + await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(false) } else if (interruption === 'chat switch') { await page .getByRole('button', { name: 'organization-chat-a', exact: true }) @@ -279,6 +283,18 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c .evaluate((element: HTMLButtonElement) => element.click()) await expect(panel).toHaveCount(0) } + if (keepsDraggedWidth) { + await expectWidth(598) + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before) + if (interruption === 'blur') { + await shell.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }) + BrowserWindow.getAllWindows()[0].focus() + }) + } + await page.mouse.move(rect.x + 150, rect.y + 100) + await expectWidth(598) + } await page.mouse.up() if (interruption === 'chat switch') { await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() @@ -299,6 +315,43 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c ) } + for (const button of ['middle', 'right'] as const) { + await check(`${button}-button drags leave the panel width unchanged`, async () => { + const before = await page.evaluate(() => JSON.stringify(localStorage)) + await divider.hover({ position: { x: 4, y: 100 } }) + const rect = await panel.boundingBox() + if (!rect) throw new Error('Missing panel bounds') + await page.mouse.down({ button }) + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await page.mouse.up({ button }) + await expectWidth(698) + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) + }) + } + + await check('a touch drag resizes the panel without becoming a scroll gesture', async () => { + const rect = await panel.boundingBox() + if (!rect) throw new Error('Missing panel bounds') + await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: true }) + await inputSession.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: rect.x, y: rect.y + 100 }], + }) + for (let offset = 10; offset <= 100; offset += 10) { + await inputSession.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: rect.x + offset, y: rect.y + 100 }], + }) + } + await inputSession.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: false }) + await expectWidth(598) + await page.reload() + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(598) + await dragTo(698) + }) + await check('a viewport change during a drag clamps the committed display width', async () => { await expectWidth(698) const rect = await beginDrag() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index c47b5077f43..29a40a09990 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -52,7 +52,7 @@ export function ChatPanelLayout({ {!collapsed && (
{ + if (e.button !== 0) return e.preventDefault() const el = mothershipRef.current