Skip to content

Commit d10b2f2

Browse files
authored
feat(desktop): quick chat window on a global shortcut (#10025)
* feat(desktop): quick chat window on a global shortcut * improvement(desktop): share quick chat window policy, cover shortcut and load-failure edges * fix(desktop): keep quick chat off during account teardown and out of app lifetime on Windows and Linux * test(desktop): resize the quick chat within any runner display
1 parent edb7799 commit d10b2f2

19 files changed

Lines changed: 757 additions & 33 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import { mkdtempSync, writeFileSync } from 'node:fs'
2+
import type { Server } from 'node:http'
3+
import { createServer } from 'node:http'
4+
import { tmpdir } from 'node:os'
5+
import { join } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
import type { ElectronApplication, Page } from '@playwright/test'
8+
import { _electron as electron, expect, test } from '@playwright/test'
9+
import type { DesktopPreferences, QuickChatShortcut } from '@sim/desktop-bridge'
10+
import { getErrorMessage } from '@sim/utils/errors'
11+
import { recordCheck } from './check-report'
12+
13+
/**
14+
* Drives the quick chat window in the real shell against a fixture origin: where it opens, that
15+
* it floats with no desktop bridge, that it resizes within its bounds, that "Open in Sim" hands
16+
* the chat to a full window, that the toggle hides and restores the same page, that sign-out
17+
* destroys it, and how its shortcut setting and load failures behave. Each check lands in a JSON
18+
* report at QUICK_CHAT_REPORT_PATH as it finishes.
19+
*/
20+
21+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
22+
const WORKSPACE_ROUTE = '/workspace/ws1/home'
23+
const DEFAULT_SHORTCUT: QuickChatShortcut = 'Alt+Space'
24+
25+
const PAGES: Record<string, string> = {
26+
[WORKSPACE_ROUTE]: '<!doctype html><html><body><h1 id="app">workspace</h1></body></html>',
27+
'/extension/ws1': `<!doctype html><html><body><h1 id="quick">quick-chat</h1>
28+
<a id="open-in-sim" href="/workspace/ws1/chat/c1" target="_blank" rel="noopener">Open in Sim</a>
29+
</body></html>`,
30+
'/workspace/ws1/chat/c1':
31+
'<!doctype html><html><body><h1 id="chat">handed-off</h1></body></html>',
32+
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
33+
}
34+
35+
interface FixtureServer {
36+
server: Server
37+
origin: string
38+
/** Drops every connection for `path`, so its loads fail, until `failing` is turned off again. */
39+
setFailing: (path: string, failing: boolean) => void
40+
}
41+
42+
function startFixtureServer(): Promise<FixtureServer> {
43+
const failing = new Set<string>()
44+
return new Promise((resolvePromise) => {
45+
const server = createServer((request, response) => {
46+
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
47+
if (failing.has(path)) {
48+
request.socket.destroy()
49+
return
50+
}
51+
if (path === '/api/auth/sign-out') {
52+
response.writeHead(200, { 'Content-Type': 'application/json' }).end('{}')
53+
return
54+
}
55+
const body = PAGES[path]
56+
if (!body) {
57+
response.writeHead(404, { 'Content-Type': 'text/html' }).end('<h1>not found</h1>')
58+
return
59+
}
60+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(body)
61+
})
62+
server.listen(0, '127.0.0.1', () => {
63+
const address = server.address()
64+
const port = typeof address === 'object' && address ? address.port : 0
65+
resolvePromise({
66+
server,
67+
origin: `http://127.0.0.1:${port}`,
68+
setFailing: (path, on) => {
69+
if (on) failing.add(path)
70+
else failing.delete(path)
71+
},
72+
})
73+
})
74+
})
75+
}
76+
77+
function launchApp(origin: string, settings: string | Record<string, unknown> = {}) {
78+
const userData = mkdtempSync(join(tmpdir(), 'sim-desktop-quick-chat-'))
79+
writeFileSync(
80+
join(userData, 'settings.json'),
81+
typeof settings === 'string'
82+
? settings
83+
: JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE, ...settings })
84+
)
85+
return electron.launch({
86+
args: ['.'],
87+
cwd: DESKTOP_DIR,
88+
env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
89+
})
90+
}
91+
92+
/** Clicks an application-menu item by label, the way a user choosing it from the menu bar would. */
93+
function clickMenu(app: ElectronApplication, label: string): Promise<void> {
94+
return app.evaluate(({ Menu }, label) => {
95+
const find = (items: Electron.MenuItem[]): Electron.MenuItem | null => {
96+
for (const item of items) {
97+
if (item.label === label) return item
98+
const found = item.submenu ? find(item.submenu.items) : null
99+
if (found) return found
100+
}
101+
return null
102+
}
103+
const item = find(Menu.getApplicationMenu()?.items ?? [])
104+
if (!item) throw new Error(`No menu item ${label}`)
105+
item.click()
106+
}, label)
107+
}
108+
109+
/** Native state of the window showing `url`, or null once no window shows it. */
110+
function windowState(app: ElectronApplication, url: string) {
111+
return app.evaluate(({ BrowserWindow }, url) => {
112+
const win = BrowserWindow.getAllWindows().find(
113+
(candidate) => !candidate.isDestroyed() && candidate.webContents.getURL() === url
114+
)
115+
return win
116+
? {
117+
visible: win.isVisible(),
118+
focused: win.isFocused(),
119+
alwaysOnTop: win.isAlwaysOnTop(),
120+
size: win.getSize(),
121+
}
122+
: null
123+
}, url)
124+
}
125+
126+
/** Resizes the window showing `url` the way dragging its edge would. */
127+
function resizeWindow(app: ElectronApplication, url: string, width: number, height: number) {
128+
return app.evaluate(
129+
({ BrowserWindow }, { url, width, height }) => {
130+
BrowserWindow.getAllWindows()
131+
.find((win) => win.webContents.getURL() === url)
132+
?.setSize(width, height)
133+
},
134+
{ url, width, height }
135+
)
136+
}
137+
138+
const hasBridge = (page: Page) =>
139+
page.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop !== 'undefined')
140+
141+
/** Changes the shortcut through the settings bridge, as the desktop settings page does. */
142+
const setShortcut = (page: Page, shortcut: string | null) =>
143+
page.evaluate(
144+
(value) =>
145+
(
146+
globalThis as unknown as {
147+
simDesktop: {
148+
settings: { setQuickChatShortcut(v: unknown): Promise<DesktopPreferences> }
149+
}
150+
}
151+
).simDesktop.settings.setQuickChatShortcut(value),
152+
shortcut
153+
)
154+
155+
const isRegistered = (app: ElectronApplication, shortcut: string) =>
156+
app.evaluate(({ globalShortcut }, value) => globalShortcut.isRegistered(value), shortcut)
157+
158+
/** Runs one named check and records its outcome as soon as it is known. */
159+
async function check(name: string, body: () => Promise<void>) {
160+
const reportPath = process.env.QUICK_CHAT_REPORT_PATH ?? test.info().outputPath('quick-chat.json')
161+
const started = Date.now()
162+
const record = (status: 'passed' | 'failed', error?: unknown) =>
163+
recordCheck(reportPath, 'quick-chat', {
164+
name,
165+
status,
166+
durationMs: Date.now() - started,
167+
retry: test.info().retry,
168+
...(error === undefined ? {} : { error: getErrorMessage(error) }),
169+
})
170+
try {
171+
await body()
172+
record('passed')
173+
} catch (error) {
174+
record('failed', error)
175+
throw error
176+
}
177+
}
178+
179+
test.describe('quick chat window', () => {
180+
let fixture: FixtureServer
181+
let app: ElectronApplication | undefined
182+
183+
test.beforeAll(async () => {
184+
fixture = await startFixtureServer()
185+
})
186+
187+
test.afterAll(() => {
188+
fixture.server.close()
189+
})
190+
191+
test.afterEach(async () => {
192+
await app?.close().catch(() => {})
193+
})
194+
195+
test('opens, resizes, hands off, toggles, and closes on sign-out', async () => {
196+
const launched = await launchApp(fixture.origin)
197+
app = launched
198+
const main = await launched.firstWindow()
199+
await expect(main.locator('#app')).toHaveText('workspace')
200+
const quickUrl = `${fixture.origin}/extension/ws1`
201+
let quick: Page | undefined
202+
203+
await check('opens the compact chat for the last workspace as a floating window', async () => {
204+
const opened = launched.waitForEvent('window')
205+
await clickMenu(launched, 'Quick Chat')
206+
quick = await opened
207+
await expect(quick.locator('#quick')).toHaveText('quick-chat')
208+
expect(quick.url()).toBe(quickUrl)
209+
await expect
210+
.poll(() => windowState(launched, quickUrl))
211+
.toMatchObject({ visible: true, alwaysOnTop: true })
212+
})
213+
214+
await check('the floating page gets no desktop bridge while full windows keep it', async () => {
215+
if (!quick) throw new Error('Quick chat did not open')
216+
expect(await hasBridge(quick)).toBe(false)
217+
expect(await hasBridge(main)).toBe(true)
218+
})
219+
220+
await check('resizing holds the minimum size and the page follows the window', async () => {
221+
if (!quick) throw new Error('Quick chat did not open')
222+
const page = quick
223+
await resizeWindow(launched, quickUrl, 200, 200)
224+
await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [360, 420] })
225+
// Small enough for any runner display; macOS clamps a window to the screen it is on.
226+
await resizeWindow(launched, quickUrl, 600, 560)
227+
await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [600, 560] })
228+
await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(600)
229+
})
230+
231+
await check(
232+
'Open in Sim hands the chat to a full window and leaves the panel open',
233+
async () => {
234+
if (!quick) throw new Error('Quick chat did not open')
235+
const handedOff = launched.waitForEvent('window')
236+
await quick.locator('#open-in-sim').click()
237+
const full = await handedOff
238+
await expect(full.locator('#chat')).toHaveText('handed-off')
239+
expect(await hasBridge(full)).toBe(true)
240+
expect(await windowState(launched, quickUrl)).toMatchObject({
241+
visible: true,
242+
alwaysOnTop: true,
243+
})
244+
}
245+
)
246+
247+
await check(
248+
'the toggle brings a background panel forward, then hides it, keeping the page',
249+
async () => {
250+
if (!quick) throw new Error('Quick chat did not open')
251+
await quick.evaluate(() => {
252+
;(globalThis as { quickChatMarker?: string }).quickChatMarker = 'kept'
253+
})
254+
// The hand-off focused the full window; the app must be active for window focus to land.
255+
await launched.evaluate(({ app }) => app.focus({ steal: true }))
256+
await clickMenu(launched, 'Quick Chat')
257+
await expect
258+
.poll(() => windowState(launched, quickUrl))
259+
.toMatchObject({ visible: true, focused: true })
260+
await clickMenu(launched, 'Quick Chat')
261+
await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ visible: false })
262+
await clickMenu(launched, 'Quick Chat')
263+
await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ visible: true })
264+
expect(
265+
await quick.evaluate(() => (globalThis as { quickChatMarker?: string }).quickChatMarker)
266+
).toBe('kept')
267+
}
268+
)
269+
270+
await check('signing out destroys the panel with the account it belonged to', async () => {
271+
await clickMenu(launched, 'Sign Out')
272+
// Found as the only always-on-top window, not by URL: sign-out sends full windows to /login.
273+
await expect
274+
.poll(
275+
() =>
276+
launched.evaluate(({ BrowserWindow }) =>
277+
BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isAlwaysOnTop())
278+
),
279+
{ timeout: 30_000 }
280+
)
281+
.toBe(false)
282+
})
283+
})
284+
285+
test('the shortcut setting and a failed first load', async () => {
286+
test.skip(process.platform !== 'darwin', 'Only macOS binds a shortcut by default')
287+
const launched = await launchApp(fixture.origin, { quickChatShortcut: 'CommandOrControl+Q' })
288+
app = launched
289+
const main = await launched.firstWindow()
290+
await expect(main.locator('#app')).toHaveText('workspace')
291+
292+
await check('a stored shortcut outside the presets falls back to the default', async () => {
293+
const preferences = await main.evaluate(() =>
294+
(
295+
globalThis as unknown as {
296+
simDesktop: { settings: { getPreferences(): Promise<DesktopPreferences> } }
297+
}
298+
).simDesktop.settings.getPreferences()
299+
)
300+
expect(preferences).toMatchObject({
301+
quickChatShortcut: DEFAULT_SHORTCUT,
302+
quickChatShortcutAvailable: true,
303+
})
304+
expect(await isRegistered(launched, DEFAULT_SHORTCUT)).toBe(true)
305+
expect(await isRegistered(launched, 'CommandOrControl+Q')).toBe(false)
306+
})
307+
308+
await check('choosing another shortcut releases the old one and binds the new', async () => {
309+
const next: QuickChatShortcut = 'CommandOrControl+Shift+Space'
310+
expect(await setShortcut(main, next)).toMatchObject({
311+
quickChatShortcut: next,
312+
quickChatShortcutAvailable: true,
313+
})
314+
expect(await isRegistered(launched, next)).toBe(true)
315+
expect(await isRegistered(launched, DEFAULT_SHORTCUT)).toBe(false)
316+
})
317+
318+
await check(
319+
'turning it off releases the shortcut and refuses values off the list',
320+
async () => {
321+
expect(await setShortcut(main, null)).toMatchObject({
322+
quickChatShortcut: null,
323+
quickChatShortcutAvailable: true,
324+
})
325+
expect(await isRegistered(launched, 'CommandOrControl+Shift+Space')).toBe(false)
326+
expect(await setShortcut(main, 'CommandOrControl+Q')).toMatchObject({
327+
quickChatShortcut: null,
328+
})
329+
expect(await isRegistered(launched, 'CommandOrControl+Q')).toBe(false)
330+
}
331+
)
332+
333+
await check('a failed first load reloads when the panel is shown again', async () => {
334+
fixture.setFailing('/extension/ws1', true)
335+
const opened = launched.waitForEvent('window')
336+
await clickMenu(launched, 'Quick Chat')
337+
const quick = await opened
338+
await quick.waitForLoadState().catch(() => {})
339+
expect(await quick.locator('#quick').count()).toBe(0)
340+
fixture.setFailing('/extension/ws1', false)
341+
await launched.evaluate(({ app }) => app.focus({ steal: true }))
342+
// Pressing the shortcut either hides a focused panel or shows it again; each show after the
343+
// failure reloads, so a few presses must land on the loaded page.
344+
await expect
345+
.poll(
346+
async () => {
347+
if ((await quick.locator('#quick').count()) > 0) return true
348+
await clickMenu(launched, 'Quick Chat')
349+
return false
350+
},
351+
{ intervals: [1_000], timeout: 15_000 }
352+
)
353+
.toBe(true)
354+
await expect(quick.locator('#quick')).toHaveText('quick-chat')
355+
})
356+
})
357+
358+
test('stays closed while account data cannot be stored', async () => {
359+
// An unreadable settings file blocks persistence, which makes account data unavailable.
360+
const launched = await launchApp(fixture.origin, '[]')
361+
app = launched
362+
await launched.firstWindow()
363+
await check(
364+
'the shortcut opens nothing while the window could not hold an account',
365+
async () => {
366+
await clickMenu(launched, 'Quick Chat')
367+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_500))
368+
expect(
369+
await launched.evaluate(({ BrowserWindow }) =>
370+
BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isAlwaysOnTop())
371+
)
372+
).toBe(false)
373+
}
374+
)
375+
})
376+
})

‎apps/desktop/src/main/app-routes.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,16 @@ export function settingsRoute(lastRoute: string | undefined): string {
6565
const organizationId = organizationIdFromRoute(lastRoute)
6666
return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE
6767
}
68+
69+
/**
70+
* Route for the quick chat window: the web app's compact chat surface (the one the browser
71+
* extension's side panel frames), scoped to the workspace or organization the user was last in.
72+
*/
73+
export function quickChatRoute(lastRoute: string | undefined): string {
74+
const workspaceId = workspaceIdFromRoute(lastRoute)
75+
if (workspaceId) {
76+
return `/extension/${workspaceId}`
77+
}
78+
const organizationId = organizationIdFromRoute(lastRoute)
79+
return organizationId ? `/extension/o/${organizationId}` : '/extension'
80+
}

0 commit comments

Comments
 (0)