Skip to content

Commit 77afdbc

Browse files
committed
fix(chat): finalize panel resizing against current layout
1 parent 17a999e commit 77afdbc

3 files changed

Lines changed: 96 additions & 26 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ on:
1515
- 'apps/sim/app/workspace/**/home/hooks/use-resource-panel.ts'
1616
- 'apps/sim/app/workspace/**/home/components/chat-panel-layout.tsx'
1717
- 'apps/sim/stores/chat-panel/**'
18+
- 'apps/sim/stores/constants.ts'
19+
- 'apps/sim/lib/browser-agent/transport.ts'
1820
- 'apps/sim/scripts/fixtures/chat-panel.tsx'
1921
- 'apps/sim/app/layout.tsx'
2022
- 'apps/sim/hooks/use-desktop-update-state.ts'

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

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,17 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
9999
page.on('pageerror', (error) => errors.push(error.message))
100100
await shell.evaluate(({ app, BrowserWindow }) => {
101101
const window = BrowserWindow.getAllWindows()[0]
102-
window.setContentSize(1440, 800)
102+
// Keep the physical window inside the small displays used by macOS CI.
103+
window.setMinimumSize(0, 0)
104+
window.setContentSize(720, 400)
105+
window.webContents.setZoomFactor(0.5)
103106
window.webContents.setBackgroundThrottling(false)
104107
app.focus({ steal: true })
105108
window.focus()
106109
})
107110
await page.reload()
108111
expect(errors).toEqual([])
112+
await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(1440)
109113
const panel = page.locator('[data-mothership-panel]')
110114
const divider = page.getByRole('separator', { name: 'Resize resource view' })
111115
const width = () => panel.evaluate((element) => element.getBoundingClientRect().width)
@@ -177,14 +181,14 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
177181
await page.getByRole('button', { name: 'Resize container' }).click()
178182
await expectWidth(798)
179183
await shell.evaluate(({ BrowserWindow }) =>
180-
BrowserWindow.getAllWindows()[0].setContentSize(1050, 800)
184+
BrowserWindow.getAllWindows()[0].setContentSize(525, 400)
181185
)
182186
await expectWidth(570)
183187
await page.getByRole('button', { name: 'Settings', exact: true }).click()
184188
await page.getByRole('button', { name: 'Back', exact: true }).click()
185189
await expectWidth(570)
186190
await shell.evaluate(({ BrowserWindow }) =>
187-
BrowserWindow.getAllWindows()[0].setContentSize(1440, 800)
191+
BrowserWindow.getAllWindows()[0].setContentSize(720, 400)
188192
)
189193
await expectWidth(798)
190194
})
@@ -205,6 +209,66 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
205209
expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before)
206210
})
207211

212+
for (const interruption of ['pointercancel', 'capture loss', 'blur', 'detach'] as const) {
213+
await check(`${interruption} keeps the previous saved width`, async () => {
214+
const before = await page.evaluate(() => JSON.stringify(localStorage))
215+
await divider.hover({ position: { x: 4, y: 100 } })
216+
const rect = await panel.boundingBox()
217+
if (!rect) throw new Error('Missing panel bounds')
218+
await page.mouse.down()
219+
await expect
220+
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
221+
.toBe(true)
222+
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
223+
await expectWidth(598)
224+
if (interruption === 'pointercancel') {
225+
await divider.dispatchEvent('pointercancel', { pointerId: 1 })
226+
} else if (interruption === 'capture loss') {
227+
await divider.evaluate((element) => element.releasePointerCapture(1))
228+
await page.mouse.move(rect.x + 101, rect.y + 100)
229+
} else if (interruption === 'blur') {
230+
await page.evaluate(() => window.dispatchEvent(new Event('blur')))
231+
} else {
232+
await page
233+
.getByRole('button', { name: 'Settings', exact: true })
234+
.evaluate((element: HTMLButtonElement) => element.click())
235+
await expect(panel).toHaveCount(0)
236+
}
237+
await page.mouse.up()
238+
if (interruption === 'detach') {
239+
await page.getByRole('button', { name: 'Back', exact: true }).click()
240+
}
241+
await expectWidth(698)
242+
expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before)
243+
})
244+
}
245+
246+
await check('a viewport change during a drag clamps the committed display width', async () => {
247+
await page.getByRole('button', { name: 'Resize container' }).click()
248+
await expectWidth(520)
249+
await divider.hover({ position: { x: 4, y: 100 } })
250+
const rect = await panel.boundingBox()
251+
if (!rect) throw new Error('Missing panel bounds')
252+
await page.mouse.down()
253+
await expect
254+
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
255+
.toBe(true)
256+
await page.mouse.move(rect.x + 20, rect.y + 100, { steps: 12 })
257+
await expectWidth(500)
258+
await shell.evaluate(({ BrowserWindow }) =>
259+
BrowserWindow.getAllWindows()[0].setContentSize(300, 400)
260+
)
261+
await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(600)
262+
await page.mouse.up()
263+
await expectWidth(480)
264+
await shell.evaluate(({ BrowserWindow }) =>
265+
BrowserWindow.getAllWindows()[0].setContentSize(720, 400)
266+
)
267+
await expectWidth(500)
268+
await page.getByRole('button', { name: 'Resize container' }).click()
269+
await dragTo(698)
270+
})
271+
208272
await check('another account cannot inherit the current chat width', async () => {
209273
await page.getByRole('button', { name: 'Switch account' }).click()
210274
await expectWidth(720)

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

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ function writeWidthInstantly(el: HTMLElement, width: number) {
9898
el.style.transition = prevTransition
9999
}
100100

101+
/** Restores the preference within the current layout without changing the saved width. */
102+
function restorePanelWidth(el: HTMLElement, preferred: number | undefined) {
103+
if (preferred === undefined) {
104+
el.style.removeProperty('width')
105+
return
106+
}
107+
const width = Math.min(preferred, measureMaxWidth(el))
108+
if (el.style.width !== `${width}px`) writeWidthInstantly(el, width)
109+
}
110+
101111
/** Mirrors the panel's current width and bounds onto the divider for assistive tech. */
102112
function syncDividerValue(handle: HTMLElement, el: HTMLElement, maxWidth = measureMaxWidth(el)) {
103113
handle.setAttribute('aria-valuemin', String(MOTHERSHIP_WIDTH.MIN))
@@ -150,13 +160,7 @@ export function useMothershipResize(
150160
const restoreWidth = () => {
151161
rafId = null
152162
if (cleanupRef.current) return
153-
const preferred = preferredWidthRef.current
154-
if (collapsed || preferred === undefined) {
155-
el.style.removeProperty('width')
156-
} else {
157-
const width = Math.min(preferred, measureMaxWidth(el))
158-
if (el.style.width !== `${width}px`) writeWidthInstantly(el, width)
159-
}
163+
restorePanelWidth(el, collapsed ? undefined : preferredWidthRef.current)
160164
const divider = focusedDividerRef.current
161165
if (divider && document.activeElement === divider) syncDividerValue(divider, el)
162166
}
@@ -195,8 +199,7 @@ export function useMothershipResize(
195199
const startRect = el.getBoundingClientRect()
196200
el.style.width = `${startRect.width}px`
197201

198-
// Nothing moves the panel's right edge mid-drag, and the pointer keeps the
199-
// offset it grabbed at, so one measurement serves the whole gesture.
202+
// Snapshot geometry avoids layout reads on every move; release applies fresh bounds.
200203
const geometry: DragGeometry = {
201204
panelRight: startRect.right,
202205
grabOffset: e.clientX - startRect.left,
@@ -227,30 +230,31 @@ export function useMothershipResize(
227230
const ac = new AbortController()
228231
const { signal } = ac
229232

230-
const cleanup = () => {
233+
const finish = (commit: boolean) => {
231234
ac.abort()
232235
if (rafId !== null) {
233236
cancelAnimationFrame(rafId)
234237
rafId = null
235238
}
236-
// Land on the exact final pointer position before transitions come back,
237-
// so a fast flick whose last move never got a frame is not lost. The
238-
// flush is what stops that catch-up delta from animating: without it the
239-
// width write and the transition restore land in one style change, and
240-
// the panel eases into its final width over 200ms while the native view
241-
// chases it.
242-
if (lastClientX !== null) {
243-
applyWidth(lastClientX)
239+
if (commit && lastClientX !== null) {
244240
rememberWidth(panelWidthAt(lastClientX, geometry))
245241
}
242+
// Flush the restored width before transitions return, so the native view
243+
// does not chase a 200ms catch-up animation.
244+
restorePanelWidth(el, preferredWidthRef.current)
246245
void el.offsetWidth
247246
el.style.transition = prevTransition
248247
document.body.style.cursor = ''
249248
document.body.style.userSelect = ''
250249
cleanupRef.current = null
250+
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId)
251251
syncDividerValue(handle, el)
252252
}
253-
cleanupRef.current = cleanup
253+
const cancel = () => finish(false)
254+
const cancelPointer = (event: PointerEvent) => {
255+
if (event.pointerId === pointerId) cancel()
256+
}
257+
cleanupRef.current = cancel
254258

255259
handle.addEventListener(
256260
'pointermove',
@@ -276,18 +280,18 @@ export function useMothershipResize(
276280
'pointerup',
277281
(upEvent: PointerEvent) => {
278282
if (upEvent.pointerId !== pointerId) return
279-
handle.releasePointerCapture(upEvent.pointerId)
280-
cleanup()
283+
finish(true)
281284
},
282285
{ signal }
283286
)
284287

285288
// Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.)
286289
// Without this, body cursor/userSelect and transition would be permanently stuck
287-
handle.addEventListener('pointercancel', cleanup, { signal })
290+
handle.addEventListener('pointercancel', cancelPointer, { signal })
291+
handle.addEventListener('lostpointercapture', cancelPointer, { signal })
288292
// A blur mid-drag (cmd-tab, window switch) would otherwise strand the
289293
// body cursor/userSelect overrides with no pointerup coming
290-
window.addEventListener('blur', cleanup, { signal })
294+
window.addEventListener('blur', cancel, { signal })
291295
},
292296
[desktopScopeId, rememberWidth]
293297
)

0 commit comments

Comments
 (0)