From 87512143bb92925938481b2f1e1ea75afe5c1335 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:07:34 -0800 Subject: [PATCH 1/4] feat(desktop): quick chat window on a global shortcut --- apps/desktop/e2e/quick-chat.spec.ts | 221 ++++++++++++++++++ apps/desktop/src/main/app-routes.ts | 13 ++ apps/desktop/src/main/config.ts | 8 +- .../desktop/src/main/desktop-settings.test.ts | 2 + apps/desktop/src/main/desktop-settings.ts | 38 ++- apps/desktop/src/main/index.ts | 34 ++- apps/desktop/src/main/ipc.test.ts | 1 + apps/desktop/src/main/ipc.ts | 10 + apps/desktop/src/main/menu.test.ts | 1 + apps/desktop/src/main/menu.ts | 2 + apps/desktop/src/main/quick-chat.ts | 134 +++++++++++ apps/desktop/src/main/tray.test.ts | 1 + apps/desktop/src/main/tray.ts | 2 + apps/desktop/src/main/window-preferences.ts | 13 +- apps/desktop/src/preload/index.ts | 3 + apps/sim/app/extension/layout.tsx | 12 +- .../settings/components/desktop/desktop.tsx | 61 ++++- packages/desktop-bridge/src/index.ts | 25 ++ 18 files changed, 565 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/e2e/quick-chat.spec.ts create mode 100644 apps/desktop/src/main/quick-chat.ts diff --git a/apps/desktop/e2e/quick-chat.spec.ts b/apps/desktop/e2e/quick-chat.spec.ts new file mode 100644 index 00000000000..317dda4a206 --- /dev/null +++ b/apps/desktop/e2e/quick-chat.spec.ts @@ -0,0 +1,221 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { ElectronApplication, Page } from '@playwright/test' +import { _electron as electron, expect, test } from '@playwright/test' +import { recordCheck } from './check-report' + +/** + * Drives the quick chat window in the real shell against a fixture origin: where it opens, that + * it floats with no desktop bridge, that "Open in Sim" hands the chat to a full window, that the + * toggle hides and restores the same page, and that sign-out destroys it. Each check lands in a + * JSON report at QUICK_CHAT_REPORT_PATH as it finishes. + */ + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const WORKSPACE_ROUTE = '/workspace/ws1/home' + +const PAGES: Record = { + [WORKSPACE_ROUTE]: '

workspace

', + '/extension/ws1': `

quick-chat

+ Open in Sim + `, + '/workspace/ws1/chat/c1': + '

handed-off

', + '/login': '

fixture-login

', +} + +function startFixtureServer(): Promise<{ server: Server; origin: string }> { + return new Promise((resolvePromise) => { + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + if (path === '/api/auth/sign-out') { + response.writeHead(200, { 'Content-Type': 'application/json' }).end('{}') + return + } + const body = PAGES[path] + if (!body) { + response.writeHead(404, { 'Content-Type': 'text/html' }).end('

not found

') + return + } + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(body) + }) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + resolvePromise({ server, origin: `http://127.0.0.1:${port}` }) + }) + }) +} + +function launchApp(origin: string): Promise { + const userData = mkdtempSync(join(tmpdir(), 'sim-desktop-quick-chat-')) + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE }) + ) + return electron.launch({ + args: ['.'], + cwd: DESKTOP_DIR, + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, + }) +} + +/** Clicks an application-menu item by label, the way a user choosing it from the menu bar would. */ +function clickMenu(app: ElectronApplication, label: string): Promise { + return app.evaluate(({ Menu }, label) => { + const find = (items: Electron.MenuItem[]): Electron.MenuItem | null => { + for (const item of items) { + if (item.label === label) return item + const found = item.submenu ? find(item.submenu.items) : null + if (found) return found + } + return null + } + const item = find(Menu.getApplicationMenu()?.items ?? []) + if (!item) throw new Error(`No menu item ${label}`) + item.click() + }, label) +} + +/** Native state of the window showing `url`, or null once no window shows it. */ +function windowState(app: ElectronApplication, url: string) { + return app.evaluate(({ BrowserWindow }, url) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => !candidate.isDestroyed() && candidate.webContents.getURL() === url + ) + return win + ? { visible: win.isVisible(), focused: win.isFocused(), alwaysOnTop: win.isAlwaysOnTop() } + : null + }, url) +} + +const hasBridge = (page: Page) => + page.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop !== 'undefined') + +test.describe('quick chat window', () => { + let server: Server + let origin: string + let app: ElectronApplication | undefined + + test.beforeAll(async () => { + ;({ server, origin } = await startFixtureServer()) + }) + + test.afterAll(() => { + server.close() + }) + + test.afterEach(async () => { + await app?.close().catch(() => {}) + }) + + test('opens, hands off, toggles, and closes on sign-out', async () => { + const reportPath = + process.env.QUICK_CHAT_REPORT_PATH ?? test.info().outputPath('quick-chat.json') + const check = async (name: string, body: () => Promise) => { + const started = Date.now() + try { + await body() + recordCheck(reportPath, 'quick-chat', { + name, + status: 'passed', + durationMs: Date.now() - started, + retry: test.info().retry, + }) + } catch (error) { + recordCheck(reportPath, 'quick-chat', { + name, + status: 'failed', + durationMs: Date.now() - started, + retry: test.info().retry, + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + } + + app = await launchApp(origin) + const launched = app + const main = await launched.firstWindow() + await expect(main.locator('#app')).toHaveText('workspace') + const quickUrl = `${origin}/extension/ws1` + let quick: Page | undefined + + await check('opens the compact chat for the last workspace as a floating window', async () => { + const opened = launched.waitForEvent('window') + await clickMenu(launched, 'Quick Chat') + quick = await opened + await expect(quick.locator('#quick')).toHaveText('quick-chat') + expect(quick.url()).toBe(quickUrl) + await expect + .poll(() => windowState(launched, quickUrl)) + .toMatchObject({ visible: true, alwaysOnTop: true }) + }) + + await check('the floating page gets no desktop bridge while full windows keep it', async () => { + if (!quick) throw new Error('Quick chat did not open') + expect(await hasBridge(quick)).toBe(false) + expect(await hasBridge(main)).toBe(true) + }) + + await check( + 'Open in Sim hands the chat to a full window and leaves the panel open', + async () => { + if (!quick) throw new Error('Quick chat did not open') + const handedOff = launched.waitForEvent('window') + await quick.locator('#open-in-sim').click() + const full = await handedOff + await expect(full.locator('#chat')).toHaveText('handed-off') + expect(await hasBridge(full)).toBe(true) + expect(await windowState(launched, quickUrl)).toMatchObject({ + visible: true, + alwaysOnTop: true, + }) + } + ) + + await check( + 'the toggle brings a background panel forward, then hides it, keeping the page', + async () => { + if (!quick) throw new Error('Quick chat did not open') + await quick.evaluate(() => { + ;(globalThis as { quickChatMarker?: string }).quickChatMarker = 'kept' + }) + // The hand-off focused the full window; the app must be active for window focus to land. + await launched.evaluate(({ app }) => app.focus({ steal: true })) + await clickMenu(launched, 'Quick Chat') + await expect + .poll(() => windowState(launched, quickUrl)) + .toMatchObject({ + visible: true, + focused: true, + }) + await clickMenu(launched, 'Quick Chat') + await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ visible: false }) + await clickMenu(launched, 'Quick Chat') + await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ visible: true }) + expect( + await quick.evaluate(() => (globalThis as { quickChatMarker?: string }).quickChatMarker) + ).toBe('kept') + } + ) + + await check('signing out destroys the panel with the account it belonged to', async () => { + await clickMenu(launched, 'Sign Out') + // Found as the only always-on-top window, not by URL: sign-out sends full windows to /login. + await expect + .poll( + () => + launched.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isAlwaysOnTop()) + ), + { timeout: 30_000 } + ) + .toBe(false) + }) + }) +}) diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts index 22d0ad9f9e5..7f4065f2a17 100644 --- a/apps/desktop/src/main/app-routes.ts +++ b/apps/desktop/src/main/app-routes.ts @@ -65,3 +65,16 @@ export function settingsRoute(lastRoute: string | undefined): string { const organizationId = organizationIdFromRoute(lastRoute) return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE } + +/** + * Route for the quick chat window: the web app's compact chat surface (the one the browser + * extension's side panel frames), scoped to the workspace or organization the user was last in. + */ +export function quickChatRoute(lastRoute: string | undefined): string { + const workspaceId = workspaceIdFromRoute(lastRoute) + if (workspaceId) { + return `/extension/${workspaceId}` + } + const organizationId = organizationIdFromRoute(lastRoute) + return organizationId ? `/extension/o/${organizationId}` : '/extension' +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index e9657914f57..983b58e963c 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -1,5 +1,9 @@ import { readFileSync } from 'node:fs' -import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-bridge' +import type { + DesktopZoomPercent, + QuickChatShortcut, + TerminalAppearanceTheme, +} from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/ssrf' import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file' @@ -109,6 +113,8 @@ export interface DesktopSettings { fullFileAccess?: boolean /** Keep the machine awake while a chat is running desktop work in the background. */ preventSleepWhileRunning?: boolean + /** Global shortcut for the quick chat window; null is off, absent is the platform default. */ + quickChatShortcut?: QuickChatShortcut | null /** Native app access is opt-in and cleared when the signed-in account changes. */ computerUseEnabled?: boolean /** Ask before operating an app for the first time; by default every unprotected app is allowed. */ diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index 436125a513c..cc10be1444f 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -30,6 +30,8 @@ function service(window: { url: string; focused: boolean } | null) { setBrowserDefaultZoom: vi.fn(), setTerminalDefaultZoom: vi.fn(), setPreventSleepWhileRunning: vi.fn(), + setQuickChatShortcut: vi.fn(), + isQuickChatShortcutAvailable: () => true, getDefaultBrowserDownloadDirectory: () => '/tmp', chooseBrowserDownloadDirectory: async () => null, }) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index a237202a12f..73103fb8462 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -8,7 +8,9 @@ import { type DesktopZoomPercent, isDesktopAppearanceTheme, isDesktopZoomPercent, + isQuickChatShortcut, isTerminalAppearanceTheme, + type QuickChatShortcut, type TerminalThemeProfile, } from '@sim/desktop-bridge' import type { BrowserWindow } from 'electron' @@ -39,6 +41,7 @@ export interface DesktopSettingsService { setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences setFullFileAccess(enabled: boolean): DesktopPreferences setPreventSleepWhileRunning(enabled: boolean): DesktopPreferences + setQuickChatShortcut(shortcut: QuickChatShortcut | null): DesktopPreferences setAppearancePreference( key: DesktopAppearanceSettingKey, value: DesktopAppearanceTheme @@ -65,6 +68,9 @@ interface DesktopSettingsServiceDeps { setTerminalEnabled: (enabled: boolean) => void /** Starts or stops keeping the machine awake for background work already running. */ setPreventSleepWhileRunning: (enabled: boolean) => void + setQuickChatShortcut: (shortcut: QuickChatShortcut | null) => void + /** False while another app holds the quick chat shortcut. */ + isQuickChatShortcutAvailable: () => boolean /** Repaints current browser tabs when their persisted appearance changes. */ setBrowserTheme: (theme: DesktopAppearanceTheme) => void /** Applies a new default zoom to current and future browser tabs. */ @@ -79,9 +85,24 @@ interface DesktopSettingsServiceDeps { chooseBrowserDownloadDirectory: (defaultPath: string) => Promise } +/** + * ⌥Space is the macOS convention for an assistant launcher. On Windows and Linux Alt+Space opens + * the focused window's system menu, so those platforms default to a chord nothing standard owns. + */ +const DEFAULT_QUICK_CHAT_SHORTCUT: QuickChatShortcut = + process.platform === 'darwin' ? 'Alt+Space' : 'CommandOrControl+Shift+J' + +/** The stored quick chat shortcut: null when turned off, the platform default when never chosen. */ +function readQuickChatShortcut(config: ConfigStore): QuickChatShortcut | null { + const stored = config.get('quickChatShortcut') + if (stored === null) return null + return isQuickChatShortcut(stored) ? stored : DEFAULT_QUICK_CHAT_SHORTCUT +} + function readPreferences( config: ConfigStore, - defaultBrowserDownloadDirectory: string + defaultBrowserDownloadDirectory: string, + quickChatShortcutAvailable: boolean ): DesktopPreferences { const browserTheme = config.get('browserTheme') const browserDefaultZoom = config.get('browserDefaultZoom') @@ -100,6 +121,8 @@ function readPreferences( terminalEnabled: config.get('terminalEnabled') ?? true, fullFileAccess: config.get('fullFileAccess') === true, preventSleepWhileRunning: config.get('preventSleepWhileRunning') ?? true, + quickChatShortcut: readQuickChatShortcut(config), + quickChatShortcutAvailable, browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app', browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100, browserDownloadDirectory: @@ -135,7 +158,12 @@ function showsRoute(window: BrowserWindow, route: string | undefined): boolean { export function createDesktopSettingsService( deps: DesktopSettingsServiceDeps ): DesktopSettingsService { - const read = () => readPreferences(deps.config, deps.getDefaultBrowserDownloadDirectory()) + const read = () => + readPreferences( + deps.config, + deps.getDefaultBrowserDownloadDirectory(), + deps.isQuickChatShortcutAvailable() + ) const applyLaunchAtLogin = (enabled: boolean) => { // Registering an unpackaged Electron binary at login is surprising and @@ -201,6 +229,12 @@ export function createDesktopSettingsService( deps.setPreventSleepWhileRunning(enabled) return read() }, + setQuickChatShortcut(shortcut) { + deps.config.set('quickChatShortcut', shortcut) + deps.config.flush() + deps.setQuickChatShortcut(shortcut) + return read() + }, setAppearancePreference(key, value) { const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined deps.config.set(key, value) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a0efc04faf5..b80b7cc6b7a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,7 +28,7 @@ import { retryAccountDataTeardown, waitForAccountDataMutations, } from '@/main/account-data-generation' -import { newChatRoute, settingsRoute } from '@/main/app-routes' +import { newChatRoute, quickChatRoute, settingsRoute } from '@/main/app-routes' import { activateBrowserScope as activateAgentBrowserScope, cancelTool as cancelAgentBrowserTool, @@ -97,6 +97,7 @@ import { import { installApplicationMenu } from '@/main/menu' import { isAppOrigin, openExternalSafe } from '@/main/navigation' import { createEventLog, installMainProcessFailureObservers } from '@/main/observability' +import { createQuickChat } from '@/main/quick-chat' import { ScopedEventRouter } from '@/main/scoped-event-router' import { installGlobalGuards } from '@/main/security-guards' import { createServerWindow, relaunchApp } from '@/main/server-window' @@ -433,6 +434,8 @@ function main(): void { }, }, { label: 'task resource state', clear: clearDesktopChatSessions }, + // The quick chat page belongs to the signed-out account and holds its chat. + { label: 'quick chat', clear: () => quickChat.close() }, { label: 'local filesystem grants', clear: clearLocalFileAccess }, { label: 'computer use', clear: () => computerUse.reset() }, ] @@ -641,6 +644,29 @@ function main(): void { showMainWindow(win) } + const quickChat = createQuickChat({ + appOrigin, + partition: () => { + // Configures the partition's permission and header handlers on first use, as a full window does. + ensureAppSession() + return appPartition() + }, + startRoute: () => quickChatRoute(config.get('lastRoute')), + themeBackground: () => config.get('themeBackground'), + isPackaged: app.isPackaged, + windowPolicy: { + appOrigin, + // The macOS panel never activates Sim, so a window it opens must bring the app forward itself. + openAppWindow: (url) => { + const route = routeFromAppUrl(url) + if (route) void createAndLoadAppWindow({ route }).then(showMainWindow) + }, + allowHttpLocalhost: allowHttpLocalhost(), + isCommittedRelaunchPending: () => committedRelaunchPending, + }, + contextMenu: { isDev: !app.isPackaged, allowHttpLocalhost: allowHttpLocalhost() }, + }) + /** Installs or removes the menu-bar status item; safe to call repeatedly. */ function setTrayEnabled(enabled: boolean): void { if (!enabled) { @@ -654,6 +680,7 @@ function main(): void { appOrigin, lastRoute: () => config.get('lastRoute'), openMainWindow: (route) => void openMainWindowAt(route), + openQuickChat: quickChat.toggle, }) } } @@ -685,6 +712,8 @@ function main(): void { desktopExecutor.refreshRegistration() }, setPreventSleepWhileRunning: () => sleepBlocker.refresh(), + setQuickChatShortcut: (shortcut) => quickChat.bindShortcut(shortcut), + isQuickChatShortcutAvailable: () => quickChat.isShortcutAvailable(), setBrowserTheme: setAgentBrowserTheme, setBrowserDefaultZoom: setAgentBrowserDefaultZoom, setTerminalDefaultZoom: (zoom) => { @@ -895,6 +924,7 @@ function main(): void { tray = null computerUse.cancel() if (computerStopShortcutRegistered) globalShortcut.unregister(computerStopShortcut) + quickChat.bindShortcut(null) localFilesystem.close() quiesceBrowserSessions() terminal.dispose() @@ -1148,6 +1178,7 @@ function main(): void { }, newWindow: () => void createAndLoadAppWindow(), newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), + openQuickChat: quickChat.toggle, handleFocusedResourceShortcut: (win, shortcut) => handleFocusedBrowserShortcut(shortcut, win) || terminal.handleFocusedShortcut(win, shortcut), @@ -1160,6 +1191,7 @@ function main(): void { }) installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) + quickChat.bindShortcut(desktopSettings.getPreferences().quickChatShortcut ?? null) updater = initUpdater({ getWindow: getMainWindow, events, diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 030c9620362..c4fab9ad534 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -341,6 +341,7 @@ describe('registerIpcHandlers', () => { setBrowserSearchSuggestionsEnabled: vi.fn(), setFullFileAccess: vi.fn(), setPreventSleepWhileRunning: vi.fn(), + setQuickChatShortcut: vi.fn(), setAppearancePreference: vi.fn(), setBrowserDefaultZoom: vi.fn(), setTerminalDefaultZoom: vi.fn(), diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 068b315b7e7..0d60c338715 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -23,6 +23,7 @@ import { isDesktopScopeId, isDesktopZoomPercent, isPendingDesktopScopeId, + isQuickChatShortcut, } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { @@ -942,6 +943,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { ? deps.settings.setPreventSleepWhileRunning(enabled) : deps.settings.getPreferences(), }, + 'desktop:settings:set-quick-chat-shortcut': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: (shortcut) => + shortcut === null || isQuickChatShortcut(shortcut) + ? deps.settings.setQuickChatShortcut(shortcut) + : deps.settings.getPreferences(), + }, 'desktop:settings:set-appearance': { kind: 'invoke', gate: 'app-origin', diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 2eb8862dc32..fc5eaed5b98 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -23,6 +23,7 @@ function makeDeps(origin = 'https://sim.ai'): MenuDeps { openFolderAccess: vi.fn(), newWindow: vi.fn(), newChat: vi.fn(), + openQuickChat: vi.fn(), handleFocusedResourceShortcut: vi.fn(() => false), toggleSidebar: vi.fn(), openSearch: vi.fn(), diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index b6bfb58fcf7..6e9e9e75597 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -22,6 +22,7 @@ export interface MenuDeps { openFolderAccess: (parent: BrowserWindow) => void newWindow: () => void newChat: () => void + openQuickChat: () => void /** * Menu accelerators are global, so the focused Browser or Terminal gets the * first chance to claim every resource shortcut before the Sim window uses @@ -227,6 +228,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] click: deps.newWindow, }, { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, + { label: 'Quick Chat', click: deps.openQuickChat }, { type: 'separator' }, { label: 'Close Window', diff --git a/apps/desktop/src/main/quick-chat.ts b/apps/desktop/src/main/quick-chat.ts new file mode 100644 index 00000000000..0c85371fcd1 --- /dev/null +++ b/apps/desktop/src/main/quick-chat.ts @@ -0,0 +1,134 @@ +import type { QuickChatShortcut } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { BrowserWindow, globalShortcut, nativeTheme, screen } from 'electron' +import { attachContextMenu, type ContextMenuDeps } from '@/main/context-menu' +import { backgroundColorFor } from '@/main/shell-theme' +import { handleWillPreventUnload } from '@/main/window' +import { createSecureWebPreferences } from '@/main/window-preferences' +import { attachWindowOpenPolicy, type WindowPolicyDeps } from '@/main/windows' + +const logger = createLogger('QuickChat') + +const WIDTH = 440 +const HEIGHT = 620 +const MIN_WIDTH = 360 +const MIN_HEIGHT = 420 + +/** Chromium's code for a navigation the page itself replaced, which is not a failure. */ +const ERR_ABORTED = -3 + +export interface QuickChatDeps { + appOrigin: () => string + partition: () => string + /** The compact chat route to open, scoped to where the user last was. */ + startRoute: () => string + themeBackground: () => 'dark' | 'light' | undefined + isPackaged: boolean + windowPolicy: WindowPolicyDeps + contextMenu: ContextMenuDeps +} + +export interface QuickChat { + /** Shows and focuses the window, creating it on first use, or hides it when it already has focus. */ + toggle(): void + /** Destroys the window and its page state; the next toggle starts a fresh chat. */ + close(): void + /** Replaces the global shortcut, or removes it with null. */ + bindShortcut(shortcut: QuickChatShortcut | null): void + /** False while another app holds the shortcut, so pressing it never reaches Sim. */ + isShortcutAvailable(): boolean +} + +/** + * A small chat window that floats above every other app until the user closes it, toggled by a + * global shortcut. It loads the web app's compact chat surface with no preload: the page is a + * plain client of the app origin, so it reports no device capabilities and the agent never + * drives the browser, terminal, or apps from a window that sits on top of them. "Open in Sim" + * hands the chat to a full window, where those capabilities live. + */ +export function createQuickChat(deps: QuickChatDeps): QuickChat { + let win: BrowserWindow | null = null + let loadFailed = false + let boundShortcut: QuickChatShortcut | null = null + let shortcutAvailable = true + + const reveal = (target: BrowserWindow) => { + if (loadFailed) { + loadFailed = false + target.webContents.reload() + } + target.show() + target.focus() + } + + const open = (): BrowserWindow => { + loadFailed = false + const { workArea } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const created = new BrowserWindow({ + title: 'Sim', + width: WIDTH, + height: HEIGHT, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + x: Math.round(workArea.x + (workArea.width - WIDTH) / 2), + y: Math.round(workArea.y + (workArea.height - HEIGHT) / 2), + // A macOS panel floats over full-screen apps and on every Space, and takes keyboard focus + // without activating Sim, so summoning it never pulls the main window forward. + ...(process.platform === 'darwin' ? { type: 'panel' } : { skipTaskbar: true }), + alwaysOnTop: true, + fullscreenable: false, + minimizable: false, + maximizable: false, + show: false, + backgroundColor: backgroundColorFor(deps.themeBackground(), nativeTheme.shouldUseDarkColors), + webPreferences: createSecureWebPreferences(deps.partition(), null, deps.isPackaged), + }) + created.once('ready-to-show', () => reveal(created)) + created.on('closed', () => { + if (win === created) win = null + }) + created.webContents.on( + 'did-fail-load', + (_event, errorCode, _description, _url, isMainFrame) => { + if (isMainFrame && errorCode !== ERR_ABORTED) loadFailed = true + } + ) + created.webContents.on('did-finish-load', () => { + loadFailed = false + }) + created.webContents.on('will-prevent-unload', (event) => { + handleWillPreventUnload(created, event, deps.windowPolicy.isCommittedRelaunchPending()) + }) + attachWindowOpenPolicy(created.webContents, deps.windowPolicy) + attachContextMenu(created.webContents, deps.contextMenu) + void created.loadURL(`${deps.appOrigin()}${deps.startRoute()}`).catch(() => {}) + return created + } + + const toggle = () => { + if (!win || win.isDestroyed()) { + win = open() + return + } + if (win.isVisible() && win.isFocused()) win.hide() + else reveal(win) + } + + return { + toggle, + close() { + if (win && !win.isDestroyed()) win.destroy() + win = null + }, + bindShortcut(shortcut) { + if (boundShortcut) globalShortcut.unregister(boundShortcut) + boundShortcut = null + shortcutAvailable = true + if (!shortcut) return + shortcutAvailable = globalShortcut.register(shortcut, toggle) + if (shortcutAvailable) boundShortcut = shortcut + else logger.warn('Quick chat shortcut is held by another app', { shortcut }) + }, + isShortcutAvailable: () => shortcutAvailable, + } +} diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index d17830b2df7..1d9c3bb4fb8 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -13,6 +13,7 @@ function makeDeps(overrides: Partial = {}): TrayDeps { appOrigin: () => 'https://sim.ai', lastRoute: () => '/workspace/ws1/home', openMainWindow: vi.fn(), + openQuickChat: vi.fn(), ...overrides, } } diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 7ae06b73c72..0bf3ea58936 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -332,6 +332,7 @@ export interface TrayDeps { appOrigin: () => string lastRoute: () => string | undefined openMainWindow: (route?: string) => void + openQuickChat: () => void } function chatMenuItem(chat: RecentChat, deps: TrayDeps): MenuItemConstructorOptions { @@ -373,6 +374,7 @@ export function buildTrayMenuTemplate( } template.push( { label: 'New Chat', click: () => deps.openMainWindow(newChatRoute(deps.lastRoute())) }, + { label: 'Quick Chat', click: deps.openQuickChat }, { label: 'Open Sim', click: () => deps.openMainWindow() }, { type: 'separator' }, // Plain item, not role:'quit' — macOS Tahoe auto-decorates standard roles diff --git a/apps/desktop/src/main/window-preferences.ts b/apps/desktop/src/main/window-preferences.ts index 9a92f224a58..188cd7a041e 100644 --- a/apps/desktop/src/main/window-preferences.ts +++ b/apps/desktop/src/main/window-preferences.ts @@ -6,10 +6,13 @@ import { app } from 'electron' * The preload injects nothing into the page; it only exposes a whitelisted * IPC bridge. The shell version rides in as a preload argv flag so the web * app can enforce its minimum shell version without an IPC round-trip. + * + * A null `preloadPath` gives a plain web page with no bridge at all, for + * surfaces that must not report device capabilities (see `quick-chat.ts`). */ export function createSecureWebPreferences( partition: string, - preloadPath: string, + preloadPath: string | null, isPackaged: boolean ): WebPreferences { return { @@ -21,7 +24,11 @@ export function createSecureWebPreferences( devTools: !isPackaged, spellcheck: true, partition, - preload: preloadPath, - additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], + ...(preloadPath + ? { + preload: preloadPath, + additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], + } + : {}), } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 7f06f2b5180..e4a0c216116 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -46,6 +46,7 @@ import type { DesktopZoomPercent, LocalFilesystemRequest, LocalFilesystemResponse, + QuickChatShortcut, SimDesktopApi, TerminalShortcutCommand, TerminalThemeProfile, @@ -217,6 +218,8 @@ const api: SimDesktopApi = { }, setPreventSleepWhileRunning: (enabled: boolean): Promise => ipcRenderer.invoke('desktop:settings:set-prevent-sleep', enabled), + setQuickChatShortcut: (shortcut: QuickChatShortcut | null): Promise => + ipcRenderer.invoke('desktop:settings:set-quick-chat-shortcut', shortcut), setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise => ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled), notify: (payload: DesktopNotificationPayload): Promise => diff --git a/apps/sim/app/extension/layout.tsx b/apps/sim/app/extension/layout.tsx index a8cff1d9d89..083ff5fd7e7 100644 --- a/apps/sim/app/extension/layout.tsx +++ b/apps/sim/app/extension/layout.tsx @@ -13,15 +13,13 @@ interface ExtensionLayoutProps { export const metadata = { title: 'Sim', robots: { index: false, follow: false } } +/** + * The compact chat surface. The browser extension frames it in its side panel, and the desktop + * app's quick chat window opens it top-level, so it renders on deployments without the extension + * too: with no extension origins the proxy's `frame-ancestors 'none'` keeps it unframeable. + */ export default async function ExtensionLayout({ children }: ExtensionLayoutProps) { const origins = getBrowserExtensionOrigins() - if (origins.length === 0) { - return ( -
- The browser extension is not enabled for this Sim deployment. -
- ) - } const session = await getSession() if (!session?.user) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index b96eae87fb4..05f59684683 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -1,9 +1,16 @@ 'use client' import { useEffect, useState } from 'react' -import type { DesktopPreferenceKey, DesktopPreferences } from '@sim/desktop-bridge' -import { Label, Switch, toast } from '@sim/emcn' +import { + type DesktopPreferenceKey, + type DesktopPreferences, + isQuickChatShortcut, + QUICK_CHAT_SHORTCUTS, + type QuickChatShortcut, +} from '@sim/desktop-bridge' +import { ChipSelect, Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' +import { isMacPlatform } from '@/lib/core/utils/platform' import { getDesktopBridge, getDesktopShellVersion } from '@/lib/desktop' import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -19,6 +26,14 @@ interface PreferenceRowProps { onCheckedChange: (checked: boolean) => void } +const QUICK_CHAT_SHORTCUT_LABELS: Record = { + 'Alt+Space': { mac: '⌥ Space', other: 'Alt+Space' }, + 'CommandOrControl+Shift+Space': { mac: '⇧⌘ Space', other: 'Ctrl+Shift+Space' }, + 'CommandOrControl+Shift+J': { mac: '⇧⌘J', other: 'Ctrl+Shift+J' }, +} + +const QUICK_CHAT_SHORTCUT_OFF = 'off' + function PreferenceRow({ id, label, checked, disabled, onCheckedChange }: PreferenceRowProps) { return (
@@ -82,6 +97,14 @@ export function Desktop() { setPreferences ) + const { pending: quickChatShortcutPending, mutate: setQuickChatShortcut } = + useDesktopPreferenceMutation( + async (bridge, shortcut: QuickChatShortcut | null) => + bridge.settings.setQuickChatShortcut?.(shortcut), + 'Could not update the quick chat shortcut', + setPreferences + ) + if (!preferences) { return null } @@ -131,6 +154,40 @@ export function Desktop() { disabled={pendingPreference !== null} onCheckedChange={(checked) => void updatePreference('trayEnabled', checked)} /> + {preferences.quickChatShortcut !== undefined && ( +
+
+ + {preferences.quickChatShortcut && + preferences.quickChatShortcutAvailable === false && ( +

+ Another app is using this shortcut +

+ )} +
+
+ + void setQuickChatShortcut(isQuickChatShortcut(next) ? next : null) + } + disabled={quickChatShortcutPending} + options={[ + { label: 'Off', value: QUICK_CHAT_SHORTCUT_OFF }, + ...QUICK_CHAT_SHORTCUTS.map((shortcut) => ({ + label: + QUICK_CHAT_SHORTCUT_LABELS[shortcut][isMacPlatform() ? 'mac' : 'other'], + value: shortcut, + })), + ]} + /> +
+
+ )} + /** + * Binds the quick chat window to a global shortcut, or unbinds it with null. Optional for + * installed shells that predate quick chat. + */ + setQuickChatShortcut?(shortcut: QuickChatShortcut | null): Promise /** Optional for installed shells that predate the explicit full-file-access setting. */ setFullFileAccess?(enabled: boolean): Promise /** Keeps settings in sync when folder consent changes access in the native shell. */ From 3fe143eb587e8b3dc36a39f74dc3d498eb5c6b4c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:24:41 -0800 Subject: [PATCH 2/4] improvement(desktop): share quick chat window policy, cover shortcut and load-failure edges --- apps/desktop/e2e/quick-chat.spec.ts | 221 ++++++++++++++---- apps/desktop/src/main/desktop-settings.ts | 14 +- apps/desktop/src/main/index.ts | 71 +++--- apps/desktop/src/main/quick-chat.ts | 47 ++-- apps/desktop/src/main/window-preferences.ts | 8 +- .../settings/components/desktop/desktop.tsx | 48 +--- .../desktop/quick-chat-shortcut-select.tsx | 52 +++++ 7 files changed, 304 insertions(+), 157 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/desktop/quick-chat-shortcut-select.tsx diff --git a/apps/desktop/e2e/quick-chat.spec.ts b/apps/desktop/e2e/quick-chat.spec.ts index 317dda4a206..d9094deead2 100644 --- a/apps/desktop/e2e/quick-chat.spec.ts +++ b/apps/desktop/e2e/quick-chat.spec.ts @@ -6,17 +6,22 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { ElectronApplication, Page } from '@playwright/test' import { _electron as electron, expect, test } from '@playwright/test' +import type { DesktopPreferences, QuickChatShortcut } from '@sim/desktop-bridge' +import { getErrorMessage } from '@sim/utils/errors' import { recordCheck } from './check-report' /** * Drives the quick chat window in the real shell against a fixture origin: where it opens, that - * it floats with no desktop bridge, that "Open in Sim" hands the chat to a full window, that the - * toggle hides and restores the same page, and that sign-out destroys it. Each check lands in a - * JSON report at QUICK_CHAT_REPORT_PATH as it finishes. + * it floats with no desktop bridge, that it resizes within its bounds, that "Open in Sim" hands + * the chat to a full window, that the toggle hides and restores the same page, that sign-out + * destroys it, and how its shortcut setting and load failures behave. Each check lands in a JSON + * report at QUICK_CHAT_REPORT_PATH as it finishes. */ const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) const WORKSPACE_ROUTE = '/workspace/ws1/home' +const DEFAULT_SHORTCUT: QuickChatShortcut = + process.platform === 'darwin' ? 'Alt+Space' : 'CommandOrControl+Shift+J' const PAGES: Record = { [WORKSPACE_ROUTE]: '

workspace

', @@ -28,10 +33,22 @@ const PAGES: Record = { '/login': '

fixture-login

', } -function startFixtureServer(): Promise<{ server: Server; origin: string }> { +interface FixtureServer { + server: Server + origin: string + /** Drops every connection for `path`, so its loads fail, until `failing` is turned off again. */ + setFailing: (path: string, failing: boolean) => void +} + +function startFixtureServer(): Promise { + const failing = new Set() return new Promise((resolvePromise) => { const server = createServer((request, response) => { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + if (failing.has(path)) { + request.socket.destroy() + return + } if (path === '/api/auth/sign-out') { response.writeHead(200, { 'Content-Type': 'application/json' }).end('{}') return @@ -46,16 +63,23 @@ function startFixtureServer(): Promise<{ server: Server; origin: string }> { server.listen(0, '127.0.0.1', () => { const address = server.address() const port = typeof address === 'object' && address ? address.port : 0 - resolvePromise({ server, origin: `http://127.0.0.1:${port}` }) + resolvePromise({ + server, + origin: `http://127.0.0.1:${port}`, + setFailing: (path, on) => { + if (on) failing.add(path) + else failing.delete(path) + }, + }) }) }) } -function launchApp(origin: string): Promise { +function launchApp(origin: string, settings: Record = {}) { const userData = mkdtempSync(join(tmpdir(), 'sim-desktop-quick-chat-')) writeFileSync( join(userData, 'settings.json'), - JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE }) + JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE, ...settings }) ) return electron.launch({ args: ['.'], @@ -88,61 +112,91 @@ function windowState(app: ElectronApplication, url: string) { (candidate) => !candidate.isDestroyed() && candidate.webContents.getURL() === url ) return win - ? { visible: win.isVisible(), focused: win.isFocused(), alwaysOnTop: win.isAlwaysOnTop() } + ? { + visible: win.isVisible(), + focused: win.isFocused(), + alwaysOnTop: win.isAlwaysOnTop(), + size: win.getSize(), + } : null }, url) } +/** Resizes the window showing `url` the way dragging its edge would. */ +function resizeWindow(app: ElectronApplication, url: string, width: number, height: number) { + return app.evaluate( + ({ BrowserWindow }, { url, width, height }) => { + BrowserWindow.getAllWindows() + .find((win) => win.webContents.getURL() === url) + ?.setSize(width, height) + }, + { url, width, height } + ) +} + const hasBridge = (page: Page) => page.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop !== 'undefined') +/** Changes the shortcut through the settings bridge, as the desktop settings page does. */ +const setShortcut = (page: Page, shortcut: string | null) => + page.evaluate( + (value) => + ( + globalThis as unknown as { + simDesktop: { + settings: { setQuickChatShortcut(v: unknown): Promise } + } + } + ).simDesktop.settings.setQuickChatShortcut(value), + shortcut + ) + +const isRegistered = (app: ElectronApplication, shortcut: string) => + app.evaluate(({ globalShortcut }, value) => globalShortcut.isRegistered(value), shortcut) + +/** Runs one named check and records its outcome as soon as it is known. */ +async function check(name: string, body: () => Promise) { + const reportPath = process.env.QUICK_CHAT_REPORT_PATH ?? test.info().outputPath('quick-chat.json') + const started = Date.now() + const record = (status: 'passed' | 'failed', error?: unknown) => + recordCheck(reportPath, 'quick-chat', { + name, + status, + durationMs: Date.now() - started, + retry: test.info().retry, + ...(error === undefined ? {} : { error: getErrorMessage(error) }), + }) + try { + await body() + record('passed') + } catch (error) { + record('failed', error) + throw error + } +} + test.describe('quick chat window', () => { - let server: Server - let origin: string + let fixture: FixtureServer let app: ElectronApplication | undefined test.beforeAll(async () => { - ;({ server, origin } = await startFixtureServer()) + fixture = await startFixtureServer() }) test.afterAll(() => { - server.close() + fixture.server.close() }) test.afterEach(async () => { await app?.close().catch(() => {}) }) - test('opens, hands off, toggles, and closes on sign-out', async () => { - const reportPath = - process.env.QUICK_CHAT_REPORT_PATH ?? test.info().outputPath('quick-chat.json') - const check = async (name: string, body: () => Promise) => { - const started = Date.now() - try { - await body() - recordCheck(reportPath, 'quick-chat', { - name, - status: 'passed', - durationMs: Date.now() - started, - retry: test.info().retry, - }) - } catch (error) { - recordCheck(reportPath, 'quick-chat', { - name, - status: 'failed', - durationMs: Date.now() - started, - retry: test.info().retry, - error: error instanceof Error ? error.message : String(error), - }) - throw error - } - } - - app = await launchApp(origin) - const launched = app + test('opens, resizes, hands off, toggles, and closes on sign-out', async () => { + const launched = await launchApp(fixture.origin) + app = launched const main = await launched.firstWindow() await expect(main.locator('#app')).toHaveText('workspace') - const quickUrl = `${origin}/extension/ws1` + const quickUrl = `${fixture.origin}/extension/ws1` let quick: Page | undefined await check('opens the compact chat for the last workspace as a floating window', async () => { @@ -162,6 +216,16 @@ test.describe('quick chat window', () => { expect(await hasBridge(main)).toBe(true) }) + await check('resizing holds the minimum size and the page follows the window', async () => { + if (!quick) throw new Error('Quick chat did not open') + const page = quick + await resizeWindow(launched, quickUrl, 200, 200) + await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [360, 420] }) + await resizeWindow(launched, quickUrl, 720, 900) + await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [720, 900] }) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(720) + }) + await check( 'Open in Sim hands the chat to a full window and leaves the panel open', async () => { @@ -190,10 +254,7 @@ test.describe('quick chat window', () => { await clickMenu(launched, 'Quick Chat') await expect .poll(() => windowState(launched, quickUrl)) - .toMatchObject({ - visible: true, - focused: true, - }) + .toMatchObject({ visible: true, focused: true }) await clickMenu(launched, 'Quick Chat') await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ visible: false }) await clickMenu(launched, 'Quick Chat') @@ -218,4 +279,76 @@ test.describe('quick chat window', () => { .toBe(false) }) }) + + test('the shortcut setting and a failed first load', async () => { + const launched = await launchApp(fixture.origin, { quickChatShortcut: 'CommandOrControl+Q' }) + app = launched + const main = await launched.firstWindow() + await expect(main.locator('#app')).toHaveText('workspace') + + await check('a stored shortcut outside the presets falls back to the default', async () => { + const preferences = await main.evaluate(() => + ( + globalThis as unknown as { + simDesktop: { settings: { getPreferences(): Promise } } + } + ).simDesktop.settings.getPreferences() + ) + expect(preferences).toMatchObject({ + quickChatShortcut: DEFAULT_SHORTCUT, + quickChatShortcutAvailable: true, + }) + expect(await isRegistered(launched, DEFAULT_SHORTCUT)).toBe(true) + expect(await isRegistered(launched, 'CommandOrControl+Q')).toBe(false) + }) + + await check('choosing another shortcut releases the old one and binds the new', async () => { + const next: QuickChatShortcut = 'CommandOrControl+Shift+Space' + expect(await setShortcut(main, next)).toMatchObject({ + quickChatShortcut: next, + quickChatShortcutAvailable: true, + }) + expect(await isRegistered(launched, next)).toBe(true) + expect(await isRegistered(launched, DEFAULT_SHORTCUT)).toBe(false) + }) + + await check( + 'turning it off releases the shortcut and refuses values off the list', + async () => { + expect(await setShortcut(main, null)).toMatchObject({ + quickChatShortcut: null, + quickChatShortcutAvailable: true, + }) + expect(await isRegistered(launched, 'CommandOrControl+Shift+Space')).toBe(false) + expect(await setShortcut(main, 'CommandOrControl+Q')).toMatchObject({ + quickChatShortcut: null, + }) + expect(await isRegistered(launched, 'CommandOrControl+Q')).toBe(false) + } + ) + + await check('a failed first load reloads when the panel is shown again', async () => { + fixture.setFailing('/extension/ws1', true) + const opened = launched.waitForEvent('window') + await clickMenu(launched, 'Quick Chat') + const quick = await opened + await quick.waitForLoadState().catch(() => {}) + expect(await quick.locator('#quick').count()).toBe(0) + fixture.setFailing('/extension/ws1', false) + await launched.evaluate(({ app }) => app.focus({ steal: true })) + // Pressing the shortcut either hides a focused panel or shows it again; each show after the + // failure reloads, so a few presses must land on the loaded page. + await expect + .poll( + async () => { + if ((await quick.locator('#quick').count()) > 0) return true + await clickMenu(launched, 'Quick Chat') + return false + }, + { intervals: [1_000], timeout: 15_000 } + ) + .toBe(true) + await expect(quick.locator('#quick')).toHaveText('quick-chat') + }) + }) }) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index 73103fb8462..dd5dd27447e 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -101,8 +101,7 @@ function readQuickChatShortcut(config: ConfigStore): QuickChatShortcut | null { function readPreferences( config: ConfigStore, - defaultBrowserDownloadDirectory: string, - quickChatShortcutAvailable: boolean + defaultBrowserDownloadDirectory: string ): DesktopPreferences { const browserTheme = config.get('browserTheme') const browserDefaultZoom = config.get('browserDefaultZoom') @@ -122,7 +121,6 @@ function readPreferences( fullFileAccess: config.get('fullFileAccess') === true, preventSleepWhileRunning: config.get('preventSleepWhileRunning') ?? true, quickChatShortcut: readQuickChatShortcut(config), - quickChatShortcutAvailable, browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app', browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100, browserDownloadDirectory: @@ -158,12 +156,10 @@ function showsRoute(window: BrowserWindow, route: string | undefined): boolean { export function createDesktopSettingsService( deps: DesktopSettingsServiceDeps ): DesktopSettingsService { - const read = () => - readPreferences( - deps.config, - deps.getDefaultBrowserDownloadDirectory(), - deps.isQuickChatShortcutAvailable() - ) + const read = (): DesktopPreferences => ({ + ...readPreferences(deps.config, deps.getDefaultBrowserDownloadDirectory()), + quickChatShortcutAvailable: deps.isQuickChatShortcutAvailable(), + }) const applyLaunchAtLogin = (enabled: boolean) => { // Registering an unpackaged Electron binary at login is surprising and diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b80b7cc6b7a..ef6cdc67bcf 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -67,7 +67,7 @@ import { isSafeInternalPath, partitionForOrigin, } from '@/main/config' -import { attachContextMenu } from '@/main/context-menu' +import { attachContextMenu, type ContextMenuDeps } from '@/main/context-menu' import { attachCspFallback } from '@/main/csp' import { DesktopChatSessionStore } from '@/main/desktop-chat-session-store' import { createApprovalNotifier } from '@/main/desktop-executor/approval-notifier' @@ -118,7 +118,7 @@ import { installTray, type TrayHandle } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' import { installBrowserUserAgent } from '@/main/user-agent' import { createMainWindow, setupPermissionHandlers } from '@/main/window' -import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows' +import { attachWindowOpenPolicy, isPopupContents, type WindowPolicyDeps } from '@/main/windows' const logger = createLogger('DesktopMain') @@ -500,6 +500,28 @@ function main(): void { } } + /** + * Window-open policy for app pages: internal URLs open as full Sim windows. `activate` brings + * the app forward for a caller that never activates it itself, like the macOS quick chat panel. + */ + function appWindowPolicy({ activate }: { activate: boolean }): WindowPolicyDeps { + return { + appOrigin, + openAppWindow: (url) => { + const route = routeFromAppUrl(url) + if (!route) return + const opened = createAndLoadAppWindow({ route }) + if (activate) void opened.then(showMainWindow) + }, + allowHttpLocalhost: allowHttpLocalhost(), + isCommittedRelaunchPending: () => committedRelaunchPending, + } + } + + function appContextMenuDeps(): ContextMenuDeps { + return { isDev: !app.isPackaged, allowHttpLocalhost: allowHttpLocalhost() } + } + async function createAndLoadAppWindow({ route: requestedRouteOverride, restorePosition = false, @@ -554,21 +576,8 @@ function main(): void { win.webContents.on('did-start-loading', () => { setBrowserAgentPanelBounds(null, win) }) - attachWindowOpenPolicy(win.webContents, { - appOrigin, - openAppWindow: (url) => { - const route = routeFromAppUrl(url) - if (route) { - void createAndLoadAppWindow({ route }) - } - }, - allowHttpLocalhost: allowHttpLocalhost(), - isCommittedRelaunchPending: () => committedRelaunchPending, - }) - attachContextMenu(win.webContents, { - isDev: !app.isPackaged, - allowHttpLocalhost: allowHttpLocalhost(), - }) + attachWindowOpenPolicy(win.webContents, appWindowPolicy({ activate: false })) + attachContextMenu(win.webContents, appContextMenuDeps()) const loadHealth = attachLoadHealth(win, { offlinePageUrl: (query) => localPageUrl('offline.html', query), getStartUrl: () => `${appOrigin()}${route}`, @@ -645,26 +654,16 @@ function main(): void { } const quickChat = createQuickChat({ - appOrigin, partition: () => { // Configures the partition's permission and header handlers on first use, as a full window does. ensureAppSession() return appPartition() }, - startRoute: () => quickChatRoute(config.get('lastRoute')), + startUrl: () => `${appOrigin()}${quickChatRoute(config.get('lastRoute'))}`, themeBackground: () => config.get('themeBackground'), isPackaged: app.isPackaged, - windowPolicy: { - appOrigin, - // The macOS panel never activates Sim, so a window it opens must bring the app forward itself. - openAppWindow: (url) => { - const route = routeFromAppUrl(url) - if (route) void createAndLoadAppWindow({ route }).then(showMainWindow) - }, - allowHttpLocalhost: allowHttpLocalhost(), - isCommittedRelaunchPending: () => committedRelaunchPending, - }, - contextMenu: { isDev: !app.isPackaged, allowHttpLocalhost: allowHttpLocalhost() }, + windowPolicy: appWindowPolicy({ activate: true }), + contextMenu: appContextMenuDeps(), }) /** Installs or removes the menu-bar status item; safe to call repeatedly. */ @@ -712,8 +711,8 @@ function main(): void { desktopExecutor.refreshRegistration() }, setPreventSleepWhileRunning: () => sleepBlocker.refresh(), - setQuickChatShortcut: (shortcut) => quickChat.bindShortcut(shortcut), - isQuickChatShortcutAvailable: () => quickChat.isShortcutAvailable(), + setQuickChatShortcut: quickChat.bindShortcut, + isQuickChatShortcutAvailable: quickChat.isShortcutAvailable, setBrowserTheme: setAgentBrowserTheme, setBrowserDefaultZoom: setAgentBrowserDefaultZoom, setTerminalDefaultZoom: (zoom) => { @@ -1204,9 +1203,11 @@ function main(): void { }, describeInFlightWork: async () => { // Native activity is read after the window probes so work started meanwhile still counts. - const windows = await countWindowsWithUnsavedChanges( - getWindows().map((win) => win.webContents) - ) + const quickChatPage = quickChat.webContents() + const windows = await countWindowsWithUnsavedChanges([ + ...getWindows().map((win) => win.webContents), + ...(quickChatPage ? [quickChatPage] : []), + ]) const terminalWork = await terminal.runningWork() const computerUseActivity = computerUse.getActivity() return describeInFlightWork({ diff --git a/apps/desktop/src/main/quick-chat.ts b/apps/desktop/src/main/quick-chat.ts index 0c85371fcd1..3c62dc426f4 100644 --- a/apps/desktop/src/main/quick-chat.ts +++ b/apps/desktop/src/main/quick-chat.ts @@ -1,7 +1,9 @@ import type { QuickChatShortcut } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' +import type { WebContents } from 'electron' import { BrowserWindow, globalShortcut, nativeTheme, screen } from 'electron' import { attachContextMenu, type ContextMenuDeps } from '@/main/context-menu' +import { classifyLoadError } from '@/main/load-health' import { backgroundColorFor } from '@/main/shell-theme' import { handleWillPreventUnload } from '@/main/window' import { createSecureWebPreferences } from '@/main/window-preferences' @@ -14,14 +16,10 @@ const HEIGHT = 620 const MIN_WIDTH = 360 const MIN_HEIGHT = 420 -/** Chromium's code for a navigation the page itself replaced, which is not a failure. */ -const ERR_ABORTED = -3 - export interface QuickChatDeps { - appOrigin: () => string partition: () => string - /** The compact chat route to open, scoped to where the user last was. */ - startRoute: () => string + /** The compact chat URL to open, scoped to where the user last was. */ + startUrl: () => string themeBackground: () => 'dark' | 'light' | undefined isPackaged: boolean windowPolicy: WindowPolicyDeps @@ -37,6 +35,8 @@ export interface QuickChat { bindShortcut(shortcut: QuickChatShortcut | null): void /** False while another app holds the shortcut, so pressing it never reaches Sim. */ isShortcutAvailable(): boolean + /** The open window's page, for checks that must see every app page before a relaunch. */ + webContents(): WebContents | null } /** @@ -49,8 +49,7 @@ export interface QuickChat { export function createQuickChat(deps: QuickChatDeps): QuickChat { let win: BrowserWindow | null = null let loadFailed = false - let boundShortcut: QuickChatShortcut | null = null - let shortcutAvailable = true + let shortcut: QuickChatShortcut | null = null const reveal = (target: BrowserWindow) => { if (loadFailed) { @@ -87,21 +86,17 @@ export function createQuickChat(deps: QuickChatDeps): QuickChat { created.on('closed', () => { if (win === created) win = null }) - created.webContents.on( - 'did-fail-load', - (_event, errorCode, _description, _url, isMainFrame) => { - if (isMainFrame && errorCode !== ERR_ABORTED) loadFailed = true - } - ) - created.webContents.on('did-finish-load', () => { - loadFailed = false + created.webContents.on('did-fail-load', (_event, errorCode, _description, url, isMainFrame) => { + if (isMainFrame && classifyLoadError(errorCode, url) !== 'ignored') loadFailed = true }) + // A crashed page cannot recover in place; the next toggle builds a fresh window instead. + created.webContents.on('render-process-gone', () => created.destroy()) created.webContents.on('will-prevent-unload', (event) => { handleWillPreventUnload(created, event, deps.windowPolicy.isCommittedRelaunchPending()) }) attachWindowOpenPolicy(created.webContents, deps.windowPolicy) attachContextMenu(created.webContents, deps.contextMenu) - void created.loadURL(`${deps.appOrigin()}${deps.startRoute()}`).catch(() => {}) + void created.loadURL(deps.startUrl()).catch(() => {}) return created } @@ -120,15 +115,15 @@ export function createQuickChat(deps: QuickChatDeps): QuickChat { if (win && !win.isDestroyed()) win.destroy() win = null }, - bindShortcut(shortcut) { - if (boundShortcut) globalShortcut.unregister(boundShortcut) - boundShortcut = null - shortcutAvailable = true - if (!shortcut) return - shortcutAvailable = globalShortcut.register(shortcut, toggle) - if (shortcutAvailable) boundShortcut = shortcut - else logger.warn('Quick chat shortcut is held by another app', { shortcut }) + bindShortcut(next) { + if (next === shortcut && (!next || globalShortcut.isRegistered(next))) return + if (shortcut) globalShortcut.unregister(shortcut) + shortcut = next + if (next && !globalShortcut.register(next, toggle)) { + logger.warn('Quick chat shortcut is held by another app', { shortcut: next }) + } }, - isShortcutAvailable: () => shortcutAvailable, + isShortcutAvailable: () => !shortcut || globalShortcut.isRegistered(shortcut), + webContents: () => (win && !win.isDestroyed() ? win.webContents : null), } } diff --git a/apps/desktop/src/main/window-preferences.ts b/apps/desktop/src/main/window-preferences.ts index 188cd7a041e..77382bfbf57 100644 --- a/apps/desktop/src/main/window-preferences.ts +++ b/apps/desktop/src/main/window-preferences.ts @@ -24,11 +24,7 @@ export function createSecureWebPreferences( devTools: !isPackaged, spellcheck: true, partition, - ...(preloadPath - ? { - preload: preloadPath, - additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], - } - : {}), + preload: preloadPath ?? undefined, + additionalArguments: [`--sim-desktop-version=${app.getVersion()}`], } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index 05f59684683..a1ba37a3061 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -1,18 +1,16 @@ 'use client' import { useEffect, useState } from 'react' -import { - type DesktopPreferenceKey, - type DesktopPreferences, - isQuickChatShortcut, - QUICK_CHAT_SHORTCUTS, - type QuickChatShortcut, +import type { + DesktopPreferenceKey, + DesktopPreferences, + QuickChatShortcut, } from '@sim/desktop-bridge' -import { ChipSelect, Label, Switch, toast } from '@sim/emcn' +import { Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { isMacPlatform } from '@/lib/core/utils/platform' import { getDesktopBridge, getDesktopShellVersion } from '@/lib/desktop' import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use' +import { QuickChatShortcutSelect } from '@/app/workspace/[workspaceId]/settings/components/desktop/quick-chat-shortcut-select' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useDesktopPreferenceMutation } from '@/hooks/use-desktop-preference-mutation' @@ -26,14 +24,6 @@ interface PreferenceRowProps { onCheckedChange: (checked: boolean) => void } -const QUICK_CHAT_SHORTCUT_LABELS: Record = { - 'Alt+Space': { mac: '⌥ Space', other: 'Alt+Space' }, - 'CommandOrControl+Shift+Space': { mac: '⇧⌘ Space', other: 'Ctrl+Shift+Space' }, - 'CommandOrControl+Shift+J': { mac: '⇧⌘J', other: 'Ctrl+Shift+J' }, -} - -const QUICK_CHAT_SHORTCUT_OFF = 'off' - function PreferenceRow({ id, label, checked, disabled, onCheckedChange }: PreferenceRowProps) { return (
@@ -165,27 +155,11 @@ export function Desktop() {

)}
-
- - void setQuickChatShortcut(isQuickChatShortcut(next) ? next : null) - } - disabled={quickChatShortcutPending} - options={[ - { label: 'Off', value: QUICK_CHAT_SHORTCUT_OFF }, - ...QUICK_CHAT_SHORTCUTS.map((shortcut) => ({ - label: - QUICK_CHAT_SHORTCUT_LABELS[shortcut][isMacPlatform() ? 'mac' : 'other'], - value: shortcut, - })), - ]} - /> -
+ void setQuickChatShortcut(shortcut)} + />
)} = { + 'Alt+Space': '⌥ Space', + 'CommandOrControl+Shift+Space': '⇧⌘ Space', + 'CommandOrControl+Shift+J': '⇧⌘J', +} + +const OFF = 'off' + +interface QuickChatShortcutSelectProps { + value: QuickChatShortcut | null + onChange: (value: QuickChatShortcut | null) => void + disabled?: boolean +} + +/** Picks the global shortcut for the desktop quick chat window, or turns it off. */ +export function QuickChatShortcutSelect({ + value, + onChange, + disabled, +}: QuickChatShortcutSelectProps) { + const mac = isMacPlatform() + return ( +
+ onChange(isQuickChatShortcut(next) ? next : null)} + disabled={disabled} + options={[ + { label: 'Off', value: OFF }, + ...QUICK_CHAT_SHORTCUTS.map((shortcut) => ({ + label: mac ? MAC_LABELS[shortcut] : shortcut.replace('CommandOrControl', 'Ctrl'), + value: shortcut, + })), + ]} + /> +
+ ) +} From 6f3fbd34799bf034057f632b6e115b03fd9f6943 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:26:33 -0800 Subject: [PATCH 3/4] fix(desktop): keep quick chat off during account teardown and out of app lifetime on Windows and Linux --- apps/desktop/e2e/quick-chat.spec.ts | 29 +++++++++++++++++++---- apps/desktop/src/main/desktop-settings.ts | 9 +++---- apps/desktop/src/main/index.ts | 3 +++ apps/desktop/src/main/quick-chat.ts | 4 +++- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/desktop/e2e/quick-chat.spec.ts b/apps/desktop/e2e/quick-chat.spec.ts index d9094deead2..8712fb30a33 100644 --- a/apps/desktop/e2e/quick-chat.spec.ts +++ b/apps/desktop/e2e/quick-chat.spec.ts @@ -20,8 +20,7 @@ import { recordCheck } from './check-report' const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) const WORKSPACE_ROUTE = '/workspace/ws1/home' -const DEFAULT_SHORTCUT: QuickChatShortcut = - process.platform === 'darwin' ? 'Alt+Space' : 'CommandOrControl+Shift+J' +const DEFAULT_SHORTCUT: QuickChatShortcut = 'Alt+Space' const PAGES: Record = { [WORKSPACE_ROUTE]: '

workspace

', @@ -75,11 +74,13 @@ function startFixtureServer(): Promise { }) } -function launchApp(origin: string, settings: Record = {}) { +function launchApp(origin: string, settings: string | Record = {}) { const userData = mkdtempSync(join(tmpdir(), 'sim-desktop-quick-chat-')) writeFileSync( join(userData, 'settings.json'), - JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE, ...settings }) + typeof settings === 'string' + ? settings + : JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE, ...settings }) ) return electron.launch({ args: ['.'], @@ -281,6 +282,7 @@ test.describe('quick chat window', () => { }) test('the shortcut setting and a failed first load', async () => { + test.skip(process.platform !== 'darwin', 'Only macOS binds a shortcut by default') const launched = await launchApp(fixture.origin, { quickChatShortcut: 'CommandOrControl+Q' }) app = launched const main = await launched.firstWindow() @@ -351,4 +353,23 @@ test.describe('quick chat window', () => { await expect(quick.locator('#quick')).toHaveText('quick-chat') }) }) + + test('stays closed while account data cannot be stored', async () => { + // An unreadable settings file blocks persistence, which makes account data unavailable. + const launched = await launchApp(fixture.origin, '[]') + app = launched + await launched.firstWindow() + await check( + 'the shortcut opens nothing while the window could not hold an account', + async () => { + await clickMenu(launched, 'Quick Chat') + await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_500)) + expect( + await launched.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isAlwaysOnTop()) + ) + ).toBe(false) + } + ) + }) }) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index dd5dd27447e..26318e8b398 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -86,11 +86,12 @@ interface DesktopSettingsServiceDeps { } /** - * ⌥Space is the macOS convention for an assistant launcher. On Windows and Linux Alt+Space opens - * the focused window's system menu, so those platforms default to a chord nothing standard owns. + * ⌥Space is the macOS convention for an assistant launcher. On Windows and Linux every preset is + * already a common chord (the window menu, browser DevTools, editor hints), so the shortcut starts + * off there rather than silently taking one system-wide; the menu and tray still open quick chat. */ -const DEFAULT_QUICK_CHAT_SHORTCUT: QuickChatShortcut = - process.platform === 'darwin' ? 'Alt+Space' : 'CommandOrControl+Shift+J' +const DEFAULT_QUICK_CHAT_SHORTCUT: QuickChatShortcut | null = + process.platform === 'darwin' ? 'Alt+Space' : null /** The stored quick chat shortcut: null when turned off, the platform default when never chosen. */ function readQuickChatShortcut(config: ConfigStore): QuickChatShortcut | null { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ef6cdc67bcf..c6781c8666d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -557,6 +557,8 @@ function main(): void { if (lastActiveWindow === win) { lastActiveWindow = getWindows().at(-1) ?? null } + // Closing the last full window quits on Windows and Linux; the quick chat must not keep the app alive. + if (process.platform !== 'darwin' && getWindows().length === 0) quickChat.close() }, }) windows.add(win) @@ -654,6 +656,7 @@ function main(): void { } const quickChat = createQuickChat({ + isAvailable: accountDataAvailable, partition: () => { // Configures the partition's permission and header handlers on first use, as a full window does. ensureAppSession() diff --git a/apps/desktop/src/main/quick-chat.ts b/apps/desktop/src/main/quick-chat.ts index 3c62dc426f4..a274e5e94f6 100644 --- a/apps/desktop/src/main/quick-chat.ts +++ b/apps/desktop/src/main/quick-chat.ts @@ -17,6 +17,8 @@ const MIN_WIDTH = 360 const MIN_HEIGHT = 420 export interface QuickChatDeps { + /** False while account data is being torn down or cannot be stored; the window holds a signed-in page. */ + isAvailable: () => boolean partition: () => string /** The compact chat URL to open, scoped to where the user last was. */ startUrl: () => string @@ -102,7 +104,7 @@ export function createQuickChat(deps: QuickChatDeps): QuickChat { const toggle = () => { if (!win || win.isDestroyed()) { - win = open() + if (deps.isAvailable()) win = open() return } if (win.isVisible() && win.isFocused()) win.hide() From 0f3e30fb01a677201c2eb140cd6effaeedb57dfb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:39:32 -0800 Subject: [PATCH 4/4] test(desktop): resize the quick chat within any runner display --- apps/desktop/e2e/quick-chat.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e/quick-chat.spec.ts b/apps/desktop/e2e/quick-chat.spec.ts index 8712fb30a33..95d94ef3f4d 100644 --- a/apps/desktop/e2e/quick-chat.spec.ts +++ b/apps/desktop/e2e/quick-chat.spec.ts @@ -222,9 +222,10 @@ test.describe('quick chat window', () => { const page = quick await resizeWindow(launched, quickUrl, 200, 200) await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [360, 420] }) - await resizeWindow(launched, quickUrl, 720, 900) - await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [720, 900] }) - await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(720) + // Small enough for any runner display; macOS clamps a window to the screen it is on. + await resizeWindow(launched, quickUrl, 600, 560) + await expect.poll(() => windowState(launched, quickUrl)).toMatchObject({ size: [600, 560] }) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(600) }) await check(