From d8fe1ae0dacfa1c4767122c6f7968fbc8097fe58 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Mon, 14 Sep 2026 16:09:16 -0700
Subject: [PATCH 1/3] fix(desktop): unify shell dialogs and recovery screens
with emcn
---
.github/workflows/desktop-e2e.yml | 4 +
apps/desktop/e2e/packaged-smoke.spec.ts | 4 +-
apps/desktop/e2e/smoke.spec.ts | 115 +++++++-
apps/desktop/package.json | 9 +-
apps/desktop/scripts/build.ts | 64 ++++-
.../desktop/src/main/browser-agent/session.ts | 23 +-
.../src/main/browser-credentials/os-auth.ts | 7 +-
apps/desktop/src/main/dialogs.test.ts | 128 +++++++++
apps/desktop/src/main/dialogs.ts | 136 +++++++++
apps/desktop/src/main/handoff.ts | 7 +-
apps/desktop/src/main/local-pages.test.ts | 19 ++
apps/desktop/src/main/local-pages.ts | 22 +-
apps/desktop/src/main/menu.test.ts | 2 +-
apps/desktop/src/main/menu.ts | 14 +-
apps/desktop/src/main/observability.ts | 7 +-
apps/desktop/src/main/server-window.test.ts | 26 +-
apps/desktop/src/main/server-window.ts | 65 +++--
apps/desktop/src/main/session-lifecycle.ts | 9 +-
apps/desktop/src/main/shell-window.ts | 37 +++
apps/desktop/src/main/terminal/index.ts | 29 +-
.../src/main/terminal/registry.test.ts | 2 +
apps/desktop/src/main/terminal/registry.ts | 28 +-
.../desktop/src/main/terminal/service.test.ts | 47 ++++
apps/desktop/src/main/updater.ts | 11 +-
apps/desktop/src/main/window-preferences.ts | 27 ++
apps/desktop/src/main/window.test.ts | 2 +-
apps/desktop/src/main/window.ts | 70 ++---
apps/desktop/src/preload/shell.ts | 14 +
apps/desktop/src/renderer/dialog/index.tsx | 79 ++++++
apps/desktop/src/renderer/offline/index.tsx | 116 ++++++++
apps/desktop/src/renderer/server/index.tsx | 25 ++
.../src/renderer/server/server-modal.tsx | 111 ++++++++
apps/desktop/src/renderer/shell.css | 17 ++
apps/desktop/src/renderer/shell.ts | 32 +++
apps/desktop/src/renderer/styles.d.ts | 6 +
apps/desktop/src/shared/shell.ts | 23 ++
apps/desktop/src/test/dialog-mock.ts | 3 +
apps/desktop/src/test/electron-mock.ts | 3 +
apps/desktop/src/test/setup.ts | 4 +
apps/desktop/static/dialog.html | 17 ++
apps/desktop/static/offline.html | 251 +----------------
apps/desktop/static/server.html | 266 +-----------------
apps/desktop/tsconfig.json | 5 +-
apps/desktop/turbo.json | 13 +
apps/desktop/vitest.config.ts | 1 +
apps/sim/app/oauth-error/page.tsx | 16 +-
.../browser-session/browser-page-issue.tsx | 11 +-
.../browser-session/browser-theme-notice.tsx | 23 +-
.../terminal-session/terminal-session.tsx | 19 +-
.../use-terminal-close-confirmation.test.tsx | 117 ++++++++
.../use-terminal-close-confirmation.tsx | 85 ++++++
.../resource-tabs/resource-tabs.tsx | 103 ++++---
.../app/workspace/[workspaceId]/not-found.tsx | 13 +-
.../[workspaceId]/tables/[tableId]/error.tsx | 11 +-
bun.lock | 7 +
.../src/components/chip-modal/chip-modal.tsx | 54 ++--
packages/emcn/src/components/index.ts | 1 +
57 files changed, 1566 insertions(+), 794 deletions(-)
create mode 100644 apps/desktop/src/main/dialogs.test.ts
create mode 100644 apps/desktop/src/main/dialogs.ts
create mode 100644 apps/desktop/src/main/shell-window.ts
create mode 100644 apps/desktop/src/main/window-preferences.ts
create mode 100644 apps/desktop/src/preload/shell.ts
create mode 100644 apps/desktop/src/renderer/dialog/index.tsx
create mode 100644 apps/desktop/src/renderer/offline/index.tsx
create mode 100644 apps/desktop/src/renderer/server/index.tsx
create mode 100644 apps/desktop/src/renderer/server/server-modal.tsx
create mode 100644 apps/desktop/src/renderer/shell.css
create mode 100644 apps/desktop/src/renderer/shell.ts
create mode 100644 apps/desktop/src/renderer/styles.d.ts
create mode 100644 apps/desktop/src/shared/shell.ts
create mode 100644 apps/desktop/src/test/dialog-mock.ts
create mode 100644 apps/desktop/src/test/setup.ts
create mode 100644 apps/desktop/static/dialog.html
create mode 100644 apps/desktop/turbo.json
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.test.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
index eb5d1316f00..3ea5df89904 100644
--- a/.github/workflows/desktop-e2e.yml
+++ b/.github/workflows/desktop-e2e.yml
@@ -10,7 +10,11 @@ on:
- '.github/workflows/desktop-e2e.yml'
- '.github/workflows/desktop-release.yml'
- 'apps/desktop/**'
+ - 'apps/sim/app/_styles/**'
+ - 'apps/sim/lib/postcss/**'
+ - 'apps/sim/postcss.config.mjs'
- 'apps/sim/public/brand/fonts/**'
+ - 'packages/emcn/**'
- 'packages/desktop-bridge/**'
- 'packages/browser-protocol/**'
- 'packages/terminal-protocol/**'
diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts
index f4010e8abfa..84cefa543bc 100644
--- a/apps/desktop/e2e/packaged-smoke.spec.ts
+++ b/apps/desktop/e2e/packaged-smoke.spec.ts
@@ -146,8 +146,8 @@ test('packaged shell renders the bundled offline page', async () => {
.toBe(true)
const picker = findPage('sim-shell://pages/server.html')
if (!picker) throw new Error('server picker disappeared')
- await expect(picker.locator('h1')).toHaveText('Sim server')
- await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
+ await expect(picker.getByRole('dialog', { name: 'Sim server' })).toBeVisible()
+ await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
} finally {
await browser?.close().catch(() => {})
if (child.exitCode === null && child.signalCode === null) {
diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts
index df17dd4f51f..578bf6e78f0 100644
--- a/apps/desktop/e2e/smoke.spec.ts
+++ b/apps/desktop/e2e/smoke.spec.ts
@@ -158,8 +158,11 @@ test.describe('desktop shell smoke', () => {
const window = await app.firstWindow()
await window.waitForSelector('#retry', { timeout: 30_000 })
expect(window.url()).toMatch(/^sim-shell:\/\/pages\/offline\.html\?/)
- await expect(window.locator('.wordmark')).toBeVisible()
- await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
+ await expect(window.getByRole('img', { name: 'Sim', exact: true })).toBeVisible()
+ await expect(window.getByRole('img', { name: 'Sim', exact: true })).toHaveAttribute(
+ 'aria-label',
+ 'Sim'
+ )
await expect(window.locator('#title')).toHaveText('Can’t connect to Sim')
// The recovery path for a self-hosted shell pointed at a server it cannot
// reach. Exercised end to end here because it is the only coverage of the
@@ -173,22 +176,74 @@ test.describe('desktop shell smoke', () => {
await expect
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
.toBe(true)
- await expect(window.locator('#retry')).toHaveCSS('height', '30px')
- await expect(window.locator('#retry')).toHaveCSS('border-radius', '8px')
- await expect(window.locator('#retry')).toHaveCSS('padding-left', '8px')
- await expect(window.locator('#retry')).toHaveCSS('font-size', '14px')
- await expect(window.locator('#retry')).toHaveCSS('line-height', '20px')
- await expect(window.locator('#retry')).toHaveCSS('text-align', 'left')
- await window.locator('#retry').focus()
- await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid')
await expect(window.locator('#detail')).toHaveAttribute('role', 'status')
})
+ test('recovery messages use an isolated EMCN dialog with a safe keyboard default', async () => {
+ app = await launchApp('http://127.0.0.1:1')
+ const window = await app.firstWindow()
+ await expect(window.locator('#server')).toBeVisible()
+ const dialogPromise = app.waitForEvent('window')
+ await app.evaluate(({ BrowserWindow }) => {
+ BrowserWindow.getAllWindows()[0].webContents.emit('unresponsive')
+ })
+ const prompt = await dialogPromise
+ await expect(prompt.getByRole('dialog', { name: 'Sim', exact: true })).toBeVisible()
+ await expect(prompt.getByText('Sim isn’t responding')).toBeVisible()
+ await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
+ await expect
+ .poll(() =>
+ prompt
+ .getByRole('dialog')
+ .evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
+ )
+ .toBe(true)
+ await expect
+ .poll(() => prompt.evaluate(() => typeof (globalThis as { simDesktop?: unknown }).simDesktop))
+ .toBe('undefined')
+ await prompt.screenshot({
+ path: test.info().outputPath('recovery-dialog.png'),
+ animations: 'disabled',
+ })
+ await app.evaluate(({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows().find(
+ (entry) => entry.webContents.getURL() === 'sim-shell://pages/dialog.html'
+ )
+ if (!win) throw new Error('Recovery dialog is missing')
+ win.webContents.ipc.removeHandler('shell:configuration')
+ win.webContents.ipc.handle('shell:configuration', () => ({
+ title: 'Long recovery message',
+ message: 'Recovery details',
+ detail: Array.from({ length: 80 }, (_, index) => `Diagnostic detail ${index + 1}`).join(
+ '\n'
+ ),
+ type: 'warning',
+ buttons: ['Wait', 'Reload'],
+ defaultId: 0,
+ cancelId: 0,
+ }))
+ win.webContents.reload()
+ })
+ await expect(
+ prompt.getByRole('dialog', { name: 'Long recovery message', exact: true })
+ ).toBeVisible()
+ await expect(prompt.getByRole('button', { name: 'Reload', exact: true })).toBeInViewport()
+ await expect(prompt.getByRole('button', { name: 'Wait', exact: true })).toBeFocused()
+ const closed = prompt.waitForEvent('close')
+ await prompt
+ .getByRole('button', { name: 'Wait', exact: true })
+ .press('Enter')
+ .catch(() => {})
+ await closed
+ await expect(window.locator('#server')).toBeVisible()
+ })
+
// The picker is the only way to repoint a shell whose server is unreachable.
// Its page, the pre-filled value (which crosses the local-page IPC gate) and
// Escape are asserted together because the packaged build once opened it as
// a blank sheet with no way out.
- test('the offline page opens the server picker, pre-filled, and Escape closes it', async () => {
+ test('the offline server picker renders EMCN controls and handles validation and dismissal', async () => {
+ const testInfo = test.info()
app = await launchApp('http://127.0.0.1:1')
const window = await app.firstWindow()
await window.waitForSelector('#server', { timeout: 30_000 })
@@ -198,8 +253,42 @@ test.describe('desktop shell smoke', () => {
const picker = await pickerPromise
expect(picker.url()).toBe('sim-shell://pages/server.html')
- await expect(picker.locator('h1')).toHaveText('Sim server')
- await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1')
+ await expect(picker.getByRole('dialog', { name: 'Sim server', exact: true })).toBeVisible()
+ await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
+ await expect(picker.getByLabel('Server URL')).toBeFocused()
+ await expect
+ .poll(() =>
+ picker
+ .getByRole('dialog')
+ .evaluate((element) => element.scrollHeight <= globalThis.innerHeight)
+ )
+ .toBe(true)
+ await picker.getByLabel('Server URL').fill('http://example.com')
+ await picker.getByLabel('Server URL').press('Enter')
+ await expect(picker.getByRole('alert')).toBeVisible()
+ await expect(picker.getByLabel('Server URL')).toHaveAttribute('aria-invalid', 'true')
+ await picker.getByLabel('Server URL').fill('http://127.0.0.1:1')
+ await expect(picker.getByRole('alert')).toHaveCount(0)
+ await picker.getByRole('button', { name: 'Connect', exact: true }).click()
+ await expect(picker.getByRole('status')).toHaveText('Already connected to this server.')
+ await expect
+ .poll(() =>
+ picker
+ .locator('[data-chip-modal-body]')
+ .evaluate((element) => element.scrollHeight <= element.clientHeight)
+ )
+ .toBe(true)
+ await picker.emulateMedia({ colorScheme: 'light' })
+ await picker.screenshot({
+ path: testInfo.outputPath('server-modal-light.png'),
+ animations: 'disabled',
+ })
+ await picker.emulateMedia({ colorScheme: 'dark' })
+ await expect(picker.locator('html')).toHaveClass('dark')
+ await picker.screenshot({
+ path: testInfo.outputPath('server-modal-dark.png'),
+ animations: 'disabled',
+ })
const closed = picker.waitForEvent('close')
// The main process destroys the window on the key-down, so the key-up half
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index c90de11d4bd..f4c24dee88c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
- "description": "Sim desktop app for macOS — Electron shell around the hosted web app",
+ "description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app",
"author": "Sim ",
"homepage": "https://sim.ai",
"type": "module",
@@ -47,13 +47,20 @@
"devDependencies": {
"@electron/fuses": "1.8.0",
"@playwright/test": "1.61.1",
+ "@sim/emcn": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
"electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"jsdom": "^26.0.0",
+ "postcss": "^8",
+ "postcss-load-config": "6.0.1",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts
index f2b553661d2..2f009c58087 100644
--- a/apps/desktop/scripts/build.ts
+++ b/apps/desktop/scripts/build.ts
@@ -1,7 +1,9 @@
import { execFileSync } from 'node:child_process'
-import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { build } from 'esbuild'
+import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { type BuildOptions, build } from 'esbuild'
+import postcss from 'postcss'
+import loadPostcssConfig from 'postcss-load-config'
import { identityForOrigin } from './channels'
const watch = process.argv.includes('--watch')
@@ -94,10 +96,56 @@ const common = {
},
}
+/** Bundles the shared EMCN components and app tokens for offline shell use. */
+const renderer: BuildOptions = {
+ entryPoints: {
+ server: 'src/renderer/server/index.tsx',
+ offline: 'src/renderer/offline/index.tsx',
+ dialog: 'src/renderer/dialog/index.tsx',
+ },
+ outdir: 'dist/renderer',
+ bundle: true,
+ platform: 'browser',
+ format: 'iife',
+ target: 'chrome146',
+ minify: true,
+ tsconfig: 'tsconfig.json',
+ external: ['*.woff2'],
+ define: { 'process.env.NODE_ENV': '"production"', 'process.env': '{}' },
+ loader: { '.module.css': 'local-css' },
+ plugins: [
+ {
+ name: 'desktop-tailwind',
+ setup(builder) {
+ builder.onLoad({ filter: /shell\.css$/ }, async ({ path }) => {
+ const config = await loadPostcssConfig({}, resolve('../sim'))
+ const result = await postcss(config.plugins).process(readFileSync(path, 'utf8'), {
+ from: path,
+ })
+ return {
+ contents: result.css,
+ loader: 'css',
+ resolveDir: dirname(path),
+ watchFiles: result.messages.flatMap((message) =>
+ message.type === 'dependency' ? [message.file as string] : []
+ ),
+ }
+ })
+ },
+ },
+ ],
+}
+
async function run(): Promise {
compileNativeHelpSearch()
if (watch) {
const { context } = await import('esbuild')
+ const rendererCtx = await context(renderer)
+ const shellPreloadCtx = await context({
+ ...common,
+ entryPoints: ['src/preload/shell.ts'],
+ outfile: 'dist/shell-preload.cjs',
+ })
const mainCtx = await context({
...common,
entryPoints: ['src/main/index.ts'],
@@ -115,10 +163,18 @@ async function run(): Promise {
entryPoints: ['src/preload/browser/index.ts'],
outfile: 'dist/browser-preload.cjs',
})
- await Promise.all([mainCtx.watch(), preloadCtx.watch(), browserPreloadCtx.watch()])
+ await Promise.all([
+ mainCtx.watch(),
+ preloadCtx.watch(),
+ browserPreloadCtx.watch(),
+ rendererCtx.watch(),
+ shellPreloadCtx.watch(),
+ ])
return
}
await Promise.all([
+ build(renderer),
+ build({ ...common, entryPoints: ['src/preload/shell.ts'], outfile: 'dist/shell-preload.cjs' }),
build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),
build({
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
index bbfe654b6c9..9538bff0b81 100644
--- a/apps/desktop/src/main/browser-agent/session.ts
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -36,7 +36,6 @@ import type {
} from 'electron'
import {
app,
- dialog,
session as electronSession,
Menu,
nativeTheme,
@@ -71,6 +70,7 @@ import {
} from '@/main/browser-agent/url-guard'
import { browserUserAgent } from '@/main/browser-agent/user-agent'
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
+import { showShellDialog } from '@/main/dialogs'
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
import {
type FocusedResourceShortcut,
@@ -1524,17 +1524,16 @@ async function requestSitePermission(details: {
const pending = scoped.tab.pendingSitePermission
if (!pending || pending.request.requestId !== request.requestId) return await allowed
pending.nativePromptController = nativePromptController
- void dialog
- .showMessageBox(win, {
- type: 'warning',
- buttons: ['Block', 'Allow'],
- defaultId: 0,
- cancelId: 0,
- noLink: true,
- signal: nativePromptController.signal,
- message: `Allow this browser task to open ${request.origin}?`,
- detail: 'Only allow this site if it is expected for the current task.',
- })
+ void showShellDialog(win, {
+ type: 'warning',
+ buttons: ['Block', 'Allow'],
+ defaultId: 0,
+ cancelId: 0,
+ noLink: true,
+ signal: nativePromptController.signal,
+ message: `Allow this browser task to open ${request.origin}?`,
+ detail: 'Only allow this site if it is expected for the current task.',
+ })
.then(({ response }) => {
withBrowserScope(scoped.scopeId, () => {
respondToSitePermission(request.requestId, response === 1)
diff --git a/apps/desktop/src/main/browser-credentials/os-auth.ts b/apps/desktop/src/main/browser-credentials/os-auth.ts
index 57749e6e5ac..23d9cf6c530 100644
--- a/apps/desktop/src/main/browser-credentials/os-auth.ts
+++ b/apps/desktop/src/main/browser-credentials/os-auth.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import type { MessageBoxOptions } from 'electron'
-import { BrowserWindow, dialog, systemPreferences } from 'electron'
+import { BrowserWindow, systemPreferences } from 'electron'
+import { showShellDialog } from '@/main/dialogs'
const logger = createLogger('BrowserCredentialAuth')
@@ -143,8 +144,8 @@ async function promptForSecret(reason: string, action: string): Promise
const parent = BrowserWindow.getFocusedWindow()
const { response } =
parent && !parent.isDestroyed()
- ? await dialog.showMessageBox(parent, options)
- : await dialog.showMessageBox(options)
+ ? await showShellDialog(parent, options)
+ : await showShellDialog(options)
return response === 1
} catch (error) {
// Fail closed: if the confirmation cannot be shown, nothing is revealed.
diff --git a/apps/desktop/src/main/dialogs.test.ts b/apps/desktop/src/main/dialogs.test.ts
new file mode 100644
index 00000000000..e2915ac5925
--- /dev/null
+++ b/apps/desktop/src/main/dialogs.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => import('@/test/electron-mock'))
+vi.unmock('@/main/dialogs')
+
+import { dialog, session } from 'electron'
+import { showShellDialog } from '@/main/dialogs'
+import { BrowserWindow } from '@/test/electron-mock'
+
+function latestWindow() {
+ const win = BrowserWindow.instances.at(-1)
+ if (!win) throw new Error('Dialog window was not created')
+ win.webContents.mainFrame.url = 'sim-shell://pages/dialog.html'
+ return win
+}
+
+function sender(win: BrowserWindow) {
+ return { sender: win.webContents, senderFrame: win.webContents.mainFrame }
+}
+
+function respond(win: BrowserWindow, response: unknown, event = sender(win)) {
+ const handler = win.webContents.ipc.on.mock.calls.find(
+ ([channel]) => channel === 'shell:respond'
+ )?.[1]
+ if (!handler) throw new Error('Missing dialog response handler')
+ handler(event, response)
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ BrowserWindow.instances.length = 0
+ vi.mocked(session.fromPartition).mockReturnValue({
+ setPermissionRequestHandler: vi.fn(),
+ setPermissionCheckHandler: vi.fn(),
+ protocol: { isProtocolHandled: () => false, handle: vi.fn() },
+ } as never)
+})
+
+describe('showShellDialog', () => {
+ it('uses an isolated preload and preserves response indices', async () => {
+ const result = showShellDialog({
+ message: 'Continue?',
+ buttons: ['Later', 'Continue'],
+ cancelId: 0,
+ })
+ const win = latestWindow()
+ expect(BrowserWindow.lastOptions).toMatchObject({
+ frame: false,
+ webPreferences: {
+ partition: 'shell-dialogs',
+ sandbox: true,
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ })
+ respond(win, 1)
+ await expect(result).resolves.toEqual({ response: 1, checkboxChecked: false })
+ expect(win.destroy).toHaveBeenCalledOnce()
+ expect(dialog.showMessageBox).not.toHaveBeenCalled()
+ })
+
+ it('rejects foreign documents, subframes, and invalid action indices', async () => {
+ const result = showShellDialog({ message: 'Allow?', buttons: ['Block', 'Allow'], cancelId: 0 })
+ const win = latestWindow()
+ respond(win, 1, { ...sender(win), senderFrame: { url: 'sim-shell://pages/dialog.html' } })
+ win.webContents.mainFrame.url = 'https://untrusted.example'
+ respond(win, 1)
+ win.webContents.mainFrame.url = 'sim-shell://pages/dialog.html'
+ respond(win, -1)
+ respond(win, 2)
+ respond(win, '1')
+ expect(win.destroy).not.toHaveBeenCalled()
+ respond(win, 0)
+ await expect(result).resolves.toMatchObject({ response: 0 })
+ })
+
+ it('settles once and refuses a late response after cancellation', async () => {
+ const controller = new AbortController()
+ const result = showShellDialog({
+ message: 'Allow?',
+ buttons: ['Block', 'Allow'],
+ cancelId: 0,
+ signal: controller.signal,
+ })
+ const win = latestWindow()
+ controller.abort()
+ respond(win, 1)
+ await expect(result).resolves.toMatchObject({ response: 0 })
+ expect(win.destroy).toHaveBeenCalledOnce()
+ })
+
+ it('does not open an already-aborted prompt', async () => {
+ const controller = new AbortController()
+ controller.abort()
+ await expect(
+ showShellDialog({ message: 'Continue?', signal: controller.signal })
+ ).resolves.toMatchObject({ response: 0 })
+ expect(BrowserWindow.instances).toHaveLength(0)
+ })
+
+ it('uses the OS recovery fallback only when the bundled renderer fails', async () => {
+ const result = showShellDialog({
+ message: 'Recover',
+ buttons: ['Restart', 'Quit'],
+ cancelId: 1,
+ })
+ const win = latestWindow()
+ const failed = win.webContents.on.mock.calls.find(([event]) => event === 'did-fail-load')?.[1]
+ failed?.({}, -6, 'missing asset', 'sim-shell://pages/dialog.html', true)
+ await expect(result).resolves.toMatchObject({ response: 0 })
+ expect(dialog.showMessageBox).toHaveBeenCalledOnce()
+ })
+
+ it('clamps content sizing and ignores untrusted resize messages', async () => {
+ const result = showShellDialog({ message: 'Info' })
+ const win = latestWindow()
+ const resize = win.webContents.ipc.on.mock.calls.find(
+ ([channel]) => channel === 'shell:resize'
+ )?.[1]
+ resize?.({ ...sender(win), senderFrame: { url: 'https://untrusted.example' } }, 500)
+ resize?.(sender(win), Number.NaN)
+ expect(win.setContentSize).not.toHaveBeenCalled()
+ resize?.(sender(win), 100000)
+ expect(win.setContentSize).toHaveBeenCalledWith(500, 820)
+ respond(win, 0)
+ await result
+ })
+})
diff --git a/apps/desktop/src/main/dialogs.ts b/apps/desktop/src/main/dialogs.ts
new file mode 100644
index 00000000000..4e8180c2661
--- /dev/null
+++ b/apps/desktop/src/main/dialogs.ts
@@ -0,0 +1,136 @@
+import { join } from 'node:path'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import { app, BrowserWindow, dialog, nativeTheme, session } from 'electron'
+import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages'
+import { attachShellWindowSizing, isShellWindowSender } from '@/main/shell-window'
+import { createSecureWebPreferences } from '@/main/window-preferences'
+import type { ShellDialogConfiguration } from '@/shared/shell'
+
+const logger = createLogger('DesktopDialogs')
+const DIALOG_WIDTH = 500
+const DIALOG_PARTITION = 'shell-dialogs'
+
+interface ShellDialogOptions extends MessageBoxOptions {
+ primaryVariant?: ShellDialogConfiguration['primaryVariant']
+}
+
+export function showShellDialog(options: ShellDialogOptions): Promise
+export function showShellDialog(
+ parent: BrowserWindow,
+ options: ShellDialogOptions
+): Promise
+/**
+ * Presents app-owned messages in an isolated bundled EMCN window. The OS dialog
+ * is the last-resort fallback if this recovery renderer itself cannot load.
+ */
+export function showShellDialog(
+ parentOrOptions: BrowserWindow | ShellDialogOptions,
+ suppliedOptions?: ShellDialogOptions
+): Promise {
+ const parent = suppliedOptions ? (parentOrOptions as BrowserWindow) : undefined
+ const options = suppliedOptions ?? (parentOrOptions as ShellDialogOptions)
+ const buttons = options.buttons?.length ? options.buttons : ['OK']
+ const cancelId =
+ options.cancelId ??
+ Math.max(
+ 0,
+ buttons.findIndex((label) => /^(cancel|no|close|ok)$/i.test(label))
+ )
+ const configuration: ShellDialogConfiguration = {
+ title: options.title ?? 'Sim',
+ message: options.message,
+ detail: options.detail ?? '',
+ type: options.type ?? 'none',
+ buttons,
+ defaultId: options.defaultId ?? 0,
+ cancelId,
+ primaryVariant: options.primaryVariant ?? 'primary',
+ }
+ const cancelled = { response: cancelId, checkboxChecked: false }
+ if (options.signal?.aborted) return Promise.resolve(cancelled)
+
+ return new Promise((resolve, reject) => {
+ const ses = session.fromPartition(DIALOG_PARTITION)
+ ses.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
+ ses.setPermissionCheckHandler(() => false)
+ attachLocalPageProtocol(ses)
+ const win = new BrowserWindow({
+ width: DIALOG_WIDTH,
+ height: 240,
+ useContentSize: true,
+ frame: false,
+ resizable: false,
+ minimizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ show: false,
+ title: configuration.title,
+ backgroundColor: nativeTheme.shouldUseDarkColors ? '#1b1b1b' : '#ffffff',
+ ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}),
+ webPreferences: createSecureWebPreferences(
+ DIALOG_PARTITION,
+ join(__dirname, 'shell-preload.cjs'),
+ app.isPackaged
+ ),
+ })
+ const pageUrl = localPageUrl('dialog.html')
+ let settled = false
+ const finish = (response: number) => {
+ if (settled) return
+ settled = true
+ options.signal?.removeEventListener('abort', abort)
+ clearTimeout(loadTimeout)
+ if (!win.isDestroyed()) win.destroy()
+ resolve({ response, checkboxChecked: false })
+ }
+ const abort = () => finish(cancelId)
+ const fallback = () => {
+ if (settled) return
+ settled = true
+ options.signal?.removeEventListener('abort', abort)
+ clearTimeout(loadTimeout)
+ if (!win.isDestroyed()) win.destroy()
+ const result =
+ parent && !parent.isDestroyed()
+ ? dialog.showMessageBox(parent, options)
+ : dialog.showMessageBox(options)
+ void result.then(resolve, reject)
+ }
+ const loadTimeout = setTimeout(fallback, 10_000)
+ options.signal?.addEventListener('abort', abort, { once: true })
+ win.on('closed', abort)
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+ win.webContents.on('will-navigate', (event) => event.preventDefault())
+ win.webContents.on('will-redirect', (event) => event.preventDefault())
+ win.webContents.on('render-process-gone', fallback)
+ win.webContents.on('did-fail-load', (_event, code, _description, _url, isMainFrame) => {
+ if (isMainFrame && code !== -3) fallback()
+ })
+ win.webContents.on('before-input-event', (event, input) => {
+ if (input.type === 'keyDown' && input.key === 'Escape') {
+ event.preventDefault()
+ abort()
+ }
+ })
+ win.webContents.ipc.handle('shell:configuration', (event) => {
+ if (!isShellWindowSender(win, pageUrl, event)) throw new Error('Untrusted dialog sender')
+ return configuration
+ })
+ win.webContents.ipc.on('shell:respond', (event, response: unknown) => {
+ if (!isShellWindowSender(win, pageUrl, event)) return
+ if (typeof response !== 'number' || !Number.isInteger(response) || !buttons[response]) return
+ finish(response)
+ })
+ attachShellWindowSizing(win, pageUrl, DIALOG_WIDTH, () => {
+ if (settled) return
+ clearTimeout(loadTimeout)
+ win.show()
+ })
+ void win.loadURL(pageUrl).catch((error) => {
+ logger.error('Could not load a bundled dialog', { error: getErrorMessage(error) })
+ fallback()
+ })
+ })
+}
diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts
index 2f2e95583b5..c9295941377 100644
--- a/apps/desktop/src/main/handoff.ts
+++ b/apps/desktop/src/main/handoff.ts
@@ -5,7 +5,8 @@ import { safeCompare } from '@sim/security/compare'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import type { BrowserWindow } from 'electron'
-import { app, dialog } from 'electron'
+import { app } from 'electron'
+import { showShellDialog } from '@/main/dialogs'
import type { EventRecorder } from '@/main/observability'
const logger = createLogger('DesktopHandoff')
@@ -417,7 +418,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
'handoff_redeem_fail',
status === undefined ? { reason } : { reason, status }
)
- void dialog.showMessageBox(win, {
+ void showShellDialog(win, {
type: 'error',
message: 'Sign-in failed',
detail: 'The sign-in could not be completed. Try signing in again.',
@@ -433,7 +434,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
if (!opened) {
const win = await resolveWindow('begin_window')
if (!win) return
- void dialog.showMessageBox(win, {
+ void showShellDialog(win, {
type: 'error',
message: 'Couldn’t start sign-in',
detail: 'Sim could not open your browser to sign in. Try again.',
diff --git a/apps/desktop/src/main/local-pages.test.ts b/apps/desktop/src/main/local-pages.test.ts
index a425b2f6235..fde78208fa8 100644
--- a/apps/desktop/src/main/local-pages.test.ts
+++ b/apps/desktop/src/main/local-pages.test.ts
@@ -49,6 +49,8 @@ describe('isLocalPageUrl', () => {
'https://www.sim.ai/offline.html',
'sim-shell://evil/offline.html',
'sim-shell://pages/SeasonSansUprightsVF.woff2',
+ 'sim-shell://pages/server.js',
+ 'sim-shell://pages/server.css',
'sim-shell://pages/static/offline.html',
'sim-shell://pages/',
'not a url',
@@ -66,6 +68,8 @@ describe('createLocalPageHandler', () => {
root = mkdtempSync(join(tmpdir(), 'sim-local-pages-'))
writeFileSync(join(root, 'offline.html'), 'offline ')
writeFileSync(join(root, 'secret.txt'), 'nope')
+ writeFileSync(join(root, 'server.js'), 'window.renderServerModal()')
+ writeFileSync(join(root, 'server.css'), 'body { margin: 0 }')
})
afterAll(() => {
@@ -83,6 +87,21 @@ describe('createLocalPageHandler', () => {
expect(await response.text()).toBe('offline ')
})
+ it.each([
+ ['server.js', 'text/javascript; charset=utf-8'],
+ ['server.css', 'text/css; charset=utf-8'],
+ ])(
+ 'serves the bundled renderer asset %s with a strict content type',
+ async (name, contentType) => {
+ const response = await createLocalPageHandler([root])(
+ new Request(`${LOCAL_PAGE_ORIGIN}/${name}`)
+ )
+ expect(response.status).toBe(200)
+ expect(response.headers.get('content-type')).toBe(contentType)
+ expect(response.headers.get('x-content-type-options')).toBe('nosniff')
+ }
+ )
+
it('refuses everything outside the allowlist, however the path is spelled', async () => {
const handler = createLocalPageHandler([root])
for (const path of [
diff --git a/apps/desktop/src/main/local-pages.ts b/apps/desktop/src/main/local-pages.ts
index 1446b6517f0..4e34f2e9870 100644
--- a/apps/desktop/src/main/local-pages.ts
+++ b/apps/desktop/src/main/local-pages.ts
@@ -25,7 +25,7 @@ export const LOCAL_PAGE_SCHEME = 'sim-shell'
const LOCAL_PAGE_HOST = 'pages'
export const LOCAL_PAGE_ORIGIN = `${LOCAL_PAGE_SCHEME}://${LOCAL_PAGE_HOST}`
-export type LocalPage = 'offline.html' | 'server.html'
+export type LocalPage = 'offline.html' | 'server.html' | 'dialog.html'
const LOCAL_PAGES: ReadonlySet = new Set(['offline.html', 'server.html'])
@@ -34,11 +34,23 @@ const LOCAL_PAGES: ReadonlySet = new Set(['offline.html', 'se
* directory walk: nothing outside it can be requested however the path is
* spelled, and adding an asset is a deliberate one-line change.
*/
-const SERVABLE_FILES: ReadonlySet = new Set([...LOCAL_PAGES, 'SeasonSansUprightsVF.woff2'])
+const SERVABLE_FILES: ReadonlySet = new Set([
+ ...LOCAL_PAGES,
+ 'SeasonSansUprightsVF.woff2',
+ 'server.js',
+ 'server.css',
+ 'offline.js',
+ 'offline.css',
+ 'dialog.html',
+ 'dialog.js',
+ 'dialog.css',
+])
const CONTENT_TYPES: Readonly> = {
'.html': 'text/html; charset=utf-8',
'.woff2': 'font/woff2',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
}
/** Builds the URL of a bundled page, with its query encoded. */
@@ -152,14 +164,14 @@ async function readFirst(rootDirs: readonly string[], name: string): Promise {
])
expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([
- 'about',
+ 'About Sim',
'Settings…',
'Server…',
'Check for Updates…',
diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts
index cf1b94009f6..c7d61ec0ba6 100644
--- a/apps/desktop/src/main/menu.ts
+++ b/apps/desktop/src/main/menu.ts
@@ -1,6 +1,7 @@
import type { MenuItemConstructorOptions } from 'electron'
import { app, BrowserWindow, Menu } from 'electron'
import { type ConfigStore, isSimCloudOrigin } from '@/main/config'
+import { showShellDialog } from '@/main/dialogs'
import { DOCS_URL, STATUS_URL } from '@/main/external-links'
import { openExternalSafe } from '@/main/navigation'
import type {
@@ -166,7 +167,18 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
{
label: app.name,
submenu: [
- { role: 'about' },
+ {
+ label: `About ${app.name}`,
+ click: () => {
+ void showShellDialog({
+ title: `About ${app.name}`,
+ type: 'info',
+ message: app.name,
+ detail: `Version ${app.getVersion()}`,
+ buttons: ['OK'],
+ })
+ },
+ },
{ label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings },
{ label: 'Server…', click: deps.openServerSettings },
{ label: 'Check for Updates…', click: deps.checkForUpdates },
diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts
index 142283ef9da..7dec829b156 100644
--- a/apps/desktop/src/main/observability.ts
+++ b/apps/desktop/src/main/observability.ts
@@ -2,7 +2,8 @@ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from 'node
import { join } from 'node:path'
import { createLogger } from '@sim/logger'
import type { BrowserWindow, Details } from 'electron'
-import { app, dialog } from 'electron'
+import { app } from 'electron'
+import { showShellDialog } from '@/main/dialogs'
const logger = createLogger('DesktopEvents')
@@ -124,9 +125,7 @@ export function installMainProcessFailureObservers({
}
const win = getWindow()
const prompt =
- win && !win.isDestroyed()
- ? dialog.showMessageBox(win, options)
- : dialog.showMessageBox(options)
+ win && !win.isDestroyed() ? showShellDialog(win, options) : showShellDialog(options)
void prompt
.then(({ response }) => {
if (response === 0) app.relaunch()
diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts
index c6936234413..b273eac6561 100644
--- a/apps/desktop/src/main/server-window.test.ts
+++ b/apps/desktop/src/main/server-window.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -67,11 +67,17 @@ describe('server window', () => {
let deps: ServerWindowDeps
beforeEach(() => {
+ vi.useFakeTimers()
deps = makeDeps()
MockBrowserWindow.instances = []
vi.mocked(dialog.showMessageBox).mockClear()
})
+ afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ })
+
// The page ships inside app.asar. Loaded over `file:` it never rendered in a
// packaged build (the file-protocol fuse is off), which is the blank sheet
// this window used to open as.
@@ -97,6 +103,24 @@ describe('server window', () => {
expect(event.preventDefault).toHaveBeenCalledTimes(1)
})
+ it('only shows the picker once its renderer has supplied a content size', () => {
+ const { win } = openPicker(deps)
+ expect(win.show).not.toHaveBeenCalled()
+ win.webContents.mainFrame.url = 'sim-shell://pages/server.html'
+ const resize = win.webContents.ipc.on.mock.calls.find(([name]) => name === 'shell:resize')?.[1]
+ resize?.({ sender: win.webContents, senderFrame: win.webContents.mainFrame }, 320)
+ expect(win.show).toHaveBeenCalledOnce()
+ vi.advanceTimersByTime(10_000)
+ expect(dialog.showMessageBox).not.toHaveBeenCalled()
+ })
+
+ it('recovers if the HTML loads but the renderer never becomes ready', () => {
+ const { win } = openPicker(deps)
+ vi.advanceTimersByTime(10_000)
+ expect(win.destroy).toHaveBeenCalledOnce()
+ expect(dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+
it('never leaves a blank sheet when the page fails to load', () => {
const { win, handler } = openPicker(deps)
diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts
index db4685b9e9f..4c846cc09f2 100644
--- a/apps/desktop/src/main/server-window.ts
+++ b/apps/desktop/src/main/server-window.ts
@@ -1,20 +1,20 @@
+import { dirname, join } from 'node:path'
import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { app, BrowserWindow, dialog, nativeTheme, session } from 'electron'
+import { app, BrowserWindow, nativeTheme, session } from 'electron'
import type { ConfigStore, DesktopSettings } from '@/main/config'
import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config'
+import { showShellDialog } from '@/main/dialogs'
import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages'
-import {
- backgroundColorFor,
- createSecureWebPreferences,
- setupPermissionHandlers,
-} from '@/main/window'
+import { attachShellWindowSizing } from '@/main/shell-window'
+import { backgroundColorFor, setupPermissionHandlers } from '@/main/window'
+import { createSecureWebPreferences } from '@/main/window-preferences'
const logger = createLogger('DesktopServerWindow')
-const WINDOW_WIDTH = 520
-const WINDOW_HEIGHT = 340
+const WINDOW_WIDTH = 500
+const WINDOW_HEIGHT = 300
/**
* The partition the server-selection window runs in.
@@ -141,12 +141,13 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
win = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
+ useContentSize: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
title: 'Sim Server',
- titleBarStyle: 'hiddenInset',
+ frame: false,
show: false,
// System preference only, unlike the main window: that one pre-paints for
// the web app it is about to load, whose theme the user picked in Sim.
@@ -160,22 +161,19 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}),
webPreferences: createSecureWebPreferences(
SERVER_WINDOW_PARTITION,
- deps.preloadPath,
+ join(dirname(deps.preloadPath), 'shell-preload.cjs'),
deps.isPackaged
),
})
- win.once('ready-to-show', () => {
- win?.show()
- })
- win.on('closed', () => {
- win = null
- })
// A sheet has no title bar, and the page owns the only Cancel button. Both
// ways out must therefore work without the page: Escape is handled here,
// and a page that fails to load closes the window instead of leaving a
// blank sheet nothing can dismiss.
const opened = win
+ let closed = false
const closeOpened = () => {
+ closed = true
+ clearTimeout(loadTimeout)
if (!opened.isDestroyed()) {
opened.destroy()
}
@@ -183,6 +181,30 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
win = null
}
}
+ const failed = () => {
+ if (closed || opened.isDestroyed()) return
+ closeOpened()
+ const options = {
+ type: 'error' as const,
+ message: 'Couldn’t open the server settings',
+ detail: 'Sim could not load its server settings page. Restart Sim and try again.',
+ }
+ void (parent && !parent.isDestroyed()
+ ? showShellDialog(parent, options)
+ : showShellDialog(options))
+ }
+ const loadTimeout = setTimeout(failed, 10_000)
+ opened.on('closed', () => {
+ closed = true
+ clearTimeout(loadTimeout)
+ if (win === opened) win = null
+ })
+ attachShellWindowSizing(opened, localPageUrl('server.html'), WINDOW_WIDTH, () => {
+ if (closed) return
+ clearTimeout(loadTimeout)
+ opened.show()
+ })
+ opened.webContents.on('render-process-gone', failed)
opened.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.key === 'Escape') {
event.preventDefault()
@@ -195,19 +217,12 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
// -3 is ERR_ABORTED: a load this window cancelled, not a page that failed.
if (!isMainFrame || errorCode === -3) return
logger.error('Server window page failed to load', { errorCode, errorDescription })
- closeOpened()
- const options = {
- type: 'error' as const,
- message: 'Couldn’t open the server settings',
- detail: 'Sim could not load its server settings page. Restart Sim and try again.',
- }
- void (parent && !parent.isDestroyed()
- ? dialog.showMessageBox(parent, options)
- : dialog.showMessageBox(options))
+ failed()
}
)
void opened.loadURL(localPageUrl('server.html')).catch((error) => {
logger.error('Could not open the server window', { error: getErrorMessage(error) })
+ failed()
})
}
diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts
index 5a8eee85932..cf16a6fa675 100644
--- a/apps/desktop/src/main/session-lifecycle.ts
+++ b/apps/desktop/src/main/session-lifecycle.ts
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import type { Session, WebContents } from 'electron'
-import { BrowserWindow, dialog } from 'electron'
+import { BrowserWindow } from 'electron'
import {
beginAccountDataTeardown,
completeAccountDataTeardown,
@@ -9,6 +9,7 @@ import {
} from '@/main/account-data-generation'
import { APP_ENTRY_ROUTE } from '@/main/app-routes'
import { isSafeInternalPath } from '@/main/config'
+import { showShellDialog } from '@/main/dialogs'
import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation'
import type { EventRecorder } from '@/main/observability'
@@ -378,7 +379,7 @@ export function createSessionLifecycleCoordinator(
})
.catch((error) => {
logger.error('Session teardown failed; refusing to report a clean sign-out', { error })
- void dialog.showMessageBox({
+ void showShellDialog({
type: 'error',
message: 'Sim could not finish signing out',
detail:
@@ -466,9 +467,7 @@ export async function handleConnectIntercept(
detail:
'This provider requires completing the connection in your web browser. Sim will open this page there — connect the account, then come back to the app and refresh.',
}
- const { response } = win
- ? await dialog.showMessageBox(win, options)
- : await dialog.showMessageBox(options)
+ const { response } = win ? await showShellDialog(win, options) : await showShellDialog(options)
if (response === 0) {
await openExternalSafe(pageUrl, allowHttpLocalhost)
}
diff --git a/apps/desktop/src/main/shell-window.ts b/apps/desktop/src/main/shell-window.ts
new file mode 100644
index 00000000000..af74d8811b3
--- /dev/null
+++ b/apps/desktop/src/main/shell-window.ts
@@ -0,0 +1,37 @@
+import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent } from 'electron'
+import { screen } from 'electron'
+
+/** Only the exact bundled top-level document may operate its own host window. */
+export function isShellWindowSender(
+ win: BrowserWindow,
+ pageUrl: string,
+ event: IpcMainEvent | IpcMainInvokeEvent
+): boolean {
+ return (
+ !win.isDestroyed() &&
+ event.sender === win.webContents &&
+ event.senderFrame === win.webContents.mainFrame &&
+ event.senderFrame?.url === pageUrl
+ )
+}
+
+/** Keeps compact native dialogs at their rendered content height, within the display. */
+export function attachShellWindowSizing(
+ win: BrowserWindow,
+ pageUrl: string,
+ width: number,
+ onReady?: () => void
+) {
+ let ready = false
+ win.webContents.ipc.on('shell:resize', (event, height: unknown) => {
+ if (!isShellWindowSender(win, pageUrl, event)) return
+ if (typeof height !== 'number' || !Number.isFinite(height)) return
+ const available = screen.getDisplayMatching(win.getBounds()).workArea.height - 80
+ const nextHeight = Math.min(Math.max(Math.ceil(height), 120), available)
+ if (win.getContentSize()[1] !== nextHeight) win.setContentSize(width, nextHeight)
+ if (!ready) {
+ ready = true
+ onReady?.()
+ }
+ })
+}
diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts
index 3f9367a448c..6256a0bcd93 100644
--- a/apps/desktop/src/main/terminal/index.ts
+++ b/apps/desktop/src/main/terminal/index.ts
@@ -165,6 +165,7 @@ export class TerminalService {
/** Insertion-ordered, which is also the tab order the user sees. */
private readonly sessions = new Map()
private activeId: string | null = null
+ private readonly pendingCloseConfirmations = new Set()
private agentActiveId: string | null = null
private activeTerminalUserSelected = false
/** True while tearing every shell down, so an exit does not respawn one. */
@@ -492,7 +493,7 @@ export class TerminalService {
shortcut: FocusedResourceShortcut,
ownerWindow: BrowserWindow | null,
emitRendererCommand: (command: TerminalShortcutCommand, terminalId: string) => void,
- confirmCloseRunning?: (running: string) => boolean
+ confirmCloseRunning?: (running: string) => boolean | Promise
): boolean {
// Hard reload has no terminal meaning — leave it to the Browser or shell.
if (shortcut === 'focus-omnibox' || shortcut === 'hard-reload') return false
@@ -527,7 +528,10 @@ export class TerminalService {
if (this.activeId) {
const active = this.sessions.get(this.activeId)
const running = active?.isBusy ? (active.foreground ?? 'A process') : null
- if (running && confirmCloseRunning && !confirmCloseRunning(running)) return true
+ if (running && active && confirmCloseRunning) {
+ void this.confirmCloseRunningTerminal(active, running, confirmCloseRunning)
+ return true
+ }
this.closeTerminal(this.activeId)
}
return true
@@ -543,6 +547,27 @@ export class TerminalService {
return true
}
+ /** Revalidates the captured terminal after an asynchronous native-window confirmation. */
+ private async confirmCloseRunningTerminal(
+ terminal: TerminalSession,
+ running: string,
+ confirm: (running: string) => boolean | Promise
+ ): Promise {
+ const id = this.activeId
+ if (!id || this.pendingCloseConfirmations.has(id)) return
+ this.pendingCloseConfirmations.add(id)
+ try {
+ if (!(await confirm(running))) return
+ if (this.sessions.get(id) !== terminal) return
+ if (terminal.isBusy && (terminal.foreground ?? 'A process') !== running) return
+ this.closeTerminal(id)
+ } catch {
+ logger.warn('Could not confirm closing the running terminal')
+ } finally {
+ this.pendingCloseConfirmations.delete(id)
+ }
+ }
+
/**
* Whether the terminal panel owns keyboard focus. Menu accelerators are
* global, so Cmd-W has to know whether the user is looking at a terminal or
diff --git a/apps/desktop/src/main/terminal/registry.test.ts b/apps/desktop/src/main/terminal/registry.test.ts
index a3f50e674c3..e463431a91e 100644
--- a/apps/desktop/src/main/terminal/registry.test.ts
+++ b/apps/desktop/src/main/terminal/registry.test.ts
@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'
import type { TerminalCommandEvent } from '@sim/terminal-protocol'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+vi.mock('electron', () => import('@/test/electron-mock'))
+
interface StubSessionControl {
terminalId: string
cwd: string
diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts
index b6a4ac7c90c..db737cad65f 100644
--- a/apps/desktop/src/main/terminal/registry.ts
+++ b/apps/desktop/src/main/terminal/registry.ts
@@ -8,8 +8,9 @@ import {
type TerminalToolArgs,
type TerminalToolResponse,
} from '@sim/terminal-protocol'
-import { type BrowserWindow, dialog, type WebContents } from 'electron'
+import type { BrowserWindow, WebContents } from 'electron'
import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store'
+import { showShellDialog } from '@/main/dialogs'
import type { FocusedResourceShortcut } from '@/main/resource-shortcuts'
import {
MAX_TERMINALS_PER_SCOPE,
@@ -300,20 +301,19 @@ export class TerminalRegistry {
terminalId
)
},
- (running) => {
+ async (running) => {
if (!ownerWindow || ownerWindow.isDestroyed()) return false
- return (
- dialog.showMessageBoxSync(ownerWindow, {
- type: 'warning',
- title: 'Close Running Terminal?',
- message: `${describeRunningCommand(running)} is still running.`,
- detail: 'Closing this terminal will stop the process.',
- buttons: ['Close Terminal', 'Cancel'],
- defaultId: 1,
- cancelId: 1,
- noLink: true,
- }) === 0
- )
+ const { response } = await showShellDialog(ownerWindow, {
+ type: 'warning',
+ title: 'Close running terminal?',
+ message: `${describeRunningCommand(running)} is still running.`,
+ detail: 'Closing this terminal will stop the process.',
+ buttons: ['Close Terminal', 'Cancel'],
+ primaryVariant: 'destructive',
+ defaultId: 1,
+ cancelId: 1,
+ })
+ return response === 0 && !ownerWindow.isDestroyed()
}
)
) {
diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts
index 5e6122a0b8f..174a3aa10a8 100644
--- a/apps/desktop/src/main/terminal/service.test.ts
+++ b/apps/desktop/src/main/terminal/service.test.ts
@@ -266,6 +266,53 @@ describe('focus-gated shortcuts', () => {
expect(terminal.getTabs().activeTerminalId).toBe(activeId)
})
+ it('waits for a single confirmation and closes only the captured running terminal', async () => {
+ const terminal = service()
+ const started = terminal.start({ cols: 80, rows: 24 })
+ const id = started.activeTerminalId as string
+ stubSessions.get(id)?.setBusy(true)
+ const renderer = rendererStub()
+ terminal.setPanelFocused(true, renderer.contents)
+ let resolvePermission: (value: boolean) => void = () => {}
+ const permission = {
+ promise: new Promise((resolve) => {
+ resolvePermission = resolve
+ }),
+ resolve: (value: boolean) => resolvePermission(value),
+ }
+ const confirm = vi.fn(() => permission.promise)
+ terminal.handleFocusedShortcut('close-tab', renderer.window, vi.fn(), confirm)
+ terminal.handleFocusedShortcut('close-tab', renderer.window, vi.fn(), confirm)
+ expect(confirm).toHaveBeenCalledOnce()
+ expect(terminal.getTabs().tabs.some((tab) => tab.terminalId === id)).toBe(true)
+ permission.resolve(true)
+ await vi.waitFor(() =>
+ expect(terminal.getTabs().tabs.some((tab) => tab.terminalId === id)).toBe(false)
+ )
+ })
+
+ it('does not close a replacement terminal after the original exits during confirmation', async () => {
+ const terminal = service()
+ const started = terminal.start({ cols: 80, rows: 24 })
+ const id = started.activeTerminalId as string
+ stubSessions.get(id)?.setBusy(true)
+ const renderer = rendererStub()
+ terminal.setPanelFocused(true, renderer.contents)
+ let resolvePermission: (value: boolean) => void = () => {}
+ const permission = {
+ promise: new Promise((resolve) => {
+ resolvePermission = resolve
+ }),
+ resolve: (value: boolean) => resolvePermission(value),
+ }
+ terminal.handleFocusedShortcut('close-tab', renderer.window, vi.fn(), () => permission.promise)
+ stubSessions.get(id)?.exit()
+ const replacement = terminal.openTerminal().activeTerminalId
+ permission.resolve(true)
+ await permission.promise
+ expect(terminal.getTabs().activeTerminalId).toBe(replacement)
+ })
+
it('opens tabs in main and sends canvas commands to the focused renderer', () => {
const terminal = service()
terminal.start({ cols: 80, rows: 24 })
diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts
index a185f6c1669..1f111282976 100644
--- a/apps/desktop/src/main/updater.ts
+++ b/apps/desktop/src/main/updater.ts
@@ -3,7 +3,8 @@ import type { DesktopUpdateState } from '@sim/desktop-bridge'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { BrowserWindow } from 'electron'
-import { app, dialog, net } from 'electron'
+import { app, net } from 'electron'
+import { showShellDialog } from '@/main/dialogs'
import { isSafeExternalUrl, openExternalSafe } from '@/main/navigation'
import type { EventRecorder } from '@/main/observability'
@@ -434,9 +435,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
'Sim will close all app windows while it updates. Running terminal commands, browser activity, downloads, uploads, and unsaved edits may be interrupted. Choose Later to install the update the next time you quit Sim.',
}
const win = deps.getWindow()
- const confirmation = win
- ? dialog.showMessageBox(win, options)
- : dialog.showMessageBox(options)
+ const confirmation = win ? showShellDialog(win, options) : showShellDialog(options)
void confirmation
.then(({ response }) => {
if (response === 1 && state.status === 'ready' && state.version === version) {
@@ -903,7 +902,7 @@ export function checkForUpdatesInteractive(
deps: Pick & { handle: UpdaterHandle | null }
): void {
if (!app.isPackaged) {
- void dialog.showMessageBox({
+ void showShellDialog({
type: 'info',
message: 'Updates are only available in packaged builds',
})
@@ -917,7 +916,7 @@ export function checkForUpdatesInteractive(
const showDialog = (options: Electron.MessageBoxOptions) => {
const win = deps.getWindow()
- return win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options)
+ return win ? showShellDialog(win, options) : showShellDialog(options)
}
const settle = (state: DesktopUpdateState) => {
diff --git a/apps/desktop/src/main/window-preferences.ts b/apps/desktop/src/main/window-preferences.ts
new file mode 100644
index 00000000000..9a92f224a58
--- /dev/null
+++ b/apps/desktop/src/main/window-preferences.ts
@@ -0,0 +1,27 @@
+import type { WebPreferences } from 'electron'
+import { app } from 'electron'
+
+/**
+ * The hardened webPreferences shared by the main window and any child window.
+ * 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.
+ */
+export function createSecureWebPreferences(
+ partition: string,
+ preloadPath: string,
+ isPackaged: boolean
+): WebPreferences {
+ return {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webSecurity: true,
+ webviewTag: false,
+ devTools: !isPackaged,
+ spellcheck: true,
+ partition,
+ preload: preloadPath,
+ additionalArguments: [`--sim-desktop-version=${app.getVersion()}`],
+ }
+}
diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts
index a92e177bc5a..2d2ca5488be 100644
--- a/apps/desktop/src/main/window.test.ts
+++ b/apps/desktop/src/main/window.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createSecureWebPreferences } from '@/main/window-preferences'
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -8,7 +9,6 @@ import type { EventRecorder } from '@/main/observability'
import {
backgroundColorFor,
createMainWindow,
- createSecureWebPreferences,
ensureMicrophoneAccess,
fitBoundsToWorkArea,
resolvePermission,
diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts
index 90f93e6882c..52fb546115e 100644
--- a/apps/desktop/src/main/window.ts
+++ b/apps/desktop/src/main/window.ts
@@ -1,10 +1,12 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import type { Event, Rectangle, Session, WebPreferences } from 'electron'
+import type { Event, Rectangle, Session } from 'electron'
import { app, BrowserWindow, dialog, nativeTheme, screen, systemPreferences } from 'electron'
import { type ConfigStore, isSafeInternalPath, type WindowBounds } from '@/main/config'
+import { showShellDialog } from '@/main/dialogs'
import { isAppOrigin, isAuthSurfacePath } from '@/main/navigation'
import type { EventRecorder } from '@/main/observability'
+import { createSecureWebPreferences } from '@/main/window-preferences'
const logger = createLogger('DesktopWindow')
@@ -26,31 +28,6 @@ const THEME_PROBE_SCRIPT = `(() => {
}
})()`
-/**
- * The hardened webPreferences shared by the main window and any child window.
- * 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.
- */
-export function createSecureWebPreferences(
- partition: string,
- preloadPath: string,
- isPackaged: boolean
-): WebPreferences {
- return {
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: true,
- webSecurity: true,
- webviewTag: false,
- devTools: !isPackaged,
- spellcheck: true,
- partition,
- preload: preloadPath,
- additionalArguments: [`--sim-desktop-version=${app.getVersion()}`],
- }
-}
-
/**
* The permission matrix: sanitized clipboard writes and microphone access for
* the trusted app origin, default-deny for everything else including unknown
@@ -203,7 +180,12 @@ export function fitBoundsToWorkArea(bounds: WindowBounds, workArea: Rectangle):
return { x, y, width, height }
}
-/** Applies the shared renderer unload decision to main and child windows. */
+/**
+ * Applies the shared renderer unload decision to main and child windows.
+ * Electron requires this decision before the event returns. Keep this one
+ * synchronous OS confirmation: awaiting a renderer dialog here loses the
+ * pending navigation or close and can discard unsaved changes.
+ */
export function handleWillPreventUnload(
win: BrowserWindow,
event: Event,
@@ -339,15 +321,14 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow {
return
}
recoveryDialog = 'crash'
- void dialog
- .showMessageBox(win, {
- type: 'error',
- buttons: ['Reload', 'Quit Sim'],
- defaultId: 0,
- cancelId: 0,
- message: 'Sim encountered a problem',
- detail: 'The page stopped unexpectedly. Reload to pick up where you left off.',
- })
+ void showShellDialog(win, {
+ type: 'error',
+ buttons: ['Reload', 'Quit Sim'],
+ defaultId: 0,
+ cancelId: 0,
+ message: 'Sim encountered a problem',
+ detail: 'The page stopped unexpectedly. Reload to pick up where you left off.',
+ })
.then(({ response }) => {
if (win.isDestroyed()) return
if (response === 0) win.webContents.reload()
@@ -377,15 +358,14 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow {
if (recoveryDialog !== null || win.isDestroyed()) return
recoveryDialog = 'hang'
deps.events.record('renderer_unresponsive')
- void dialog
- .showMessageBox(win, {
- type: 'warning',
- buttons: ['Wait', 'Reload'],
- defaultId: 0,
- cancelId: 0,
- message: 'Sim isn’t responding',
- detail: 'You can wait for it to recover or reload the page.',
- })
+ void showShellDialog(win, {
+ type: 'warning',
+ buttons: ['Wait', 'Reload'],
+ defaultId: 0,
+ cancelId: 0,
+ message: 'Sim isn’t responding',
+ detail: 'You can wait for it to recover or reload the page.',
+ })
.then(({ response }) => {
if (!win.isDestroyed() && response === 1) {
win.webContents.reload()
diff --git a/apps/desktop/src/preload/shell.ts b/apps/desktop/src/preload/shell.ts
new file mode 100644
index 00000000000..900c7993aef
--- /dev/null
+++ b/apps/desktop/src/preload/shell.ts
@@ -0,0 +1,14 @@
+import { contextBridge, ipcRenderer } from 'electron'
+import type { ShellWindowApi } from '@/shared/shell'
+
+const api: ShellWindowApi = {
+ resizeContent: (height) => ipcRenderer.send('shell:resize', height),
+ getDialogConfiguration: () => ipcRenderer.invoke('shell:configuration'),
+ respond: (response) => ipcRenderer.send('shell:respond', response),
+ server: {
+ getConfiguration: () => ipcRenderer.invoke('server:get-configuration'),
+ setOrigin: (origin) => ipcRenderer.invoke('server:set-origin', origin),
+ },
+}
+
+contextBridge.exposeInMainWorld('simShell', api)
diff --git a/apps/desktop/src/renderer/dialog/index.tsx b/apps/desktop/src/renderer/dialog/index.tsx
new file mode 100644
index 00000000000..ad13bf53a9f
--- /dev/null
+++ b/apps/desktop/src/renderer/dialog/index.tsx
@@ -0,0 +1,79 @@
+import { ChipModalBody, ChipModalFooter, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
+import { CircleInfo, TriangleAlert } from '@sim/emcn/icons'
+import { createRoot } from 'react-dom/client'
+import { initializeShellPage, observeShellSize, shellWindow } from '@/renderer/shell'
+import type { ShellDialogConfiguration } from '@/shared/shell'
+import '@/renderer/shell.css'
+
+interface ShellDialogProps {
+ configuration: ShellDialogConfiguration
+}
+
+function ShellDialog({ configuration }: ShellDialogProps) {
+ const { message, detail, buttons, defaultId, cancelId, type } = configuration
+ const primaryId = buttons.length === 1 ? 0 : buttons.findIndex((_, index) => index !== cancelId)
+ const respond = (response: number) => shellWindow?.respond(response)
+ const close = () => respond(cancelId)
+
+ return (
+ {
+ element?.querySelector('[data-chip-modal-default-action]')?.focus()
+ return observeShellSize(element)
+ }}
+ role='dialog'
+ aria-modal='true'
+ aria-labelledby='dialog-title'
+ aria-describedby={detail ? 'dialog-message dialog-detail' : 'dialog-message'}
+ className='max-h-screen'
+ >
+
+ {configuration.title}
+
+
+
+ {message}
+
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+ 1
+ ? { onCancel: close, cancelLabel: buttons[cancelId] }
+ : { hideCancel: true })}
+ primaryAction={{
+ label: buttons[primaryId],
+ variant: configuration.primaryVariant,
+ onClick: () => respond(primaryId),
+ }}
+ secondaryActions={buttons.flatMap((label, index) =>
+ index !== primaryId && index !== cancelId
+ ? [{ label, onClick: () => respond(index) }]
+ : []
+ )}
+ />
+
+ )
+}
+
+initializeShellPage()
+const container = document.getElementById('root')
+if (!container || !shellWindow) throw new Error('Dialog host is unavailable')
+void shellWindow.getDialogConfiguration().then((configuration) => {
+ document.title = configuration.title
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') shellWindow?.respond(configuration.cancelId)
+ })
+ createRoot(container).render( )
+})
diff --git a/apps/desktop/src/renderer/offline/index.tsx b/apps/desktop/src/renderer/offline/index.tsx
new file mode 100644
index 00000000000..c00417bb57c
--- /dev/null
+++ b/apps/desktop/src/renderer/offline/index.tsx
@@ -0,0 +1,116 @@
+import { useState } from 'react'
+import type { SimDesktopApi } from '@sim/desktop-bridge'
+import { Chip } from '@sim/emcn'
+import { ArrowUpRight, RefreshCw, Server, Wordmark } from '@sim/emcn/icons'
+import { createRoot } from 'react-dom/client'
+import { initializeShellPage } from '@/renderer/shell'
+import '@/renderer/shell.css'
+
+const ERROR_COPY = {
+ offline: {
+ title: 'You’re offline',
+ message:
+ 'Sim needs an internet connection. Reconnect, then try again. We’ll also retry automatically.',
+ },
+ dns: {
+ title: 'Can’t find the server',
+ message:
+ 'The server address couldn’t be resolved. Check your connection or the configured server.',
+ },
+ tls: {
+ title: 'Connection isn’t secure',
+ message: 'The server’s TLS certificate couldn’t be verified, so the connection was refused.',
+ },
+ timeout: {
+ title: 'The server isn’t responding',
+ message: 'The connection timed out. The server may be down or your network may be blocking it.',
+ },
+ unreachable: {
+ title: 'Can’t connect to Sim',
+ message: 'Sim couldn’t reach the server. Check your internet connection, then try again.',
+ },
+} as const
+
+const params = new URLSearchParams(location.search)
+const kind = params.get('kind') ?? 'unreachable'
+const copy = Object.hasOwn(ERROR_COPY, kind)
+ ? ERROR_COPY[kind as keyof typeof ERROR_COPY]
+ : ERROR_COPY.unreachable
+const detail = params.get('detail')
+const bridge = (window as Window & { simDesktop?: SimDesktopApi }).simDesktop
+
+interface OfflinePageProps {
+ isSimCloud: boolean
+}
+
+function OfflinePage({ isSimCloud }: OfflinePageProps) {
+ const [actionError, setActionError] = useState('')
+
+ async function checkStatus() {
+ setActionError('')
+ try {
+ if (!(await bridge?.openExternal('https://status.sim.ai'))) {
+ setActionError('Could not open the status page. Try again.')
+ }
+ } catch {
+ setActionError('Could not open the status page. Try again.')
+ }
+ }
+
+ return (
+
+
+
+
+
+ {copy.title}
+
+
{copy.message}
+
+ bridge?.offlineRetry()}
+ >
+ Retry
+
+ {isSimCloud ? (
+
+ Check status
+
+ ) : null}
+ bridge?.server?.open()}>
+ Change server
+
+
+
+ {actionError || detail}
+
+
+
+
+ )
+}
+
+initializeShellPage()
+const container = document.getElementById('root')
+if (!container) throw new Error('Offline page root is missing')
+const root = createRoot(container)
+root.render( )
+void bridge?.server
+ ?.getConfiguration()
+ .then(({ isSimCloud }) => {
+ root.render( )
+ })
+ .catch(() => {})
diff --git a/apps/desktop/src/renderer/server/index.tsx b/apps/desktop/src/renderer/server/index.tsx
new file mode 100644
index 00000000000..3c7b1fdbb94
--- /dev/null
+++ b/apps/desktop/src/renderer/server/index.tsx
@@ -0,0 +1,25 @@
+import { createRoot } from 'react-dom/client'
+import { ServerModal } from '@/renderer/server/server-modal'
+import { initializeShellPage, shellWindow } from '@/renderer/shell'
+import '@/renderer/shell.css'
+
+initializeShellPage()
+document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') window.close()
+})
+
+const server = shellWindow?.server
+const container = document.getElementById('root')
+if (!container) throw new Error('Server modal root is missing')
+const root = createRoot(container)
+
+async function renderServerModal() {
+ try {
+ const configuration = await server?.getConfiguration()
+ root.render( )
+ } catch {
+ root.render( )
+ }
+}
+
+void renderServerModal()
diff --git a/apps/desktop/src/renderer/server/server-modal.tsx b/apps/desktop/src/renderer/server/server-modal.tsx
new file mode 100644
index 00000000000..f240b739794
--- /dev/null
+++ b/apps/desktop/src/renderer/server/server-modal.tsx
@@ -0,0 +1,111 @@
+import { useRef, useState } from 'react'
+import type { DesktopServerConfiguration } from '@sim/desktop-bridge'
+import {
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ ChipModalSurface,
+} from '@sim/emcn'
+import { Server } from '@sim/emcn/icons'
+import { observeShellSize } from '@/renderer/shell'
+import type { ShellWindowApi } from '@/shared/shell'
+
+interface ServerModalProps {
+ server: ShellWindowApi['server'] | undefined
+ configuration?: DesktopServerConfiguration
+ initialError?: string
+}
+
+function closeWindow() {
+ window.close()
+}
+
+function focusServerInput(element: HTMLDivElement | null) {
+ element?.querySelector('input')?.select()
+ return observeShellSize(element)
+}
+
+export function ServerModal({ server, configuration, initialError }: ServerModalProps) {
+ const requestInFlight = useRef(false)
+ const [origin, setOrigin] = useState(configuration?.origin ?? '')
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState(initialError)
+ const [message, setMessage] = useState(
+ configuration && configuration.origin !== configuration.defaultOrigin
+ ? `This build defaults to ${configuration.defaultOrigin}`
+ : ''
+ )
+
+ async function connect() {
+ if (requestInFlight.current || !origin.trim()) return
+ requestInFlight.current = true
+ setPending(true)
+ setError(undefined)
+ setMessage('')
+ try {
+ const result = await server?.setOrigin(origin)
+ if (!result) {
+ setError('The desktop shell is unavailable.')
+ } else if (!result.ok) {
+ setError(result.error)
+ } else if (result.unchanged) {
+ setMessage('Already connected to this server.')
+ }
+ } catch {
+ setError('The server could not be changed.')
+ } finally {
+ requestInFlight.current = false
+ setPending(false)
+ }
+ }
+
+ return (
+
+
+ Sim server
+
+
+
+ Point this app at your own Sim deployment. Self-hosted servers must use HTTPS; localhost
+ may use HTTP.
+
+ {
+ setOrigin(value)
+ setError(undefined)
+ setMessage('')
+ }}
+ autoComplete='off'
+ placeholder='https://sim.example.com'
+ disabled={pending}
+ error={error}
+ hint={{pending ? 'Connecting…' : message} }
+ />
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/shell.css b/apps/desktop/src/renderer/shell.css
new file mode 100644
index 00000000000..5c49487a5d1
--- /dev/null
+++ b/apps/desktop/src/renderer/shell.css
@@ -0,0 +1,17 @@
+@import "../../../sim/app/_styles/globals.css";
+
+@source ".";
+
+@font-face {
+ font-family: "Season Sans";
+ src: url("./SeasonSansUprightsVF.woff2") format("woff2");
+ font-style: normal;
+ font-weight: 300 800;
+ font-display: block;
+}
+
+html {
+ font-family: "Season Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+}
diff --git a/apps/desktop/src/renderer/shell.ts b/apps/desktop/src/renderer/shell.ts
new file mode 100644
index 00000000000..49a284719dc
--- /dev/null
+++ b/apps/desktop/src/renderer/shell.ts
@@ -0,0 +1,32 @@
+import type { ShellWindowApi } from '@/shared/shell'
+
+export const shellWindow = (window as Window & { simShell?: ShellWindowApi }).simShell
+
+/** Local windows follow the system theme independently of any reachable deployment. */
+export function initializeShellPage() {
+ const theme = window.matchMedia('(prefers-color-scheme: dark)')
+ const syncTheme = () => document.documentElement.classList.toggle('dark', theme.matches)
+ syncTheme()
+ theme.addEventListener('change', syncTheme)
+}
+
+/** Fits the native window to the complete modal, including changing inline messages. */
+export function observeShellSize(element: HTMLDivElement | null) {
+ if (!element || !shellWindow) return
+ const resize = () => {
+ const body = element.querySelector('[data-chip-modal-body]')
+ const overflow = body ? body.scrollHeight - body.clientHeight : 0
+ shellWindow.resizeContent(Math.ceil(element.getBoundingClientRect().height + overflow))
+ }
+ const observer = new ResizeObserver(resize)
+ observer.observe(element)
+ for (const child of element.querySelectorAll('[data-chip-modal-body] > *'))
+ observer.observe(child)
+ const mutations = new MutationObserver(resize)
+ mutations.observe(element, { childList: true, characterData: true, subtree: true })
+ resize()
+ return () => {
+ observer.disconnect()
+ mutations.disconnect()
+ }
+}
diff --git a/apps/desktop/src/renderer/styles.d.ts b/apps/desktop/src/renderer/styles.d.ts
new file mode 100644
index 00000000000..fa5d6b64af6
--- /dev/null
+++ b/apps/desktop/src/renderer/styles.d.ts
@@ -0,0 +1,6 @@
+declare module '*.css'
+
+declare module '*.module.css' {
+ const classes: Readonly>
+ export default classes
+}
diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts
new file mode 100644
index 00000000000..9587ea467c2
--- /dev/null
+++ b/apps/desktop/src/shared/shell.ts
@@ -0,0 +1,23 @@
+import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge'
+
+export interface ShellDialogConfiguration {
+ title: string
+ message: string
+ detail: string
+ type: 'none' | 'info' | 'error' | 'question' | 'warning'
+ buttons: string[]
+ defaultId: number
+ cancelId: number
+ primaryVariant: 'primary' | 'destructive'
+}
+
+/** Narrow bridge for bundled dialogs and server settings, isolated from app and browser sessions. */
+export interface ShellWindowApi {
+ resizeContent(height: number): void
+ getDialogConfiguration(): Promise
+ respond(response: number): void
+ server: {
+ getConfiguration(): Promise
+ setOrigin(origin: string): Promise
+ }
+}
diff --git a/apps/desktop/src/test/dialog-mock.ts b/apps/desktop/src/test/dialog-mock.ts
new file mode 100644
index 00000000000..deeab4be576
--- /dev/null
+++ b/apps/desktop/src/test/dialog-mock.ts
@@ -0,0 +1,3 @@
+import { dialog } from 'electron'
+
+export const showShellDialog = dialog.showMessageBox
diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts
index 4c2b69eb4b2..fea9849d085 100644
--- a/apps/desktop/src/test/electron-mock.ts
+++ b/apps/desktop/src/test/electron-mock.ts
@@ -248,6 +248,8 @@ export class BrowserWindow {
BrowserWindow.lastOptions = options
}
webContents = {
+ ipc: { on: vi.fn(), handle: vi.fn() },
+ mainFrame: { url: '' },
on: vi.fn(),
getURL: vi.fn(() => ''),
loadURL: vi.fn(() => Promise.resolve()),
@@ -276,6 +278,7 @@ export class BrowserWindow {
getNormalBounds = vi.fn(() => ({ x: 0, y: 0, width: 1360, height: 860 }))
getBounds = vi.fn(() => ({ x: 1292, y: 41, width: 420, height: 150 }))
setBounds = vi.fn()
+ setContentSize = vi.fn()
loadURL = vi.fn(() => Promise.resolve())
loadFile = vi.fn(() => Promise.resolve())
focus = vi.fn()
diff --git a/apps/desktop/src/test/setup.ts b/apps/desktop/src/test/setup.ts
new file mode 100644
index 00000000000..f855b236293
--- /dev/null
+++ b/apps/desktop/src/test/setup.ts
@@ -0,0 +1,4 @@
+import { vi } from 'vitest'
+
+/** Existing behavior tests stub prompt responses at the shared dialog boundary. */
+vi.mock('@/main/dialogs', () => import('@/test/dialog-mock'))
diff --git a/apps/desktop/static/dialog.html b/apps/desktop/static/dialog.html
new file mode 100644
index 00000000000..e6c97e1173d
--- /dev/null
+++ b/apps/desktop/static/dialog.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ Sim
+
+
+
+
+
+
+
diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html
index 87313fe2cff..6d46019d6a5 100644
--- a/apps/desktop/static/offline.html
+++ b/apps/desktop/static/offline.html
@@ -2,257 +2,16 @@
+
Sim - Can’t connect
-
+
+
-
-
-
-
Can’t connect to Sim
-
- Sim couldn’t reach the server. Check your internet connection, then try again.
-
-
- Retry
- Check status
- Change server
-
-
-
-
-
+
diff --git a/apps/desktop/static/server.html b/apps/desktop/static/server.html
index 44c50e546c2..e096b082283 100644
--- a/apps/desktop/static/server.html
+++ b/apps/desktop/static/server.html
@@ -2,272 +2,16 @@
+
Sim - Server
-
+
+
-
-
- Sim server
-
- Point this app at your own Sim deployment. Self-hosted servers must use HTTPS; localhost may
- use HTTP.
-
- Server URL
-
-
-
- Cancel
- Connect
-
-
-
+
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
index 77d5b0963ea..3f6edb1844f 100644
--- a/apps/desktop/tsconfig.json
+++ b/apps/desktop/tsconfig.json
@@ -1,10 +1,11 @@
{
"extends": "@sim/tsconfig/base.json",
"compilerOptions": {
- "lib": ["ES2022"],
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "jsx": "react-jsx"
},
"include": ["src/**/*", "scripts/**/*", "e2e/**/*", "playwright.config.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist", "release"]
diff --git a/apps/desktop/turbo.json b/apps/desktop/turbo.json
new file mode 100644
index 00000000000..975eab257e2
--- /dev/null
+++ b/apps/desktop/turbo.json
@@ -0,0 +1,13 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ "$TURBO_ROOT$/apps/sim/app/_styles/**",
+ "$TURBO_ROOT$/apps/sim/postcss.config.mjs",
+ "$TURBO_ROOT$/apps/sim/lib/postcss/**"
+ ]
+ }
+ }
+}
diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts
index 9718634dfc7..274f7646a01 100644
--- a/apps/desktop/vitest.config.ts
+++ b/apps/desktop/vitest.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
test: {
environment: 'node',
globals: true,
+ setupFiles: ['src/test/setup.ts'],
include: ['src/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
pool: 'threads',
diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx
index 3818f2eeae9..6e06358de77 100644
--- a/apps/sim/app/oauth-error/page.tsx
+++ b/apps/sim/app/oauth-error/page.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
-import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
+import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
export const metadata: Metadata = {
title: 'Sign-in couldn’t be completed',
@@ -55,15 +55,9 @@ export default async function OAuthErrorPage({ searchParams }: OAuthErrorPagePro
const code = typeof params.error === 'string' ? params.error : undefined
return (
-
-
-
-
Couldn’t complete that
-
{messageForError(code)}
-
- You can close this tab and try again from Sim.
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx
index b6853088828..199aa2b8be9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import type { BrowserPageIssue } from '@sim/browser-protocol'
-import { Button } from '@sim/emcn'
+import { Chip } from '@sim/emcn'
import { CircleAlert, Globe, RefreshCw } from '@sim/emcn/icons'
interface BrowserPageIssueProps {
@@ -161,10 +161,11 @@ export function BrowserPageIssueView({ issue, onReload, focusRecovery }: Browser
))}
{copy.code}
-
-
- Reload
-
+
+
+ Reload
+
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
index 050f3b76228..b1c53816c34 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { Banner, Button } from '@sim/emcn'
+import { Banner, Chip } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { sendBrowserPanelAction } from '@/lib/browser-agent/transport'
@@ -22,25 +22,8 @@ export function BrowserThemeNotice({ scopeId }: BrowserThemeNoticeProps) {
Some sites apply theme changes after a reload.
- sendBrowserPanelAction('reload', {}, scopeId)}
- >
- Reload page
-
- setVisible(false)}
- >
-
-
+ sendBrowserPanelAction('reload', {}, scopeId)}>Reload page
+ setVisible(false)} />
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
index 3b058443c8b..3ce52bacb91 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
@@ -20,7 +20,7 @@ import { type IBufferRange, Terminal } from '@xterm/xterm'
import { useTheme } from 'next-themes'
import { useContextMenu } from '@/hooks/use-context-menu'
import '@xterm/xterm/css/xterm.css'
-import { describeRunningCommand, type TerminalTabsState } from '@sim/terminal-protocol'
+import type { TerminalTabsState } from '@sim/terminal-protocol'
import { getDesktopBridge } from '@/lib/desktop'
import {
loadDesktopTerminalAppearance,
@@ -47,6 +47,7 @@ import {
writeToTerminal,
} from '@/lib/terminal/transport'
import { TerminalContextMenu } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu'
+import { useTerminalCloseConfirmation } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation'
import { useDesktopPreferenceMutation } from '@/hooks/use-desktop-preference-mutation'
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
import type { ChatContext, TerminalTextSelection } from '@/stores/panel'
@@ -234,6 +235,7 @@ const TerminalView = memo(function TerminalView({
const hostRef = useRef(null)
const terminalRef = useRef(null)
const fitRef = useRef(null)
+ const { confirmTerminalClose, confirmationDialog } = useTerminalCloseConfirmation(scopeId)
const [currentZoom, setCurrentZoom] = useState(defaultZoom)
// Being the selected tab is not enough to be on screen: the whole panel is
// hidden whenever another resource is open.
@@ -590,20 +592,12 @@ const TerminalView = memo(function TerminalView({
})
}, [scopeId])
- // Scoped to the terminal that was right-clicked, not the active one.
- const closeThisTerminal = useCallback(() => {
- if (
- running &&
- !window.confirm(
- `${describeRunningCommand(running)} is still running. Close this terminal and stop it?`
- )
- ) {
- return
- }
+ async function closeThisTerminal() {
+ if (!(await confirmTerminalClose([terminalId]))) return
void closeTerminal(terminalId, scopeId).catch(() => {
toast.error('Could not close that terminal. Please try again.')
})
- }, [running, terminalId, scopeId])
+ }
// An inactive tab is `display: none`, not merely invisible. xterm watches its
// element with an IntersectionObserver and pauses rendering once it stops
@@ -613,6 +607,7 @@ const TerminalView = memo(function TerminalView({
// xterm re-measures and does a full refresh when the element comes back.
return (
<>
+ {confirmationDialog}
void)[] = []
+
+function renderHook(useHook: () => ReturnType
) {
+ let value: ReturnType | undefined
+ function Harness() {
+ value = useHook()
+ return null
+ }
+ const container = document.createElement('div')
+ const root = createRoot(container)
+ let mounted = true
+ const unmount = () => {
+ if (!mounted) return
+ mounted = false
+ act(() => root.unmount())
+ }
+ cleanups.push(unmount)
+ act(() => root.render( ))
+ return {
+ result: {
+ get current() {
+ if (!value) throw new Error('Hook was not rendered')
+ return value
+ },
+ },
+ rerender: () => act(() => root.render( )),
+ unmount,
+ }
+}
+
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0)) cleanup()
+})
+
+const { getState } = vi.hoisted(() => ({ getState: vi.fn() }))
+vi.mock('@/stores/copilot-terminal/store', () => ({ useCopilotTerminalStore: { getState } }))
+vi.mock('@sim/emcn', () => ({ ChipConfirmModal: vi.fn(), toast: { warning: vi.fn() } }))
+
+function setRunning(running: string | null) {
+ getState.mockReturnValue({
+ sessions: { scope: { tabs: { tabs: [{ terminalId: 'terminal', running }] } } },
+ })
+}
+
+beforeEach(() => {
+ setRunning('sleep 1')
+})
+
+describe('useTerminalCloseConfirmation', () => {
+ it('waits for approval and rejects duplicate confirmation requests', async () => {
+ const { result } = renderHook(() => useTerminalCloseConfirmation('scope'))
+ let decision: Promise | undefined
+ act(() => {
+ decision = result.current.confirmTerminalClose(['terminal'])
+ })
+ await expect(result.current.confirmTerminalClose(['terminal'])).resolves.toBe(false)
+ act(() => {
+ result.current.confirmationDialog?.props.confirm.onClick()
+ })
+ await expect(decision).resolves.toBe(true)
+ expect(result.current.confirmationDialog).toBeNull()
+ })
+
+ it('refuses to close when the running command changed while the dialog was open', async () => {
+ const { result } = renderHook(() => useTerminalCloseConfirmation('scope'))
+ let decision: Promise | undefined
+ act(() => {
+ decision = result.current.confirmTerminalClose(['terminal'])
+ })
+ setRunning('build')
+ act(() => {
+ result.current.confirmationDialog?.props.confirm.onClick()
+ })
+ await expect(decision).resolves.toBe(false)
+ })
+
+ it('cancels pending confirmation when the caller unmounts', async () => {
+ const { result, unmount } = renderHook(() => useTerminalCloseConfirmation('scope'))
+ let decision: Promise | undefined
+ act(() => {
+ decision = result.current.confirmTerminalClose(['terminal'])
+ })
+ unmount()
+ await expect(decision).resolves.toBe(false)
+ })
+
+ it('does not resurrect a cancelled dialog when returning to its scope', async () => {
+ let scopeId = 'scope'
+ const { result, rerender } = renderHook(() => useTerminalCloseConfirmation(scopeId))
+ let decision: Promise | undefined
+ act(() => {
+ decision = result.current.confirmTerminalClose(['terminal'])
+ })
+ scopeId = 'another-scope'
+ rerender()
+ await expect(decision).resolves.toBe(false)
+ expect(result.current.confirmationDialog).toBeNull()
+ scopeId = 'scope'
+ rerender()
+ expect(result.current.confirmationDialog).toBeNull()
+ act(() => {
+ decision = result.current.confirmTerminalClose(['terminal'])
+ })
+ act(() => {
+ result.current.confirmationDialog?.props.confirm.onClick()
+ })
+ await expect(decision).resolves.toBe(true)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
new file mode 100644
index 00000000000..de33c7ce076
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ChipConfirmModal, toast } from '@sim/emcn'
+import { TerminalWindow } from '@sim/emcn/icons'
+import { describeRunningCommand } from '@sim/terminal-protocol'
+import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
+
+interface TerminalCloseRequest {
+ scopeId: string
+ targets: { terminalId: string; running: string | null }[]
+ resolve: (confirmed: boolean) => void
+}
+
+/** Confirms the captured terminals and rechecks their commands before closing them. */
+export function useTerminalCloseConfirmation(scopeId: string) {
+ const pendingRef = useRef(null)
+ const [request, setRequest] = useState(null)
+
+ if (request && request.scopeId !== scopeId) setRequest(null)
+
+ useEffect(() => {
+ return () => {
+ pendingRef.current?.resolve(false)
+ pendingRef.current = null
+ }
+ }, [scopeId])
+
+ const confirmTerminalClose = useCallback(
+ (terminalIds: string[]): Promise => {
+ if (pendingRef.current) return Promise.resolve(false)
+ const tabs = useCopilotTerminalStore.getState().sessions[scopeId]?.tabs.tabs ?? []
+ const targets = tabs
+ .filter((tab) => terminalIds.includes(tab.terminalId))
+ .map(({ terminalId, running }) => ({ terminalId, running }))
+ if (!targets.some((target) => target.running)) return Promise.resolve(true)
+ return new Promise((resolve) => {
+ const next = { scopeId, targets, resolve }
+ pendingRef.current = next
+ setRequest(next)
+ })
+ },
+ [scopeId]
+ )
+
+ function settle(confirmed: boolean) {
+ const pending = pendingRef.current
+ if (!pending) return
+ if (confirmed) {
+ const tabs = useCopilotTerminalStore.getState().sessions[pending.scopeId]?.tabs.tabs ?? []
+ confirmed = pending.targets.every((target) => {
+ const current = tabs.find((tab) => tab.terminalId === target.terminalId)
+ return current && (!current.running || current.running === target.running)
+ })
+ if (!confirmed) toast.warning('A terminal changed. Review it before closing.')
+ }
+ pendingRef.current = null
+ setRequest(null)
+ pending.resolve(confirmed)
+ }
+
+ const running =
+ request?.targets.flatMap((target) => (target.running ? [target.running] : [])) ?? []
+ const confirmationDialog =
+ request?.scopeId === scopeId ? (
+ {
+ if (!open) settle(false)
+ }}
+ title={request.targets.length === 1 ? 'Close terminal?' : 'Close terminals?'}
+ icon={TerminalWindow}
+ text={
+ running.length === 1
+ ? `${describeRunningCommand(running[0])} is still running. Closing the terminal will stop it.`
+ : `${running.length} selected terminals have a running process. Closing these terminals will stop them.`
+ }
+ confirm={{
+ label: request.targets.length === 1 ? 'Close terminal' : 'Close terminals',
+ variant: 'destructive',
+ onClick: () => settle(true),
+ }}
+ />
+ ) : null
+
+ return { confirmTerminalClose, confirmationDialog }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index 9f1d18d94ad..1e17cf742fb 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -21,7 +21,7 @@ import {
toast,
} from '@sim/emcn'
import { Columns3, Eye, Pencil } from '@sim/emcn/icons'
-import { describeRunningCommand, type TerminalTabState } from '@sim/terminal-protocol'
+import type { TerminalTabState } from '@sim/terminal-protocol'
import { browserTabTitle } from '@/lib/browser-agent/tab-label'
import {
openBrowserTab,
@@ -37,6 +37,7 @@ import { closeTerminal, openTerminal, reorderTerminal } from '@/lib/terminal/tra
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
+import { useTerminalCloseConfirmation } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_HEADER_CLASSES,
@@ -77,24 +78,6 @@ const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = [
const EMPTY_TERMINAL_TABS: TerminalTabState[] = []
-/** Closing a shell mid-command stops that command, so the user confirms first. */
-function confirmClosingRunningTerminals(
- targets: readonly MothershipResource[],
- terminalTabs: readonly TerminalTabState[]
-): boolean {
- const running = targets.flatMap((resource) => {
- if (resource.type !== 'terminal') return []
- const tab = terminalTabs.find((entry) => terminalResourceId(entry.terminalId) === resource.id)
- return tab?.running ? [tab.running] : []
- })
- if (running.length === 0) return true
- return window.confirm(
- running.length === 1
- ? `${describeRunningCommand(running[0])} is still running. Close this terminal and stop it?`
- : `${running.length} selected terminals have a running process. Close them anyway?`
- )
-}
-
/**
* Returns the id of the nearest resource to `idx` that is in `filter`
* (or any resource if `filter` is null). Returns undefined if nothing qualifies.
@@ -242,6 +225,7 @@ export function ResourceTabs({
const removeResource = useRemoveChatResource(chatId)
const reorderResources = useReorderChatResources(chatId)
+ const { confirmTerminalClose, confirmationDialog } = useTerminalCloseConfirmation(desktopScopeId)
const [selectedIds, setSelectedIds] = useState>(new Set())
const anchorIdRef = useRef(null)
const prevChatIdRef = useRef(chatId)
@@ -407,13 +391,16 @@ export function ResourceTabs({
)
const handleClose = useCallback(
- (id: string) => {
+ async (id: string) => {
const index = resources.findIndex((r) => r.id === id)
const resource = resources[index]
if (!resource) return
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
- if (!confirmClosingRunningTerminals(targets, terminalTabs)) return
+ const terminalIds = targets
+ .filter((target) => target.type === 'terminal')
+ .map((target) => terminalIdFromResourceId(target.id))
+ if (!(await confirmTerminalClose(terminalIds))) return
// Closing the shown tab moves to its neighbour, right then left, so the
// strip does not fall back to its last tab and jump. For a desktop tab
// this is also the neighbour the desktop app itself picks.
@@ -469,7 +456,7 @@ export function ResourceTabs({
resources,
selectResource,
selectedIds,
- terminalTabs,
+ confirmTerminalClose,
]
)
@@ -574,38 +561,44 @@ export function ResourceTabs({
) : null
return (
-
-
-
- }
- // A bare fragment is always truthy, so the empty case has to be `null` or
- // the strip renders an empty trailing cluster.
- endActions={
- actions || previewToggle ? (
- <>
- {actions}
- {previewToggle}
- >
- ) : null
- }
- />
+ <>
+ {confirmationDialog}
+
+
+
+ }
+ // A bare fragment is always truthy, so the empty case has to be `null` or
+ // the strip renders an empty trailing cluster.
+ endActions={
+ actions || previewToggle ? (
+ <>
+ {actions}
+ {previewToggle}
+ >
+ ) : null
+ }
+ />
+ >
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
index 6a29b6c5a26..58cc12614db 100644
--- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
@@ -1,8 +1,7 @@
'use client'
-import { Button, buttonVariants } from '@sim/emcn'
+import { Chip, ChipLink } from '@sim/emcn'
import { ArrowLeft, Compass, Home } from '@sim/emcn/icons'
-import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { ErrorShell } from '@/app/workspace/[workspaceId]/components'
@@ -17,14 +16,12 @@ export default function WorkspaceNotFound() {
description="The page you're looking for doesn't exist or has been moved. Head back to your workspace to keep building."
icon={ }
>
- router.back()}>
-
+ router.back()}>
Go back
-
-
-
+
+
Return home
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
index d17ae78e43d..78d2ed68ff8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Button } from '@sim/emcn'
+import { Chip } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { useParams, useRouter } from 'next/navigation'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
@@ -17,14 +17,9 @@ export default function TableError({ error, reset }: ErrorBoundaryProps) {
description='Something went wrong while loading this table. The table may have been deleted or you may not have permission to view it.'
loggerName='TableError'
>
- router.push(`/workspace/${workspaceId}/tables`)}
- >
-
+ router.push(`/workspace/${workspaceId}/tables`)}>
Go back
-
+
)
}
diff --git a/bun.lock b/bun.lock
index 52861a831d2..d3a4a9f48fd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -61,13 +61,20 @@
"devDependencies": {
"@electron/fuses": "1.8.0",
"@playwright/test": "1.61.1",
+ "@sim/emcn": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
"electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"jsdom": "^26.0.0",
+ "postcss": "^8",
+ "postcss-load-config": "6.0.1",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
"typescript": "^7.0.2",
"vitest": "^4.1.0",
},
diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx
index 87f1b872abf..8ad12f7c3dc 100644
--- a/packages/emcn/src/components/chip-modal/chip-modal.tsx
+++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx
@@ -219,6 +219,35 @@ export interface ChipModalProps {
children?: React.ReactNode
}
+/**
+ * Shared modal chrome and Enter-key policy. Native windows can host this
+ * surface directly when the operating system owns the dialog lifecycle.
+ * Web dialogs use it through {@link ChipModal}.
+ */
+export const ChipModalSurface = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, children, onKeyDown, ...props }, ref) => (
+ {
+ onKeyDown?.(event)
+ handleChipModalEnter(event)
+ }}
+ {...props}
+ >
+
+ {children}
+
+
+))
+
+ChipModalSurface.displayName = 'ChipModalSurface'
+
/**
* Root component. Wraps the Radix dialog and renders the panel chrome.
* Subcomponents (`ChipModalHeader`, `ChipModalBody`, `ChipModalField`,
@@ -245,19 +274,9 @@ function ChipModal({
size={size}
dismissDisabled={dismissDisabled}
onOpenAutoFocus={focusChipModalDefaultAction}
- onKeyDown={handleChipModalEnter}
aria-describedby={ariaDescribedBy}
>
-
+ {children}
)
@@ -409,6 +428,7 @@ const ChipModalBody = React.forwardRef(
({ className, fullBleed = false, ...props }, ref) => (
- Cancel
+ {cancelLabel}
)}
{primaryAdjacentAction ? renderFooterSlotAction(primaryAdjacentAction) : null}
@@ -1577,8 +1596,7 @@ export interface ChipConfirmModalProps {
defaultAction?: ChipConfirmDefaultAction
/**
* Label for the dismiss button. In a confirmation the dismiss button is a
- * named decision, so this is honest API (unlike a form footer's structural
- * Cancel). Defaults to `'Cancel'`; pass `'Keep editing'` for unsaved-changes.
+ * named decision. Defaults to `'Cancel'`; pass `'Keep editing'` for unsaved-changes.
* @default 'Cancel'
*/
dismissLabel?: string
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 20e11b11f4c..ecfcb816719 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -89,6 +89,7 @@ export {
type ChipModalPromptBodyProps,
type ChipModalProps,
ChipModalSeparator,
+ ChipModalSurface,
type ChipModalTab,
ChipModalTabs,
type ChipModalTabsProps,
From 948b619fbe4c3a5fc41e5b0a70330679a65c72e1 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Mon, 14 Sep 2026 16:13:23 -0700
Subject: [PATCH 2/3] fix(desktop): match title-only modal headers
---
apps/desktop/src/main/dialogs.ts | 1 -
apps/desktop/src/renderer/dialog/index.tsx | 4 +---
apps/desktop/src/renderer/server/server-modal.tsx | 2 --
apps/desktop/src/shared/shell.ts | 1 -
.../terminal-session/use-terminal-close-confirmation.tsx | 2 --
5 files changed, 1 insertion(+), 9 deletions(-)
diff --git a/apps/desktop/src/main/dialogs.ts b/apps/desktop/src/main/dialogs.ts
index 4e8180c2661..23f96c2beac 100644
--- a/apps/desktop/src/main/dialogs.ts
+++ b/apps/desktop/src/main/dialogs.ts
@@ -42,7 +42,6 @@ export function showShellDialog(
title: options.title ?? 'Sim',
message: options.message,
detail: options.detail ?? '',
- type: options.type ?? 'none',
buttons,
defaultId: options.defaultId ?? 0,
cancelId,
diff --git a/apps/desktop/src/renderer/dialog/index.tsx b/apps/desktop/src/renderer/dialog/index.tsx
index ad13bf53a9f..ee8e252e75c 100644
--- a/apps/desktop/src/renderer/dialog/index.tsx
+++ b/apps/desktop/src/renderer/dialog/index.tsx
@@ -1,5 +1,4 @@
import { ChipModalBody, ChipModalFooter, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
-import { CircleInfo, TriangleAlert } from '@sim/emcn/icons'
import { createRoot } from 'react-dom/client'
import { initializeShellPage, observeShellSize, shellWindow } from '@/renderer/shell'
import type { ShellDialogConfiguration } from '@/shared/shell'
@@ -10,7 +9,7 @@ interface ShellDialogProps {
}
function ShellDialog({ configuration }: ShellDialogProps) {
- const { message, detail, buttons, defaultId, cancelId, type } = configuration
+ const { message, detail, buttons, defaultId, cancelId } = configuration
const primaryId = buttons.length === 1 ? 0 : buttons.findIndex((_, index) => index !== cancelId)
const respond = (response: number) => shellWindow?.respond(response)
const close = () => respond(cancelId)
@@ -28,7 +27,6 @@ function ShellDialog({ configuration }: ShellDialogProps) {
className='max-h-screen'
>
diff --git a/apps/desktop/src/renderer/server/server-modal.tsx b/apps/desktop/src/renderer/server/server-modal.tsx
index f240b739794..6eac19d7d76 100644
--- a/apps/desktop/src/renderer/server/server-modal.tsx
+++ b/apps/desktop/src/renderer/server/server-modal.tsx
@@ -7,7 +7,6 @@ import {
ChipModalHeader,
ChipModalSurface,
} from '@sim/emcn'
-import { Server } from '@sim/emcn/icons'
import { observeShellSize } from '@/renderer/shell'
import type { ShellWindowApi } from '@/shared/shell'
@@ -70,7 +69,6 @@ export function ServerModal({ server, configuration, initialError }: ServerModal
className='max-h-screen [&_input]:select-text'
>
diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts
index 9587ea467c2..b71858f5fb9 100644
--- a/apps/desktop/src/shared/shell.ts
+++ b/apps/desktop/src/shared/shell.ts
@@ -4,7 +4,6 @@ export interface ShellDialogConfiguration {
title: string
message: string
detail: string
- type: 'none' | 'info' | 'error' | 'question' | 'warning'
buttons: string[]
defaultId: number
cancelId: number
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
index de33c7ce076..5b6f8c891ca 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChipConfirmModal, toast } from '@sim/emcn'
-import { TerminalWindow } from '@sim/emcn/icons'
import { describeRunningCommand } from '@sim/terminal-protocol'
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
@@ -67,7 +66,6 @@ export function useTerminalCloseConfirmation(scopeId: string) {
if (!open) settle(false)
}}
title={request.targets.length === 1 ? 'Close terminal?' : 'Close terminals?'}
- icon={TerminalWindow}
text={
running.length === 1
? `${describeRunningCommand(running[0])} is still running. Closing the terminal will stop it.`
From 2ce64abbb188ef297d339f99280b493813ab2523 Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Mon, 14 Sep 2026 16:22:28 -0700
Subject: [PATCH 3/3] fix(desktop): track terminal confirmation scope
transitions
---
.../terminal-session/use-terminal-close-confirmation.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
index 5b6f8c891ca..aee7686ceb3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation.tsx
@@ -13,8 +13,12 @@ interface TerminalCloseRequest {
export function useTerminalCloseConfirmation(scopeId: string) {
const pendingRef = useRef(null)
const [request, setRequest] = useState(null)
+ const [previousScopeId, setPreviousScopeId] = useState(scopeId)
- if (request && request.scopeId !== scopeId) setRequest(null)
+ if (previousScopeId !== scopeId) {
+ setPreviousScopeId(scopeId)
+ setRequest(null)
+ }
useEffect(() => {
return () => {