Skip to content

Commit 8c3b0be

Browse files
committed
fix(chat): keep active resizes aligned across chat adoption
1 parent 442f10e commit 8c3b0be

6 files changed

Lines changed: 270 additions & 55 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ on:
1717
- 'apps/sim/stores/chat-panel/**'
1818
- 'apps/sim/stores/constants.ts'
1919
- 'apps/sim/lib/browser-agent/transport.ts'
20+
- 'apps/sim/lib/core/utils/separator-keys.ts'
2021
- 'apps/sim/scripts/fixtures/chat-panel.tsx'
2122
- 'apps/sim/app/layout.tsx'
2223
- 'apps/sim/hooks/use-desktop-update-state.ts'

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

Lines changed: 172 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
44
import { dirname, join } from 'node:path'
55
import { fileURLToPath } from 'node:url'
66
import { _electron as electron, expect, test } from '@playwright/test'
7+
import type { SimDesktopApi } from '@sim/desktop-bridge'
78
import { getErrorMessage } from '@sim/utils/errors'
89
import { build } from 'esbuild'
910
import postcss from 'postcss'
@@ -38,19 +39,33 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
3839
}
3940
const userData = mkdtempSync(join(tmpdir(), 'sim-chat-panel-e2e-'))
4041
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
42+
const errors: string[] = []
43+
let desktopExit: { code: number | null; signal: string | null } | null = null
44+
let rendererCrashed = false
4145
let passed = false
4246
let javascript = ''
4347
let stylesheet = ''
4448
const server = createServer((request, response) => {
4549
const path = new URL(request.url ?? '/', 'http://localhost').pathname
46-
if (path === '/fixture.js' || path === '/fixture.css') {
50+
if (path === '/page') {
51+
response.setHeader('Content-Type', 'text/html')
52+
response.end('<!doctype html><html><body>Native browser resize fixture</body></html>')
53+
} else if (path === '/fixture.js' || path === '/fixture.css') {
4754
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
4855
response.end(path.endsWith('.js') ? javascript : stylesheet)
4956
} else if (path.startsWith('/api/')) {
5057
response.setHeader('Content-Type', 'application/json')
51-
response.end('{}')
58+
response.end(
59+
path === '/api/auth/get-session'
60+
? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
61+
: '{}'
62+
)
5263
} else {
5364
response.setHeader('Content-Type', 'text/html')
65+
response.setHeader(
66+
'Set-Cookie',
67+
'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/'
68+
)
5469
response.end(
5570
'<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>'
5671
)
@@ -79,7 +94,7 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
7994
})
8095
javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
8196
stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
82-
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
97+
await new Promise<void>((resolve) => server.listen(0, resolve))
8398
const address = server.address()
8499
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
85100
app = await electron.launch({
@@ -93,37 +108,65 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
93108
})
94109
})
95110
if (!app) throw new Error('Electron did not launch')
111+
app.process().once('exit', (code, signal) => {
112+
desktopExit = { code, signal }
113+
})
96114
const shell = app
97115
const page = await shell.firstWindow()
98-
const errors: string[] = []
99116
page.on('pageerror', (error) => errors.push(error.message))
117+
page.on('crash', () => {
118+
rendererCrashed = true
119+
})
100120
await shell.evaluate(({ app, BrowserWindow }) => {
101121
const window = BrowserWindow.getAllWindows()[0]
102122
// Keep the physical window inside the small displays used by macOS CI.
103123
window.setMinimumSize(0, 0)
104124
window.setContentSize(720, 400)
105-
window.webContents.setZoomFactor(0.5)
106125
window.webContents.setBackgroundThrottling(false)
107126
app.focus({ steal: true })
108127
window.focus()
109128
})
110129
await page.reload()
130+
await shell.evaluate(({ app, BrowserWindow }) => {
131+
const window = BrowserWindow.getAllWindows()[0]
132+
window.webContents.setZoomFactor(0.5)
133+
app.focus({ steal: true })
134+
window.focus()
135+
})
111136
expect(errors).toEqual([])
137+
await expect
138+
.poll(() =>
139+
shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused())
140+
)
141+
.toBe(true)
112142
await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(1440)
113143
const panel = page.locator('[data-mothership-panel]')
114144
const divider = page.getByRole('separator', { name: 'Resize resource view' })
115145
const width = () => panel.evaluate((element) => element.getBoundingClientRect().width)
116146
const expectWidth = async (expected: number) => {
117-
await expect.poll(async () => Math.abs((await width()) - expected)).toBeLessThan(2)
147+
await expect.poll(width).toBeCloseTo(expected, 0)
118148
}
119-
const dragTo = async (target: number) => {
149+
const beginDrag = async () => {
150+
await shell.evaluate(({ app, BrowserWindow }) => {
151+
app.focus({ steal: true })
152+
BrowserWindow.getAllWindows()[0].focus()
153+
})
154+
await expect
155+
.poll(() =>
156+
shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused())
157+
)
158+
.toBe(true)
120159
await divider.hover({ position: { x: 4, y: 100 } })
121160
const rect = await panel.boundingBox()
122161
if (!rect) throw new Error('Missing panel bounds')
123162
await page.mouse.down()
124163
await expect
125164
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
126165
.toBe(true)
166+
return rect
167+
}
168+
const dragTo = async (target: number) => {
169+
const rect = await beginDrag()
127170
await page.mouse.move(rect.x + rect.width - target, rect.y + 100, { steps: 12 })
128171
await page.mouse.up()
129172
await expectWidth(target)
@@ -195,30 +238,24 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
195238

196239
await check('resizing writes storage only when the gesture ends', async () => {
197240
const before = await page.evaluate(() => JSON.stringify(localStorage))
198-
const rect = await panel.boundingBox()
199-
if (!rect) throw new Error('Missing panel bounds')
200-
await page.mouse.move(rect.x, rect.y + 100)
201-
await page.mouse.down()
202-
await expect
203-
.poll(() => divider.evaluate((element) => element.hasPointerCapture(1)))
204-
.toBe(true)
241+
const rect = await beginDrag()
205242
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
206243
await expectWidth(698)
207244
expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before)
208245
await page.mouse.up()
209246
expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before)
210247
})
211248

212-
for (const interruption of ['pointercancel', 'capture loss', 'blur', 'detach'] as const) {
249+
for (const interruption of [
250+
'pointercancel',
251+
'capture loss',
252+
'blur',
253+
'detach',
254+
'chat switch',
255+
] as const) {
213256
await check(`${interruption} keeps the previous saved width`, async () => {
214257
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)
258+
const rect = await beginDrag()
222259
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
223260
await expectWidth(598)
224261
if (interruption === 'pointercancel') {
@@ -228,13 +265,21 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
228265
await page.mouse.move(rect.x + 101, rect.y + 100)
229266
} else if (interruption === 'blur') {
230267
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)
231273
} else {
232274
await page
233275
.getByRole('button', { name: 'Settings', exact: true })
234276
.evaluate((element: HTMLButtonElement) => element.click())
235277
await expect(panel).toHaveCount(0)
236278
}
237279
await page.mouse.up()
280+
if (interruption === 'chat switch') {
281+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
282+
}
238283
if (interruption === 'detach') {
239284
await page.getByRole('button', { name: 'Back', exact: true }).click()
240285
}
@@ -246,13 +291,7 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
246291
await check('a viewport change during a drag clamps the committed display width', async () => {
247292
await page.getByRole('button', { name: 'Resize container' }).click()
248293
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)
294+
const rect = await beginDrag()
256295
await page.mouse.move(rect.x + 20, rect.y + 100, { steps: 12 })
257296
await expectWidth(500)
258297
await shell.evaluate(({ BrowserWindow }) =>
@@ -275,12 +314,114 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
275314
await dragTo(580)
276315
await page.getByRole('button', { name: 'Switch account' }).click()
277316
await expectWidth(698)
278-
expect(errors).toEqual([])
279317
})
318+
await check('assigning a permanent chat ID lets an active drag finish', async () => {
319+
await page.getByRole('button', { name: 'pending:chat', exact: true }).click()
320+
await dragTo(620)
321+
const rect = await beginDrag()
322+
await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 })
323+
await expectWidth(520)
324+
await page
325+
.getByRole('button', { name: 'Assign chat ID', exact: true })
326+
.evaluate((element: HTMLButtonElement) => element.click())
327+
await expect(page.getByRole('button', { name: 'Assign chat ID', exact: true })).toBeDisabled()
328+
expect(await divider.evaluate((element) => element.hasPointerCapture(1))).toBe(true)
329+
await page.mouse.up()
330+
await expectWidth(520)
331+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
332+
await expectWidth(698)
333+
await page.getByRole('button', { name: 'assigned-chat', exact: true }).click()
334+
await expectWidth(520)
335+
await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click()
336+
await expectWidth(698)
337+
})
338+
339+
await check(
340+
'cancelling before the first animation frame restores native browser bounds',
341+
async () => {
342+
await page.getByRole('button', { name: 'pending:native', exact: true }).click()
343+
await dragTo(698)
344+
await shell.evaluate(({ app, BrowserWindow }) => {
345+
app.focus({ steal: true })
346+
BrowserWindow.getAllWindows()[0].focus()
347+
})
348+
await expect
349+
.poll(() =>
350+
shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused())
351+
)
352+
.toBe(true)
353+
await page.getByRole('button', { name: 'Start browser', exact: true }).click()
354+
const nativeBounds = () =>
355+
shell.evaluate(({ BrowserWindow, WebContentsView }) => {
356+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
357+
(child) =>
358+
child instanceof WebContentsView && child.webContents.getURL().endsWith('/page')
359+
)
360+
return view?.getVisible() ? view.getBounds() : null
361+
})
362+
await expect.poll(nativeBounds).not.toBeNull()
363+
const before = await nativeBounds()
364+
await beginDrag()
365+
await divider.evaluate((element) => {
366+
const rect = element.getBoundingClientRect()
367+
element.dispatchEvent(
368+
new PointerEvent('pointermove', { pointerId: 1, clientX: rect.x + 104, bubbles: true })
369+
)
370+
element.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, bubbles: true }))
371+
})
372+
// The bridge round trip observes main-process geometry after the queued bounds messages.
373+
await page.evaluate(() =>
374+
(
375+
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
376+
).simDesktop.browserAgent.capturePanelSnapshot('pending:native')
377+
)
378+
await page.mouse.up()
379+
await expectWidth(698)
380+
expect(await nativeBounds()).toEqual(before)
381+
382+
await check('native predictions follow a chat ID assigned during a drag', async () => {
383+
if (!before) throw new Error('Missing native browser bounds')
384+
await beginDrag()
385+
await page
386+
.getByRole('button', { name: 'Assign chat ID', exact: true })
387+
.evaluate((element: HTMLButtonElement) => element.click())
388+
await expect(
389+
page.getByRole('button', { name: 'Assign chat ID', exact: true })
390+
).toBeDisabled()
391+
expect(await divider.evaluate((element) => element.hasPointerCapture(1))).toBe(true)
392+
await divider.evaluate((element) => {
393+
const rect = element.getBoundingClientRect()
394+
element.dispatchEvent(
395+
new PointerEvent('pointermove', {
396+
pointerId: 1,
397+
clientX: rect.x + 104,
398+
bubbles: true,
399+
})
400+
)
401+
})
402+
await page.evaluate(() =>
403+
(
404+
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
405+
).simDesktop.browserAgent.capturePanelSnapshot('assigned-chat')
406+
)
407+
expect(await nativeBounds()).toEqual({
408+
...before,
409+
x: before.x + 50,
410+
width: before.width - 50,
411+
})
412+
await page.mouse.up()
413+
await expectWidth(598)
414+
})
415+
}
416+
)
417+
expect(errors).toEqual([])
280418
passed = true
281419
} finally {
282420
mkdirSync(dirname(reportPath), { recursive: true })
283-
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
421+
writeFileSync(
422+
reportPath,
423+
JSON.stringify({ passed, checks, errors, desktopExit, rendererCrashed }, null, 2)
424+
)
284425
await app?.close()
285426
await new Promise<void>((resolve) => server.close(() => resolve()))
286427
rmSync(userData, { recursive: true, force: true })

0 commit comments

Comments
 (0)