diff --git a/apps/desktop/e2e/quick-chat.spec.ts b/apps/desktop/e2e/quick-chat.spec.ts new file mode 100644 index 00000000000..95d94ef3f4d --- /dev/null +++ b/apps/desktop/e2e/quick-chat.spec.ts @@ -0,0 +1,376 @@ +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 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 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 = 'Alt+Space' + +const PAGES: Record = { + [WORKSPACE_ROUTE]: '

workspace

', + '/extension/ws1': `

quick-chat

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

handed-off

', + '/login': '

fixture-login

', +} + +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 + } + 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}`, + setFailing: (path, on) => { + if (on) failing.add(path) + else failing.delete(path) + }, + }) + }) + }) +} + +function launchApp(origin: string, settings: string | Record = {}) { + const userData = mkdtempSync(join(tmpdir(), 'sim-desktop-quick-chat-')) + writeFileSync( + join(userData, 'settings.json'), + typeof settings === 'string' + ? settings + : JSON.stringify({ origin, lastRoute: WORKSPACE_ROUTE, ...settings }) + ) + 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(), + 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 fixture: FixtureServer + let app: ElectronApplication | undefined + + test.beforeAll(async () => { + fixture = await startFixtureServer() + }) + + test.afterAll(() => { + fixture.server.close() + }) + + test.afterEach(async () => { + await app?.close().catch(() => {}) + }) + + 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 = `${fixture.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('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] }) + // 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( + '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) + }) + }) + + 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() + 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') + }) + }) + + 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/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..26318e8b398 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,6 +85,21 @@ interface DesktopSettingsServiceDeps { chooseBrowserDownloadDirectory: (defaultPath: string) => Promise } +/** + * ⌥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 | 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 { + const stored = config.get('quickChatShortcut') + if (stored === null) return null + return isQuickChatShortcut(stored) ? stored : DEFAULT_QUICK_CHAT_SHORTCUT +} + function readPreferences( config: ConfigStore, defaultBrowserDownloadDirectory: string @@ -100,6 +121,7 @@ function readPreferences( terminalEnabled: config.get('terminalEnabled') ?? true, fullFileAccess: config.get('fullFileAccess') === true, preventSleepWhileRunning: config.get('preventSleepWhileRunning') ?? true, + quickChatShortcut: readQuickChatShortcut(config), browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app', browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100, browserDownloadDirectory: @@ -135,7 +157,10 @@ function showsRoute(window: BrowserWindow, route: string | undefined): boolean { export function createDesktopSettingsService( deps: DesktopSettingsServiceDeps ): DesktopSettingsService { - const read = () => readPreferences(deps.config, deps.getDefaultBrowserDownloadDirectory()) + 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 @@ -201,6 +226,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..c6781c8666d 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, @@ -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' @@ -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' @@ -117,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') @@ -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() }, ] @@ -497,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, @@ -532,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) @@ -551,21 +578,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}`, @@ -641,6 +655,20 @@ function main(): void { showMainWindow(win) } + const quickChat = createQuickChat({ + isAvailable: accountDataAvailable, + partition: () => { + // Configures the partition's permission and header handlers on first use, as a full window does. + ensureAppSession() + return appPartition() + }, + startUrl: () => `${appOrigin()}${quickChatRoute(config.get('lastRoute'))}`, + themeBackground: () => config.get('themeBackground'), + isPackaged: app.isPackaged, + windowPolicy: appWindowPolicy({ activate: true }), + contextMenu: appContextMenuDeps(), + }) + /** Installs or removes the menu-bar status item; safe to call repeatedly. */ function setTrayEnabled(enabled: boolean): void { if (!enabled) { @@ -654,6 +682,7 @@ function main(): void { appOrigin, lastRoute: () => config.get('lastRoute'), openMainWindow: (route) => void openMainWindowAt(route), + openQuickChat: quickChat.toggle, }) } } @@ -685,6 +714,8 @@ function main(): void { desktopExecutor.refreshRegistration() }, setPreventSleepWhileRunning: () => sleepBlocker.refresh(), + setQuickChatShortcut: quickChat.bindShortcut, + isQuickChatShortcutAvailable: quickChat.isShortcutAvailable, setBrowserTheme: setAgentBrowserTheme, setBrowserDefaultZoom: setAgentBrowserDefaultZoom, setTerminalDefaultZoom: (zoom) => { @@ -895,6 +926,7 @@ function main(): void { tray = null computerUse.cancel() if (computerStopShortcutRegistered) globalShortcut.unregister(computerStopShortcut) + quickChat.bindShortcut(null) localFilesystem.close() quiesceBrowserSessions() terminal.dispose() @@ -1148,6 +1180,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 +1193,7 @@ function main(): void { }) installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) + quickChat.bindShortcut(desktopSettings.getPreferences().quickChatShortcut ?? null) updater = initUpdater({ getWindow: getMainWindow, events, @@ -1172,9 +1206,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/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..a274e5e94f6 --- /dev/null +++ b/apps/desktop/src/main/quick-chat.ts @@ -0,0 +1,131 @@ +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' +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 + +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 + 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 + /** The open window's page, for checks that must see every app page before a relaunch. */ + webContents(): WebContents | null +} + +/** + * 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 shortcut: QuickChatShortcut | null = null + + 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 && 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.startUrl()).catch(() => {}) + return created + } + + const toggle = () => { + if (!win || win.isDestroyed()) { + if (deps.isAvailable()) 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(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: () => !shortcut || globalShortcut.isRegistered(shortcut), + webContents: () => (win && !win.isDestroyed() ? win.webContents : null), + } +} 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..77382bfbf57 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,7 @@ export function createSecureWebPreferences( devTools: !isPackaged, spellcheck: true, partition, - preload: preloadPath, + preload: preloadPath ?? undefined, 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..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,11 +1,16 @@ 'use client' import { useEffect, useState } from 'react' -import type { DesktopPreferenceKey, DesktopPreferences } from '@sim/desktop-bridge' +import type { + DesktopPreferenceKey, + DesktopPreferences, + QuickChatShortcut, +} from '@sim/desktop-bridge' import { Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' 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' @@ -82,6 +87,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 +144,24 @@ 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(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, + })), + ]} + /> +
+ ) +} diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 526273b05b3..6b715aaa083 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -999,6 +999,13 @@ export interface DesktopPreferences { * compatibility with installed shells that predate the background executor. */ preventSleepWhileRunning?: boolean + /** + * Global shortcut that shows or hides the quick chat window, or null when it is off. Absent + * on installed shells that predate quick chat. + */ + quickChatShortcut?: QuickChatShortcut | null + /** False when another app already holds `quickChatShortcut`, so pressing it reaches that app. */ + quickChatShortcutAvailable?: boolean /** * Appearance used by browser pages on this device. `app` follows Sim's * current preference; explicit values override it. @@ -1014,6 +1021,19 @@ export interface DesktopPreferences { terminalDefaultZoom: DesktopZoomPercent } +/** Accelerators the quick chat window can be bound to; the user picks one in desktop settings. */ +export const QUICK_CHAT_SHORTCUTS = [ + 'Alt+Space', + 'CommandOrControl+Shift+Space', + 'CommandOrControl+Shift+J', +] as const + +export type QuickChatShortcut = (typeof QUICK_CHAT_SHORTCUTS)[number] + +export function isQuickChatShortcut(value: unknown): value is QuickChatShortcut { + return (QUICK_CHAT_SHORTCUTS as readonly unknown[]).includes(value) +} + export const DESKTOP_ZOOM_PERCENTS = [67, 75, 80, 90, 100, 110, 125, 150, 175, 200] as const export type DesktopZoomPercent = (typeof DESKTOP_ZOOM_PERCENTS)[number] @@ -1097,6 +1117,11 @@ interface SimDesktopSettingsApi { * compatibility with installed shells that predate the background executor. */ setPreventSleepWhileRunning?(enabled: boolean): Promise + /** + * 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. */