diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md
index 850571a9cef..865681fa09b 100644
--- a/.agents/skills/add-integration/SKILL.md
+++ b/.agents/skills/add-integration/SKILL.md
@@ -123,7 +123,7 @@ Follow `.agents/skills/add-block/SKILL.md` for the block structure, subBlock typ
`canvasPresentation`; `bun run apps/sim/scripts/check-canvas-sentences.ts --block={service}` must
pass (CI runs `check:canvas-sentences --require-coverage`).
-Two rules that are easy to get wrong when copying from existing blocks:
+Three rules that are easy to get wrong when copying from existing blocks:
- Every remote `selectorKey` must use the unified server selector path. Apply the `add-selector` skill:
add browser-safe metadata to `apps/sim/lib/selectors/manifest.ts`, reuse or extract a server-only
@@ -135,6 +135,12 @@ Two rules that are easy to get wrong when copying from existing blocks:
(e.g. `channelSelector` + `channelId` → `canonicalParamId: 'channel'`). It is the only key that
survives serialization, so `inputs` and `tools.config.params` reference the canonical id, never the
subblock ids. It is unique block-wide, and every member of a group shares the same `required` value.
+- Every text-entry subBlock (`short-input`, `long-input`, `code`) and every selector declares a
+ `placeholder`; an empty box tells the user nothing. Secrets read `Enter your {thing}` (e.g.
+ `Enter your API key`), free text names what to type (`Enter branch name`), and formatted values
+ show the shape (`2023-01-01T00:00:00Z`, `1 to 1000`). An optional field with a server-side default
+ names that default (`Defaults to the database region`). Dropdowns, switches, and `oauth-input` do
+ not need one.
## Step 4: Add Icon
@@ -309,6 +315,28 @@ bun run docs:check
This creates `apps/docs/content/docs/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`).
+Every generated integration page carries a hand-written intro directly under ``. The
+generator preserves it across regenerations, so write it once after the first generate:
+
+```mdx
+{/* MANUAL-CONTENT-START:intro */}
+[{Service}](https://service.com/) is {one sentence on what the service is}.
+
+With the {Service} block, you can:
+
+- **{Capability}**: {what the operations in this group do}
+- **{Capability}**: {...}
+
+{How to connect: which credential to create and where, if it is not OAuth.}
+
+In Sim, the {Service} block lets your agents {concrete workflow uses}.
+{/* MANUAL-CONTENT-END */}
+```
+
+Group the bullets by what the user gets done, not one bullet per tool. Only describe operations the
+block actually ships. Follow `.claude/rules/constitution.md` for voice. Re-run
+`bun run scripts/generate-docs.ts` afterwards and confirm the section survived unchanged.
+
The docs generator refreshes `packages/deployment-config/src/integrations.json`, and the deployment
config generator projects service-account provider IDs from that catalog plus the canonical OAuth
registry. The checks compare both committed projections with their sources. Review the generated
@@ -368,6 +396,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] Defined operation dropdown with all operations
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
- [ ] Added conditional fields per operation
+- [ ] Every `short-input`, `long-input`, `code`, and selector subBlock has a `placeholder`
- [ ] Set up dependsOn for cascading selectors
- [ ] Every remote `selectorKey` exists in the shared manifest and has one server attachment with
trusted credential provider binding and a fixed, credential-bound, or explicitly reviewed
@@ -415,6 +444,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Ran `bun run deployment-config:generate` for OAuth or service-account changes
- [ ] Verified docs file created
+- [ ] Wrote the `{/* MANUAL-CONTENT-START:intro */}` section under `` and confirmed it survives a regenerate
- [ ] Reviewed and committed the generated `packages/deployment-config/src/integrations.json` change
- [ ] `bun run integration-catalog:check` passes
- [ ] `bun run docs:check` passes — CI fails on stale generated docs, so commit the full generator
diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md
index a32df2b8bb3..1bcde985b53 100644
--- a/.agents/skills/validate-integration/SKILL.md
+++ b/.agents/skills/validate-integration/SKILL.md
@@ -218,6 +218,9 @@ For **each tool** in `tools.access`:
- True/false → `switch` (a Yes/No `dropdown` only when the tool needs a third "unset" state)
- Credentials → `oauth-input` with correct `serviceId`
- [ ] Dropdown `value: () => 'default'` is set for dropdowns with a sensible default
+- [ ] Every `short-input`, `long-input`, `code`, and selector subBlock has a `placeholder` — including
+ password fields (`Enter your API key`). Formatted values show the shape
+ (`2023-01-01T00:00:00Z`); optional fields with a server default name it. See add-integration → Step 3
### Advanced Mode
- [ ] Optional, rarely-used fields are set to `mode: 'advanced'`:
@@ -450,6 +453,10 @@ upstream PR that skipped regeneration), and investigate anything that looks like
page losing a section usually means its source block moved or a generator input broke, not that the
hunk should be reverted.
+The integration's page must carry a `{/* MANUAL-CONTENT-START:intro */}` section directly under
+``. If it is missing, write one using the template in add-integration → Step 8, and
+check an existing intro against what the block actually ships (no removed or unshipped operations).
+
If an icon changed, `apps/sim/components/icons.tsx` is the source of truth and `apps/docs/components/icons.tsx` is its generated mirror — they must end up byte-identical for that component.
### Validation Output
@@ -473,6 +480,7 @@ After fixing, confirm:
- [ ] Validated every tool's ID, params, request, response, outputs, and types against API docs
- [ ] Validated block ↔ tool alignment (every tool param has a subBlock, every condition is correct)
- [ ] Validated advanced mode on optional/rarely-used fields
+- [ ] Validated every text-entry and selector subBlock has a `placeholder`
- [ ] Validated wandConfig on timestamps and complex inputs
- [ ] Validated tools.config mapping, tool selector, and type coercions
- [ ] Validated block outputs match what tools return, with typed JSON where possible
@@ -496,6 +504,7 @@ After fixing, confirm:
- [ ] Fixed all critical and warning issues
- [ ] Ran `bun run tool-metadata:generate` if any tool outputs/params changed, and confirmed `bun run tool-metadata:check` passes
- [ ] Ran `bun run scripts/generate-docs.ts` if any block metadata changed, and committed the full generated diff — including stale-page catch-up for other integrations (`bun run docs:check` fails CI on reverted generator output)
+- [ ] Validated the docs page has an accurate `MANUAL-CONTENT-START:intro` section
- [ ] Ran `bun run lint` after fixes
- [ ] Verified TypeScript compiles clean
- [ ] Verified added tests fail without their fix
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
index 0e8d3c8d697..d8c2ddafde0 100644
--- a/.github/workflows/desktop-e2e.yml
+++ b/.github/workflows/desktop-e2e.yml
@@ -10,6 +10,10 @@ on:
- '.github/workflows/desktop-e2e.yml'
- '.github/workflows/desktop-release.yml'
- 'apps/desktop/**'
+ - 'apps/sim/app/_shell/desktop-update-*.tsx'
+ - 'apps/sim/app/layout.tsx'
+ - 'apps/sim/hooks/use-desktop-update-state.ts'
+ - 'apps/sim/lib/desktop/**'
- 'apps/sim/app/workspace/**/browser-session/**'
- 'apps/sim/app/_styles/**'
- 'apps/sim/lib/postcss/**'
diff --git a/apps/desktop/e2e/browser-focus.spec.ts b/apps/desktop/e2e/browser-focus.spec.ts
new file mode 100644
index 00000000000..1f740a6b7fe
--- /dev/null
+++ b/apps/desktop/e2e/browser-focus.spec.ts
@@ -0,0 +1,319 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer, type Server } from 'node:http'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import {
+ type ElectronApplication,
+ _electron as electron,
+ expect,
+ type Page,
+ test,
+} from '@playwright/test'
+import type { BrowserToolName } from '@sim/browser-protocol'
+import type { SimDesktopApi } from '@sim/desktop-bridge'
+import { getErrorMessage } from '@sim/utils/errors'
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+const SCOPE = 'browser-focus-e2e'
+const PRIMARY = process.platform === 'darwin' ? 'meta' : 'control'
+const SHELL_FIXTURE =
+ '
Sim fixture
Browser focus fixture
'
+const page = (name: string) =>
+ `${name}Open popup`
+
+type Bridge = typeof globalThis & { simDesktop: SimDesktopApi; shellMarker?: string }
+
+/**
+ * The browser shares one window with Sim, so focus and shortcuts decide whether
+ * a keystroke lands in chat, in the page, or reloads all of Sim. These checks
+ * drive the real shell and native tab views.
+ */
+test('browser focus and shortcuts stay with the surface the user is using', async () => {
+ const reportPath =
+ process.env.DESKTOP_BROWSER_FOCUS_REPORT_PATH ?? test.info().outputPath('browser-focus.json')
+ const checks: {
+ name: string
+ status: 'passed' | 'failed'
+ durationMs: number
+ error?: string
+ }[] = []
+ const check = async (name: string, run: () => Promise) => {
+ const started = Date.now()
+ try {
+ await test.step(name, run)
+ checks.push({ name, status: 'passed', durationMs: Date.now() - started })
+ } catch (error) {
+ checks.push({
+ name,
+ status: 'failed',
+ durationMs: Date.now() - started,
+ error: getErrorMessage(error),
+ })
+ throw error
+ }
+ }
+ const calls = new Map()
+ const server: Server = createServer(async (request, response) => {
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
+ if (path === '/api/desktop/tool/authorize') {
+ let body = ''
+ for await (const chunk of request) body += chunk.toString()
+ const authorization = calls.get(JSON.parse(body).toolCallId)
+ response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify(authorization ?? {}))
+ return
+ }
+ // The app origin serves the shell; the same server on localhost is the web.
+ const isSite = request.headers.host?.startsWith('localhost') === true
+ response.writeHead(200, {
+ 'Content-Type': 'text/html',
+ ...(isSite ? {} : { 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/' }),
+ })
+ response.end(isSite ? page(path.slice(1)) : SHELL_FIXTURE)
+ })
+ const userData = mkdtempSync(join(tmpdir(), 'sim-browser-focus-e2e-'))
+ let app: ElectronApplication | undefined
+ let passed = false
+ try {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Missing fixture address')
+ const origin = `http://127.0.0.1:${address.port}`
+ /** Pages outside the app origin browse in the agent partition, like any third-party site. */
+ const site = origin.replace('127.0.0.1', 'localhost')
+ const shellApp = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
+ })
+ app = shellApp
+ const shell: Page = await shellApp.firstWindow()
+ await shellApp.evaluate(({ app, BrowserWindow }) => {
+ const host = BrowserWindow.getAllWindows()[0]
+ host.webContents.setBackgroundThrottling(false)
+ app.focus({ steal: true })
+ host.focus()
+ })
+ await expect(shell.getByRole('heading')).toHaveText('Browser focus fixture')
+ await shell.evaluate(async (scope) => {
+ const api = (globalThis as Bridge).simDesktop.browserAgent
+ await api.activateScope(scope)
+ const updateBounds = () =>
+ api.setPanelBounds(
+ { x: 0, y: 120, width: innerWidth, height: innerHeight - 120 },
+ null,
+ scope
+ )
+ updateBounds()
+ ;(globalThis as Bridge & { boundsTimer?: number }).boundsTimer = window.setInterval(
+ updateBounds,
+ 200
+ )
+ ;(globalThis as Bridge).shellMarker = 'alive'
+ }, SCOPE)
+
+ let callCount = 0
+ const execute = async (tool: BrowserToolName, args: Record) => {
+ const callId = `browser-focus-${++callCount}`
+ calls.set(callId, { chatId: SCOPE, toolName: tool, args })
+ const result = await shell.evaluate(
+ ({ callId, tool, args, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
+ { callId, tool, args, scope: SCOPE }
+ )
+ expect(result.ok).toBe(true)
+ return result.ok ? result.result : undefined
+ }
+ const panelAction = (action: Record) =>
+ shell.evaluate(
+ ({ action, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.panelAction(
+ action as unknown as Parameters[0],
+ scope
+ ),
+ { action, scope: SCOPE }
+ )
+ const setPanelFocused = (focused: boolean) =>
+ shell.evaluate(
+ ({ focused, scope }) =>
+ (globalThis as Bridge).simDesktop.browserAgent.setPanelFocused(focused, scope),
+ { focused, scope: SCOPE }
+ )
+ const tabCount = () =>
+ shell.evaluate(
+ async (scope) =>
+ (await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs.length,
+ SCOPE
+ )
+ const shellAlive = () =>
+ shell.evaluate(() => (globalThis as Bridge).shellMarker === 'alive').catch(() => false)
+ const composerFocused = () =>
+ shell.evaluate(() => document.hasFocus() && document.activeElement?.id === 'composer')
+ const focusedPageUrls = () =>
+ shellApp.evaluate(({ BrowserWindow, WebContentsView }) =>
+ BrowserWindow.getAllWindows()[0]
+ .contentView.children.filter(
+ (view) => view instanceof WebContentsView && view.webContents.isFocused()
+ )
+ .map((view) => (view as Electron.WebContentsView).webContents.getURL())
+ )
+ const visiblePageUrl = () =>
+ shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
+ const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
+ (child) => child instanceof WebContentsView && child.getVisible()
+ ) as Electron.WebContentsView | undefined
+ return view?.webContents.getURL() ?? null
+ })
+ /** Counts main-frame loads of the visible page from now on. */
+ const countVisiblePageLoads = () =>
+ shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
+ const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
+ (child) => child instanceof WebContentsView && child.getVisible()
+ ) as Electron.WebContentsView
+ const counter = globalThis as typeof globalThis & { pageLoads?: number; counted?: boolean }
+ counter.pageLoads = 0
+ if (!counter.counted) {
+ counter.counted = true
+ view.webContents.on('did-finish-load', () => {
+ counter.pageLoads = (counter.pageLoads ?? 0) + 1
+ })
+ }
+ })
+ const pageLoads = () =>
+ shellApp.evaluate(() => (globalThis as typeof globalThis & { pageLoads?: number }).pageLoads)
+ const focusVisiblePage = () =>
+ shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
+ const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
+ (child) => child instanceof WebContentsView && child.getVisible()
+ ) as Electron.WebContentsView
+ view.webContents.focus()
+ })
+ const pressInPage = (keyCode: string, modifiers: string[]) =>
+ shellApp.evaluate(
+ ({ BrowserWindow, WebContentsView }, { keyCode, modifiers }) => {
+ const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
+ (child) => child instanceof WebContentsView && child.getVisible()
+ ) as Electron.WebContentsView
+ const input = { keyCode, modifiers } as Electron.KeyboardInputEvent
+ view.webContents.sendInputEvent({ ...input, type: 'keyDown' })
+ view.webContents.sendInputEvent({ ...input, type: 'keyUp' })
+ },
+ { keyCode, modifiers }
+ )
+ /** Clicks an application-menu item the way its accelerator would. */
+ const clickMenu = (label: string) =>
+ shellApp.evaluate(({ BrowserWindow, Menu }, label) => {
+ const win = BrowserWindow.getAllWindows()[0]
+ const find = (items: Electron.MenuItem[]): Electron.MenuItem | null => {
+ for (const item of items) {
+ if (item.label === label) return item
+ const found = item.submenu ? find(item.submenu.items) : null
+ if (found) return found
+ }
+ return null
+ }
+ const item = find(Menu.getApplicationMenu()?.items ?? [])
+ if (!item) throw new Error(`No menu item ${label}`)
+ item.click(undefined, win, win.webContents)
+ }, label)
+
+ await check('the agent working never takes the caret from the composer', async () => {
+ await shell.locator('#composer').click()
+ await shell.keyboard.type('draft')
+ for (const [tool, args] of [
+ ['browser_open_url', { url: `${site}/one` }],
+ ['browser_navigate', { url: `${site}/two` }],
+ ['browser_open_tab', { url: `${site}/three` }],
+ ] as const) {
+ await execute(tool, args)
+ await expect.poll(visiblePageUrl).not.toBeNull()
+ expect(await composerFocused()).toBe(true)
+ expect(await focusedPageUrls()).toEqual([])
+ }
+ // A link without an opener is focused by Chromium itself as it is created.
+ const snapshot = await execute('browser_snapshot', {})
+ const ref = /"Open popup" \[ref=(\d+)\]/.exec(
+ String((snapshot as { outline?: string }).outline)
+ )?.[1]
+ expect(ref, 'snapshot lists the popup link').toBeTruthy()
+ await execute('browser_click', { elementId: Number(ref) })
+ await expect.poll(tabCount).toBe(3)
+ await expect.poll(focusedPageUrls).toEqual([])
+ expect(await composerFocused()).toBe(true)
+ await shell.keyboard.type(' continues')
+ await expect(shell.locator('#composer')).toHaveValue('draft continues')
+ })
+
+ await check('an omnibox navigation hands focus to the page it loads', async () => {
+ await panelAction({ action: 'switch-tab', tabId: '1' })
+ await panelAction({ action: 'navigate', url: `${site}/four` })
+ await expect.poll(focusedPageUrls).toEqual([`${site}/four`])
+ })
+
+ await check('a popup the agent opens hands focus back to the page the user is in', async () => {
+ const before = await tabCount()
+ const snapshot = await execute('browser_snapshot', {})
+ const ref = /"Open popup" \[ref=(\d+)\]/.exec(
+ String((snapshot as { outline?: string }).outline)
+ )?.[1]
+ expect(ref, 'snapshot lists the popup link').toBeTruthy()
+ await execute('browser_click', { elementId: Number(ref) })
+ await expect.poll(tabCount).toBe(before + 1)
+ await expect.poll(focusedPageUrls).toEqual([`${site}/four`])
+ })
+
+ await check('reload keys typed in the page reload only that page', async () => {
+ for (const [keyCode, modifiers] of [
+ ['R', [PRIMARY]],
+ ['R', [PRIMARY, 'shift']],
+ ['F5', []],
+ ] as const) {
+ await countVisiblePageLoads()
+ await focusVisiblePage()
+ await pressInPage(keyCode, [...modifiers])
+ await expect.poll(pageLoads).toBe(1)
+ }
+ })
+
+ await check('the page keeps its shortcut claim after the chrome reports blur', async () => {
+ await setPanelFocused(true)
+ await focusVisiblePage()
+ await setPanelFocused(false)
+ await countVisiblePageLoads()
+ await clickMenu('Reload')
+ await expect.poll(pageLoads).toBe(1)
+ expect(await shellAlive()).toBe(true)
+ })
+
+ await check('Back and Forward move through the focused page history', async () => {
+ await panelAction({ action: 'navigate', url: `${site}/five` })
+ await expect.poll(visiblePageUrl).toBe(`${site}/five`)
+ await focusVisiblePage()
+ await clickMenu('Back')
+ await expect.poll(visiblePageUrl).toBe(`${site}/four`)
+ await focusVisiblePage()
+ await clickMenu('Forward')
+ await expect.poll(visiblePageUrl).toBe(`${site}/five`)
+ })
+
+ await check('browser shortcuts work while renderer chrome hides the page', async () => {
+ const before = await tabCount()
+ await shell.evaluate((scope) => {
+ const bridge = globalThis as Bridge & { boundsTimer?: number }
+ window.clearInterval(bridge.boundsTimer)
+ bridge.simDesktop.browserAgent.setPanelBounds(null, null, scope)
+ }, SCOPE)
+ await setPanelFocused(true)
+ await clickMenu('New Tab')
+ await expect.poll(tabCount).toBe(before + 1)
+ })
+ passed = true
+ } finally {
+ mkdirSync(dirname(reportPath), { recursive: true })
+ writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
+ await app?.close()
+ await new Promise((resolve) => server.close(() => resolve()))
+ rmSync(userData, { recursive: true, force: true })
+ }
+})
diff --git a/apps/desktop/e2e/fixtures/update-notification.tsx b/apps/desktop/e2e/fixtures/update-notification.tsx
new file mode 100644
index 00000000000..cf707a0a325
--- /dev/null
+++ b/apps/desktop/e2e/fixtures/update-notification.tsx
@@ -0,0 +1,45 @@
+import { type ComponentType, StrictMode, useState } from 'react'
+import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn'
+import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
+import { createRoot } from 'react-dom/client'
+
+interface UpdateNotificationFixtureProps {
+ Notification: ComponentType
+}
+
+function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) {
+ const [pathname, setPathname] = useState('/workspace/first')
+ return (
+
+
+
+
+
+ setPathname('/workspace/second')}>Switch workspace
+ {
+ for (let index = 0; index < 3; index++) {
+ toast({ message: `Background task ${index + 1}`, duration: 0 })
+ }
+ }}
+ >
+ Fill notification stack
+
+ toast.dismissAll()}>Clear notifications
+
+
+
+
+ )
+}
+
+/** Supplies the production notification through Sim's module aliases. */
+export function mountUpdateNotificationFixture(Notification: ComponentType) {
+ const root = document.getElementById('root')
+ if (!root) throw new Error('Missing fixture root')
+ createRoot(root).render(
+
+
+
+ )
+}
diff --git a/apps/desktop/e2e/password-autofill.spec.ts b/apps/desktop/e2e/password-autofill.spec.ts
index 949581bce3c..8a00ba9bbd3 100644
--- a/apps/desktop/e2e/password-autofill.spec.ts
+++ b/apps/desktop/e2e/password-autofill.spec.ts
@@ -311,6 +311,9 @@ test.describe('saved password autofill', () => {
await expect
.poll(() => pageScript('document.getElementById("user").value'))
.toBe('account9@example.test')
+ await expect
+ .poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
+ .toBe(false)
await clickField()
await picker()
await pickerKey('Escape')
diff --git a/apps/desktop/e2e/update-notification.spec.ts b/apps/desktop/e2e/update-notification.spec.ts
new file mode 100644
index 00000000000..b088ef4ca78
--- /dev/null
+++ b/apps/desktop/e2e/update-notification.spec.ts
@@ -0,0 +1,262 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { _electron as electron, expect, test } from '@playwright/test'
+import type { DesktopUpdateState } from '@sim/desktop-bridge'
+import { getErrorMessage } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { build } from 'esbuild'
+import postcss from 'postcss'
+import loadPostcssConfig from 'postcss-load-config'
+
+const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
+const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('./fixtures/update-notification.tsx', import.meta.url))
+
+test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => {
+ const reportPath =
+ process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json')
+ const checks: {
+ name: string
+ status: 'passed' | 'failed'
+ durationMs: number
+ error?: string
+ }[] = []
+ const check = async (name: string, run: () => Promise) => {
+ const started = Date.now()
+ try {
+ await test.step(name, run)
+ checks.push({ name, status: 'passed', durationMs: Date.now() - started })
+ } catch (error) {
+ checks.push({
+ name,
+ status: 'failed',
+ durationMs: Date.now() - started,
+ error: getErrorMessage(error),
+ })
+ throw error
+ }
+ }
+ const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-'))
+ let app: Awaited> | undefined
+ let passed = false
+ let javascript = ''
+ let stylesheet = ''
+ const server = createServer((request, response) => {
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
+ if (path === '/fixture.js' || path === '/fixture.css') {
+ response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
+ response.end(path.endsWith('.js') ? javascript : stylesheet)
+ } else if (path.startsWith('/api/')) {
+ response.setHeader('Content-Type', 'application/json')
+ response.end('{}')
+ } else {
+ response.setHeader('Content-Type', 'text/html')
+ response.end(
+ ''
+ )
+ }
+ })
+
+ try {
+ await check('load the production notification in Electron', async () => {
+ const config = await loadPostcssConfig({}, SIM_DIR)
+ const cssPath = join(SIM_DIR, 'app/_styles/globals.css')
+ const css = await postcss(config.plugins).process(
+ `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
+ { from: cssPath }
+ )
+ const bundle = await build({
+ stdin: {
+ contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)};
+import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification';
+mountUpdateNotificationFixture(DesktopUpdateNotification);`,
+ resolveDir: SIM_DIR,
+ loader: 'tsx',
+ },
+ bundle: true,
+ write: false,
+ outfile: test.info().outputPath('fixture.js'),
+ external: ['node:async_hooks'],
+ banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
+ format: 'iife',
+ platform: 'browser',
+ tsconfig: join(SIM_DIR, 'tsconfig.json'),
+ define: { 'process.env.NODE_ENV': '"development"' },
+ })
+ javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
+ stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Missing fixture address')
+ app = await electron.launch({
+ args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
+ cwd: DESKTOP_DIR,
+ env: {
+ ...process.env,
+ SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
+ SIM_DESKTOP_USER_DATA: userData,
+ },
+ })
+ })
+ if (!app) throw new Error('Electron did not launch')
+ const shell = app
+ const page = await shell.firstWindow()
+ const errors: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ await shell.evaluate(({ app, BrowserWindow }) => {
+ const window = BrowserWindow.getAllWindows()[0]
+ window.setContentSize(1000, 700)
+ window.webContents.setBackgroundThrottling(false)
+ app.focus({ steal: true })
+ window.focus()
+ })
+ const publish = (state: DesktopUpdateState) =>
+ shell.evaluate(({ BrowserWindow }, next) => {
+ BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next)
+ }, state)
+ const notice = page.getByRole('list', { name: 'Notifications' })
+ const restart = notice.getByRole('button', { name: 'Restart to update' })
+ const download = notice.getByRole('button', { name: 'Download update' })
+
+ await check(
+ 'initial snapshot survives StrictMode and remains actionable across navigation',
+ async () => {
+ await shell.evaluate(({ ipcMain }) => {
+ ipcMain.removeHandler('desktop:updates:get-state')
+ ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' }))
+ })
+ await page.reload()
+ await expect(restart).toHaveCount(1)
+ await expect(restart).toBeVisible()
+ await page.getByRole('button', { name: 'Switch workspace' }).click()
+ await expect(page.getByLabel('Current route')).toHaveText('/workspace/second')
+ await sleep(5_500)
+ await expect(restart).toBeVisible()
+ await page.screenshot({
+ path: test.info().outputPath('update-ready-dark.png'),
+ animations: 'disabled',
+ })
+ await page.evaluate(() => document.documentElement.classList.remove('dark'))
+ await page.screenshot({
+ path: test.info().outputPath('update-ready-light.png'),
+ animations: 'disabled',
+ })
+ }
+ )
+
+ await check('dismissal survives repeated events and checking the same release', async () => {
+ await notice.getByRole('button', { name: 'Dismiss notification' }).click()
+ await expect(restart).toHaveCount(0)
+ await publish({ status: 'ready', version: '2.0.0' })
+ await publish({ status: 'checking', version: '2.0.0' })
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ )
+ )
+ await publish({ status: 'ready', version: '2.0.0' })
+ await sleep(500)
+ await expect(restart).toHaveCount(0)
+ })
+
+ await check(
+ 'a new release appears without stealing focus, and stale actions are withdrawn',
+ async () => {
+ const input = page.getByRole('textbox', { name: 'Work in progress' })
+ await input.fill('Unsaved work')
+ await publish({ status: 'ready', version: '2.1.0' })
+ await expect(restart).toBeVisible()
+ await expect(input).toBeFocused()
+ for (const status of ['checking', 'downloading', 'error', 'idle'] as const) {
+ await publish({ status, version: '2.1.0' })
+ await expect(restart).toHaveCount(0)
+ await publish({ status: 'ready', version: '2.1.0' })
+ await expect(restart).toHaveCount(1)
+ }
+ await expect(input).toHaveValue('Unsaved work')
+ }
+ )
+
+ await check(
+ 'a dismissed download offer can announce that the same release is ready',
+ async () => {
+ await publish({ status: 'available', version: '2.1.0' })
+ await expect(download).toBeVisible()
+ await expect(restart).toHaveCount(0)
+ await notice.getByRole('button', { name: 'Dismiss notification' }).click()
+ await expect(download).toHaveCount(0)
+ await publish({ status: 'ready', version: '2.1.0' })
+ await expect(restart).toBeVisible()
+ }
+ )
+
+ await check('stack eviction does not count as dismissing the update offer', async () => {
+ await publish({ status: 'checking', version: '2.4.0' })
+ await expect(restart).toHaveCount(0)
+ await publish({ status: 'ready', version: '2.4.0' })
+ await expect(restart).toHaveCount(1)
+ await page.getByRole('button', { name: 'Fill notification stack' }).click()
+ await expect(restart).toHaveCount(0)
+ await page.getByRole('button', { name: 'Clear notifications' }).click()
+ await expect(notice).toHaveCount(0)
+ await publish({ status: 'checking', version: '2.4.0' })
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ )
+ )
+ await publish({ status: 'ready', version: '2.4.0' })
+ await expect(restart).toBeVisible()
+ })
+
+ await check('actions reach the real preload IPC with the correct operation', async () => {
+ for (const { state, action, channel } of [
+ {
+ state: { status: 'ready', version: '2.4.0' },
+ action: restart,
+ channel: 'desktop:updates:install',
+ },
+ {
+ state: { status: 'available', version: '2.5.0' },
+ action: download,
+ channel: 'desktop:updates:check',
+ },
+ {
+ state: { status: 'available', version: '2.6.0', manual: true },
+ action: download,
+ channel: 'desktop:updates:install',
+ },
+ ] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) {
+ await publish(state)
+ await expect(action).toBeVisible()
+ const received = await shell.evaluateHandle(({ ipcMain }, name) => {
+ const receipt: { url: string | null } = { url: null }
+ ipcMain.once(name, (event) => {
+ receipt.url = event.sender.getURL()
+ })
+ return receipt
+ }, channel)
+ try {
+ await action.click()
+ await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url())
+ await expect(action).toHaveCount(0)
+ } finally {
+ await received.dispose()
+ }
+ }
+ expect(errors).toEqual([])
+ })
+ passed = true
+ } finally {
+ mkdirSync(dirname(reportPath), { recursive: true })
+ writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
+ await app?.close()
+ await new Promise((resolve) => server.close(() => resolve()))
+ rmSync(userData, { recursive: true, force: true })
+ }
+})
diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts
index 4e2dbe9c126..ba968d28051 100644
--- a/apps/desktop/src/main/browser-agent/driver.ts
+++ b/apps/desktop/src/main/browser-agent/driver.ts
@@ -5399,6 +5399,7 @@ export async function handlePanelAction(
)
session.prepareExplicitNavigation(contents)
void contents.loadURL(action.url).catch(() => {})
+ session.focusPageForUser(contents)
}
return
}
diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts
index b72290245da..304e7e22ade 100644
--- a/apps/desktop/src/main/browser-agent/panel.ts
+++ b/apps/desktop/src/main/browser-agent/panel.ts
@@ -47,6 +47,8 @@ export interface PanelHost {
onViewDetached: (view: WebContentsView | null) => void
/** Invalidates field-anchored UI when the page moves, hides, or detaches. */
onGeometryChanged?: () => void
+ /** Runs after each layout that leaves the active view attached and visible. */
+ onViewShown?: (view: WebContentsView) => void
}
let host: PanelHost = {
@@ -549,6 +551,7 @@ export function layout(): void {
}, 1_000)
}
}
+ if (visible) host.onViewShown?.(active.view)
}
/** Converts the applied native DIP rectangle back into Sim viewport CSS pixels. */
diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts
index 1b7de802598..abbefea8537 100644
--- a/apps/desktop/src/main/browser-agent/session.ts
+++ b/apps/desktop/src/main/browser-agent/session.ts
@@ -119,6 +119,8 @@ export interface AgentTab {
lastRealUserGestureAt?: number
/** The tab whose page opened this one; agent work returns there when this tab closes. */
openerTabId?: string
+ /** A user action asked for this page to take focus once it is on screen. */
+ pendingUserFocus?: boolean
}
interface PendingMediaPermission {
@@ -211,7 +213,13 @@ const FOREGROUND_TAB_RESTORE_TIMEOUT_MS = 20_000
const MEDIA_PERMISSION_GESTURE_WINDOW_MS = 10_000
const MEDIA_PERMISSION_PROMPT_TIMEOUT_MS = 30_000
-export type BrowserShortcut = 'focus-omnibox' | 'new-tab' | 'close-tab' | 'find'
+export type BrowserShortcut =
+ | 'focus-omnibox'
+ | 'new-tab'
+ | 'close-tab'
+ | 'find'
+ | 'reload'
+ | 'hard-reload'
type BrowserShortcutInput = Pick<
Input,
@@ -220,25 +228,32 @@ type BrowserShortcutInput = Pick<
/**
* Resolves browser-level shortcuts using Command on macOS and Control
- * elsewhere. Modified/composing/repeated keystrokes stay with the page.
+ * elsewhere. Alt, composing, and repeated keystrokes stay with the page.
+ *
+ * Reload resolves here, from the page's own keystroke, rather than through the
+ * application menu: the menu can only guess which surface owns focus, and a
+ * wrong guess reloads all of Sim instead of the page.
*/
export function browserShortcutForInput(
input: BrowserShortcutInput,
platform: NodeJS.Platform = process.platform
): BrowserShortcut | null {
- if (
- input.type !== 'keyDown' ||
- input.isAutoRepeat ||
- input.isComposing ||
- input.shift ||
- input.alt
- ) {
+ if (input.type !== 'keyDown' || input.isAutoRepeat || input.isComposing || input.alt) {
return null
}
const primaryModifier = platform === 'darwin' ? input.meta : input.control
- if (!primaryModifier) return null
+ const otherModifier = platform === 'darwin' ? input.control : input.meta
- switch (input.key.toLowerCase()) {
+ if (input.key === 'F5' && !otherModifier) {
+ if (input.shift || input.control) return 'hard-reload'
+ return input.meta ? null : 'reload'
+ }
+ if (!primaryModifier || otherModifier) return null
+
+ const key = input.key.toLowerCase()
+ if (key === 'r') return input.shift ? 'hard-reload' : 'reload'
+ if (input.shift) return null
+ switch (key) {
case 'l':
return 'focus-omnibox'
case 't':
@@ -266,6 +281,12 @@ interface BrowserScopeState {
lastPersistedSnapshot: string | null
focusedBrowserTabId: string | null
focusedBrowserClearTimer: ReturnType | null
+ /**
+ * Renderer-drawn browser chrome (omnibox, toolbar, a New Tab or error page)
+ * owns the user's interaction. Those surfaces hide the native page, so this
+ * is the only evidence the Browser is the shortcut target while they show.
+ */
+ browserChromeFocused: boolean
automationActive: boolean
automationNeedsAttention: boolean
/**
@@ -293,6 +314,7 @@ function createBrowserScopeState(): BrowserScopeState {
lastPersistedSnapshot: null,
focusedBrowserTabId: null,
focusedBrowserClearTimer: null,
+ browserChromeFocused: false,
automationActive: false,
automationNeedsAttention: false,
findingTabId: null,
@@ -1039,6 +1061,10 @@ export function initSession(
if (!scopeId) return
withBrowserScope(scopeId, restoreBrowserSession)
},
+ onViewShown: (view) => {
+ const scopeId = browserScopeIdForView(view)
+ if (scopeId) withBrowserScope(scopeId, () => applyPendingUserFocus(view))
+ },
onViewDetached: (view) => {
if (!view) return
const scopeId = browserScopeIdForView(view)
@@ -1815,6 +1841,29 @@ function configureBrowserDownloads(ses: Session): void {
})
}
+/**
+ * Hands keyboard focus to a page the user just navigated or opened, the way
+ * Chrome focuses the content after an omnibox Enter. Tab views never focus
+ * themselves on navigation, so this is the only path that moves focus into a
+ * page. A view that is not on screen yet takes focus when it is first shown.
+ */
+export function focusPageForUser(contents: WebContents): void {
+ const tab = tabForContents(contents)
+ if (!tab) return
+ tab.pendingUserFocus = true
+ applyPendingUserFocus(tab.view)
+}
+
+function applyPendingUserFocus(view: WebContentsView): void {
+ const tab = activeTab()
+ if (!tab?.pendingUserFocus || tab.view !== view || view.webContents.isDestroyed()) return
+ // A renderer modal can hide the view while the panel keeps its bounds.
+ if (!view.getVisible() || !isPanelVisible()) return
+ if (getBrowserScopeId() !== getActiveBrowserScopeId()) return
+ tab.pendingUserFocus = false
+ view.webContents.focus()
+}
+
function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void {
if (getBrowserScopeId() !== getActiveBrowserScopeId()) return
const win = panelWindow()
@@ -1830,7 +1879,18 @@ function tabForContents(contents: WebContents): AgentTab | null {
function publishPageIssue(tab: AgentTab, focusRecovery = false): void {
events?.onTabsChanged()
if (tab.id !== currentScope.activeTabId) return
- if (focusRecovery && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) {
+ // Recovery moves focus to the renderer's issue page only when the failed page
+ // held it; the user typing in chat keeps their caret.
+ const pageHadFocus =
+ !currentScope.browserChromeFocused &&
+ (currentScope.focusedBrowserTabId === tab.id ||
+ (!tab.view.webContents.isDestroyed() && tab.view.webContents.isFocused()))
+ if (
+ focusRecovery &&
+ pageHadFocus &&
+ getBrowserScopeId() === getActiveBrowserScopeId() &&
+ isPanelVisible()
+ ) {
const win = panelWindow()
if (win && !win.isDestroyed()) win.webContents.focus()
}
@@ -1952,6 +2012,22 @@ export function reloadPage(contents: WebContents): void {
contents.reload()
}
+/**
+ * Reloads past the HTTP cache, the browser's Shift-reload. A failed or hung
+ * page takes the plain reload's recovery instead: a load error retries the
+ * URL that failed, which need not be the committed page, and a hung renderer
+ * must be restarted, since reloading it in place waits on the hung page.
+ */
+function hardReloadPage(contents: WebContents): void {
+ const issue = tabForContents(contents)?.pageIssue
+ if (issue?.kind === 'load-error' || issue?.kind === 'unresponsive') {
+ reloadPage(contents)
+ return
+ }
+ prepareExplicitNavigation(contents)
+ contents.reloadIgnoringCache()
+}
+
/** Hands one page selection to the exact app window and chat hosting its tab. */
function addPageSelectionToChat(contents: WebContents, text: string): void {
if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return
@@ -2087,10 +2163,10 @@ function adoptPopupTab(
opener: WebContents,
url: string,
popup: PopupWindowOptions,
- agentOwned: boolean
+ { agentOwned, background }: { agentOwned: boolean; background: boolean }
): WebContents {
const openerTabId = tabForContents(opener)?.id
- const tab = agentOwned ? addAutomationTab(url, popup) : addTab(url, popup)
+ const tab = openLinkTab(url, { agentOwned, background }, popup)
tab.openerTabId = openerTabId
const contents = tab.view.webContents
// A background-tab disposition defers creation, so Chromium supplies no contents to adopt.
@@ -2098,16 +2174,38 @@ function adoptPopupTab(
return contents
}
+/**
+ * Creates the tab a link opens. The user's Cmd/middle-click keeps their page
+ * in front, as in Chrome; a foreground tab the user opened takes focus.
+ */
+function openLinkTab(
+ url: string,
+ { agentOwned, background }: { agentOwned: boolean; background: boolean },
+ popup?: PopupWindowOptions
+): AgentTab {
+ if (agentOwned) return addAutomationTab(url, popup)
+ if (background) {
+ restoreBrowserSession()
+ return addTabInternal({ activate: false, url, popup })
+ }
+ const tab = addTab(url, popup)
+ focusPageForUser(tab.view.webContents)
+ return tab
+}
+
/**
* Opens a link from a page in another tab of this browser. Shared by the
* window.open interception and the page's right-click menu — both have to stay
* inside the browser resource rather than spawn a native window, and both are
* reached from an untrusted page, so the scheme is checked here once.
*/
-function openTabWithUrl(url: string, { agentOwned }: { agentOwned: boolean }): void {
+function openTabWithUrl(
+ url: string,
+ { agentOwned, background = false }: { agentOwned: boolean; background?: boolean }
+): void {
if (!/^https?:\/\//i.test(url)) return
try {
- const tab = agentOwned ? addAutomationTab(url) : addTab(url)
+ const tab = openLinkTab(url, { agentOwned, background })
void tab.view.webContents.loadURL(url).catch(() => {})
} catch (error) {
logger.warn('Could not open a link in a new browser tab', {
@@ -2165,6 +2263,10 @@ function createFreshTabView(appSession: BrowserAppSession | undefined): WebConte
// the active tab while a tool waits on it, applied explicitly by
// applyAutomationTabPolicy — never blanket across every tab.
backgroundThrottling: true,
+ // Electron focuses a page on every main-frame commit, even a hidden
+ // agent tab, which steals the caret from chat. Focus follows explicit
+ // user actions instead (see focusPageForUser).
+ focusOnNavigation: false,
spellcheck: false,
// The default every origin this tab visits starts at; a per-origin zoom
// the user sets from the page menu still wins and still persists.
@@ -2209,7 +2311,8 @@ function initializeTabView(
registerAgentNavigation(contents, routeNavigation)
attachAgentContextMenu(contents, {
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
- openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false })),
+ openTab: (url) =>
+ withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false, background: true })),
defaultZoomFactor: getBrowserDefaultZoomFactor,
})
@@ -2221,7 +2324,38 @@ function initializeTabView(
currentScope.focusedBrowserClearTimer = null
}
const tab = tabs.find((entry) => entry.view.webContents === contents)
- currentScope.focusedBrowserTabId = tab?.id ?? currentScope.activeTabId
+ // A tab the user cannot see never keeps keyboard focus. Chromium focuses
+ // a page opened without an opener (a target=_blank link) while creating
+ // it, before the tab is even listed. Hand focus back to the visible page
+ // the user was in, or else to Sim, once that focus call has returned, or
+ // Chromium finishes it over the top.
+ if (!tab || tab.id !== currentScope.activeTabId) {
+ const active = activeTab()
+ const returnTo =
+ active &&
+ !currentScope.browserChromeFocused &&
+ currentScope.focusedBrowserTabId === active.id
+ ? active
+ : null
+ setImmediate(
+ bindToBrowserScope(scopeId, () => {
+ const current = tabs.find((entry) => entry.view.webContents === contents)
+ const win = panelWindow()
+ if (current?.id === currentScope.activeTabId || !win || win.isDestroyed()) return
+ if (contents.isDestroyed() || !contents.isFocused()) return
+ if (
+ returnTo?.id === currentScope.activeTabId &&
+ !returnTo.view.webContents.isDestroyed()
+ ) {
+ returnTo.view.webContents.focus()
+ } else {
+ win.webContents.focus()
+ }
+ })
+ )
+ return
+ }
+ currentScope.focusedBrowserTabId = tab.id
})
)
contents.on(
@@ -2270,16 +2404,20 @@ function initializeTabView(
contents.setWindowOpenHandler((details) =>
withBrowserScope(scopeId, () => {
const agentOwned = agentOwnsPopupFrom(contents)
+ const background = details.disposition === 'background-tab'
if (!canAdoptPopup(contents, details.url)) {
- openTabWithUrl(details.url, { agentOwned })
+ openTabWithUrl(details.url, { agentOwned, background })
return { action: 'deny' }
}
return {
action: 'allow',
outlivesOpener: true,
+ // A popup's contents are created by Chromium from these preferences, not
+ // from createFreshTabView, so they must opt out of navigation focus here.
+ overrideBrowserWindowOptions: { webPreferences: { focusOnNavigation: false } },
createWindow: (options) =>
withBrowserScope(scopeId, () =>
- adoptPopupTab(contents, details.url, options, agentOwned)
+ adoptPopupTab(contents, details.url, options, { agentOwned, background })
),
}
})
@@ -2367,6 +2505,14 @@ function initializeTabView(
focusRendererOmnibox('clear')
return
}
+ if (shortcut === 'reload') {
+ reloadPage(contents)
+ return
+ }
+ if (shortcut === 'hard-reload') {
+ hardReloadPage(contents)
+ return
+ }
if (tab) closeTabFromUser(tab.id)
})
@@ -3177,6 +3323,7 @@ export function switchTab(tabId: string, { claim = true }: { claim?: boolean } =
const previousActiveTab = activeTab()
if (previousActiveTab && previousActiveTab.id !== tab.id) {
revokeTabMediaPermissions(previousActiveTab, false)
+ previousActiveTab.pendingUserFocus = false
}
currentScope.activeTabId = tab.id
if (claim) currentScope.visibleTabUserSelected = true
@@ -3302,9 +3449,9 @@ export function closeAutomationTab(tabId: string): void {
/** The live page whose browser surface owns a menu accelerator. */
function focusedTabForShortcut(ownerWindow?: BrowserWindow | null): AgentTab | null {
- if (!isPanelVisible() || !panelUpdateAllowed(ownerWindow ?? undefined, getBrowserScopeId())) {
- return null
- }
+ if (!panelUpdateAllowed(ownerWindow ?? undefined, getBrowserScopeId())) return null
+ if (currentScope.browserChromeFocused) return activeTab()
+ if (!isPanelVisible()) return null
return (
tabs.find(
(tab) =>
@@ -3380,8 +3527,13 @@ export function handleFocusedShortcut(
reloadPage(shortcutTab.view.webContents)
return true
case 'hard-reload':
- prepareExplicitNavigation(shortcutTab.view.webContents)
- shortcutTab.view.webContents.reloadIgnoringCache()
+ hardReloadPage(shortcutTab.view.webContents)
+ return true
+ case 'back':
+ goBack(shortcutTab.view.webContents)
+ return true
+ case 'forward':
+ goForward(shortcutTab.view.webContents)
return true
}
@@ -3404,9 +3556,22 @@ export function setPanelFocused(
withBrowserScope(scopeId, () => {
if (!panelUpdateAllowed(ownerWindow, getBrowserScopeId())) return
if (!focused) {
+ currentScope.browserChromeFocused = false
+ for (const tab of tabs) tab.pendingUserFocus = false
+ // The renderer reports losing focus when the user clicks into the native
+ // page too; that page's own focus claim must survive the report.
+ const claimed = tabs.find((tab) => tab.id === currentScope.focusedBrowserTabId)
+ if (
+ claimed &&
+ !claimed.view.webContents.isDestroyed() &&
+ claimed.view.webContents.isFocused()
+ ) {
+ return
+ }
clearFocusedBrowserTab()
return
}
+ currentScope.browserChromeFocused = true
if (currentScope.focusedBrowserClearTimer !== null) {
clearTimeout(currentScope.focusedBrowserClearTimer)
currentScope.focusedBrowserClearTimer = null
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 840334ce29c..4b2fb02f087 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -51,6 +51,7 @@ import { isAgentWebContents } from '@/main/browser-agent/registry'
import {
addTab,
findInActiveTab,
+ focusPageForUser,
getBrowserDownloadsState,
peekTabsState,
reorderTab,
@@ -959,6 +960,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
return peekTabsState()
}
void tab.view.webContents.loadURL(destination).catch(() => {})
+ focusPageForUser(tab.view.webContents)
return peekTabsState()
})
},
diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts
index c7d61ec0ba6..b2234f74d5b 100644
--- a/apps/desktop/src/main/menu.ts
+++ b/apps/desktop/src/main/menu.ts
@@ -95,6 +95,29 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
}
}
+ /** The focused Browser tab moves through its own history; otherwise the Sim window does. */
+ const traverseHistory = (
+ direction: 'back' | 'forward'
+ ): NonNullable => {
+ return (_item, focusedWindow) => {
+ const win = focusedMainOrFallback(focusedWindow)
+ if (!win) return
+ if (deps.handleFocusedResourceShortcut(win, direction)) return
+ const history = win.webContents.navigationHistory
+ if (direction === 'back' ? history.canGoBack() : history.canGoForward()) {
+ if (direction === 'back') history.goBack()
+ else history.goForward()
+ }
+ }
+ }
+
+ const reload: NonNullable = (_item, focusedWindow) => {
+ const win = focusedMainOrFallback(focusedWindow)
+ if (!win) return
+ if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return
+ win.webContents.reload()
+ }
+
const viewSubmenu: MenuItemConstructorOptions[] = [
/**
* The command palette is the web app's own `Mod+K` command; claiming the
@@ -120,25 +143,20 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
{
label: 'Back',
accelerator: 'CmdOrCtrl+[',
- click: (_item, focusedWindow) => {
- const win = focusedMainOrFallback(focusedWindow)
- if (!win) return
- const history = win.webContents.navigationHistory
- if (history.canGoBack()) {
- history.goBack()
- }
- },
+ click: traverseHistory('back'),
+ },
+ {
+ label: 'Forward',
+ accelerator: 'CmdOrCtrl+]',
+ click: traverseHistory('forward'),
},
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
- click: (_item, focusedWindow) => {
- const win = focusedMainOrFallback(focusedWindow)
- if (!win) return
- if (deps.handleFocusedResourceShortcut(win, 'reload-or-clear')) return
- win.webContents.reload()
- },
+ click: reload,
},
+ /** F5 is the Windows and Linux reload key; hidden so the menu keeps one Reload row. */
+ { label: 'Reload', accelerator: 'F5', visible: false, click: reload },
/**
* Hard refresh, cache ignored. A focused Browser tab claims it first
* (same boundary as Reload/Close Tab); otherwise it reloads the Sim
diff --git a/apps/desktop/src/main/resource-shortcuts.ts b/apps/desktop/src/main/resource-shortcuts.ts
index 24aa02d7350..cfcff836a47 100644
--- a/apps/desktop/src/main/resource-shortcuts.ts
+++ b/apps/desktop/src/main/resource-shortcuts.ts
@@ -13,6 +13,8 @@ export type FocusedResourceShortcut =
| ResourceTabSelectionShortcut
| 'reload-or-clear'
| 'hard-reload'
+ | 'back'
+ | 'forward'
| `zoom-${DesktopZoomAction}`
export type ResourceTabSelectionShortcut =
diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts
index 6256a0bcd93..d20e68a57ec 100644
--- a/apps/desktop/src/main/terminal/index.ts
+++ b/apps/desktop/src/main/terminal/index.ts
@@ -495,8 +495,15 @@ export class TerminalService {
emitRendererCommand: (command: TerminalShortcutCommand, terminalId: string) => void,
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
+ // Hard reload and history have no terminal meaning — leave them to the Browser or shell.
+ if (
+ shortcut === 'focus-omnibox' ||
+ shortcut === 'hard-reload' ||
+ shortcut === 'back' ||
+ shortcut === 'forward'
+ ) {
+ return false
+ }
const visibleTabShortcut =
shortcut === 'new-tab' || shortcut === 'reopen-closed-tab' || shortcut === 'close-tab'
const ownsVisibleTabs =
diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts
index 822eab9e3ad..84155b510d7 100644
--- a/apps/desktop/src/test/electron-mock.ts
+++ b/apps/desktop/src/test/electron-mock.ts
@@ -250,7 +250,11 @@ function createWebContentsMock() {
export class WebContentsView {
webContents = createWebContentsMock()
setBackgroundColor = vi.fn()
- setVisible = vi.fn()
+ private visible = true
+ setVisible = vi.fn((visible: boolean) => {
+ this.visible = visible
+ })
+ getVisible = vi.fn(() => this.visible)
private bounds = { x: 0, y: 0, width: 0, height: 0 }
setBounds = vi.fn((bounds: { x: number; y: number; width: number; height: number }) => {
this.bounds = { ...bounds }
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index 53cb7eb66b8..01ba721eec7 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -10012,3 +10012,18 @@ export function OtterIcon(props: SVGProps) {
)
}
+
+export function PlanetScaleIcon(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts
index ab68c3ddf52..540e8cba8ff 100644
--- a/apps/docs/components/ui/icon-mapping.ts
+++ b/apps/docs/components/ui/icon-mapping.ts
@@ -212,6 +212,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
+ PlanetScaleIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
@@ -555,6 +556,7 @@ export const blockTypeToIconMap: Record = {
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
pitchbook: PitchBookIcon,
+ planetscale: PlanetScaleIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
diff --git a/apps/docs/content/docs/cli/billing.mdx b/apps/docs/content/docs/cli/billing.mdx
index 7e9d7cf6efa..2058936e030 100644
--- a/apps/docs/content/docs/cli/billing.mdx
+++ b/apps/docs/content/docs/cli/billing.mdx
@@ -7,13 +7,13 @@ import { CommandTable } from '@/components/ui/command-table'
Every command below also accepts the [global options](/cli/commands#global-options).
-## Show billing status and current-period credit usage
+## Show billing status, credits spent this period, and storage used
```bash
sim billing status [options]
```
-Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key)
+Show billing status, credits spent this period, and storage used (credits and storage require an OAuth login or personal API key)
**Options**
diff --git a/apps/docs/content/docs/cli/cli.mdx b/apps/docs/content/docs/cli/cli.mdx
new file mode 100644
index 00000000000..2135fc78fb5
--- /dev/null
+++ b/apps/docs/content/docs/cli/cli.mdx
@@ -0,0 +1,24 @@
+---
+title: CLI
+description: Discover commands in this CLI — every subcommand, argument, and flag
+---
+
+import { CommandTable } from '@/components/ui/command-table'
+
+Every command below also accepts the [global options](/cli/commands#global-options).
+
+## Find the command for a task, described in plain words
+
+```bash
+sim cli search
+```
+
+**Arguments**
+
+
+
+| Argument | Required | Description |
+| --- | --- | --- |
+| `query...` | Yes | What you want to do, e.g. "cancel a workflow run" |
+
+
diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx
index e63345e7a0f..85c9eb0da58 100644
--- a/apps/docs/content/docs/cli/commands.mdx
+++ b/apps/docs/content/docs/cli/commands.mdx
@@ -32,6 +32,7 @@ These apply to every command, and may be written before or after it.
| --- | --- |
| [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login |
| [`sim telemetry`](/cli/telemetry) | Control anonymous usage reporting |
+| [`sim cli`](/cli/cli) | Discover commands in this CLI |
| [`sim audit-logs`](/cli/audit-logs) | Manage audit logs |
| [`sim billing`](/cli/billing) | Manage billing |
| [`sim blocks`](/cli/blocks) | Manage blocks |
@@ -56,7 +57,7 @@ These apply to every command, and may be written before or after it.
| [`sim workflows`](/cli/workflows) | Manage workflows |
| [`sim workspaces`](/cli/workspaces) | Manage workspaces |
-## Sign in through the browser and store the login for the profile
+## Log in through the browser and store the login for the profile
```bash
sim login [options]
@@ -108,7 +109,7 @@ sim whoami [options]
-## Set a profile's endpoint, default workspace, or output format
+## Switch a profile's default workspace, or set its endpoint or output format
```bash
sim configure [options]
diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx
index 7963852f478..630e23f7090 100644
--- a/apps/docs/content/docs/cli/configuration.mdx
+++ b/apps/docs/content/docs/cli/configuration.mdx
@@ -40,7 +40,7 @@ sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28
```bash
sim configure --set-endpoint http://localhost:3000 --profile dev
sim configure --set-workspace 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 --profile dev
-sim configure --set-output json
+sim configure --set-output table
```
| Option | What it sets |
@@ -64,7 +64,7 @@ Each setting resolves independently, and the first match wins:
| 1 | Command-line flag — `--endpoint`, `--workspace`, `--output` |
| 2 | Environment — `SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT` |
| 3 | `~/.sim/config` for the selected profile and `~/.sim/credentials` for its `auth_profile`, when set |
-| 4 | Built-in default — `https://www.sim.ai` and `table` |
+| 4 | Built-in default — `https://www.sim.ai` and `json` |
`sim whoami` prints the winning source for each setting:
diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx
index aad0f887ff0..b79f79045f5 100644
--- a/apps/docs/content/docs/cli/files.mdx
+++ b/apps/docs/content/docs/cli/files.mdx
@@ -646,7 +646,7 @@ sim files upload [options]
-## Get a file’s content
+## Download a file’s content to stdout or a local file
```bash
sim files get [options]
diff --git a/apps/docs/content/docs/cli/index.mdx b/apps/docs/content/docs/cli/index.mdx
index cf2b958cdef..f9996d4c3f9 100644
--- a/apps/docs/content/docs/cli/index.mdx
+++ b/apps/docs/content/docs/cli/index.mdx
@@ -7,9 +7,10 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
`sim` is the command line for Sim. Sign in once, then run workflows, query tables,
-move files, search knowledge bases, and read run logs from the terminal. Use
-`--output json` for structured results that pipe into `jq`, cron jobs, and CI
-pipelines. See [Output formats](/cli/output) for commands that emit raw content
+move files, search knowledge bases, and read run logs from the terminal. Results
+are JSON by default, so they pipe into `jq`, cron jobs, CI pipelines, and coding
+agents; use `--output table` to read them as tables. Don't know a command? Run
+`sim cli search ""`. See [Output formats](/cli/output) for commands that emit raw content
or local configuration.
## Install
diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx
index 01a792b744b..e449824c7b9 100644
--- a/apps/docs/content/docs/cli/knowledge.mdx
+++ b/apps/docs/content/docs/cli/knowledge.mdx
@@ -455,7 +455,7 @@ Enable or disable every matching document (OAuth login or personal API key requi
-## Delete document
+## Delete a document from a knowledge base
```bash
sim knowledge documents delete [options]
diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx
index 4b35e0db28b..22403eec7a9 100644
--- a/apps/docs/content/docs/cli/logs.mdx
+++ b/apps/docs/content/docs/cli/logs.mdx
@@ -31,6 +31,8 @@ sim logs get [options]
| Option | Required | Description |
| --- | --- | --- |
+| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged. |
+| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
@@ -60,7 +62,7 @@ sim logs stats [options]
-## List logs
+## List run logs, such as failed or errored runs, by workflow, trigger or time
```bash
sim logs list [options]
@@ -100,7 +102,7 @@ sim logs list [options]
-## Watch runs as they arrive, printing each new run once
+## Watch runs live as they arrive, printing each new run once
```bash
sim logs follow [options]
diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json
index 0ba739e7a34..14ef23a60dd 100644
--- a/apps/docs/content/docs/cli/meta.json
+++ b/apps/docs/content/docs/cli/meta.json
@@ -15,6 +15,7 @@
"commands",
"profiles",
"telemetry",
+ "cli",
"audit-logs",
"billing",
"blocks",
diff --git a/apps/docs/content/docs/cli/output.mdx b/apps/docs/content/docs/cli/output.mdx
index 234149a1a5d..67df6820722 100644
--- a/apps/docs/content/docs/cli/output.mdx
+++ b/apps/docs/content/docs/cli/output.mdx
@@ -7,16 +7,16 @@ Commands that return structured API data support four output formats.
| Format | For |
| --- | --- |
-| `table` | reading (default) |
-| `json` | piping into `jq` |
+| `json` | agents, scripts, and piping into `jq` (default) |
+| `table` | reading |
| `yaml` | piping into anything that reads YAML |
| `text` | shell loops — tab-separated, no header, no colour |
Select one per command, save it to the profile, or set it in the environment:
```bash
-sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output json
-sim configure --set-output json
+sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output table
+sim configure --set-output table
SIM_OUTPUT=yaml sim logs list > logs.yaml
```
diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx
index 459a37af741..c24d737698e 100644
--- a/apps/docs/content/docs/cli/reference.mdx
+++ b/apps/docs/content/docs/cli/reference.mdx
@@ -26,7 +26,7 @@ These apply to every command, and may be written before or after it.
## sim login
-Sign in through the browser and store the login for the profile
+Log in through the browser and store the login for the profile
```bash
sim login [options]
@@ -84,7 +84,7 @@ sim whoami [options]
## sim configure
-Set a profile's endpoint, default workspace, or output format
+Switch a profile's default workspace, or set its endpoint or output format
```bash
sim configure [options]
@@ -215,6 +215,26 @@ Turn usage reporting off for this machine
sim telemetry disable
```
+## sim cli
+
+### sim cli search
+
+Find the command for a task, described in plain words
+
+```bash
+sim cli search
+```
+
+**Arguments**
+
+
+
+| Argument | Required | Description |
+| --- | --- | --- |
+| `query...` | Yes | What you want to do, e.g. "cancel a workflow run" |
+
+
+
## sim audit-logs
Also spelled `sim audit-log`.
@@ -280,7 +300,7 @@ sim audit-logs list [options]
### sim billing status
-Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key)
+Show billing status, credits spent this period, and storage used (credits and storage require an OAuth login or personal API key)
```bash
sim billing status [options]
@@ -1424,7 +1444,7 @@ sim files upload [options]
### sim files get
-Get a file’s content
+Download a file’s content to stdout or a local file
```bash
sim files get [options]
@@ -1952,7 +1972,7 @@ sim knowledge documents batch-update [options]
### sim knowledge documents delete
-Delete Document
+Delete a document from a knowledge base
```bash
sim knowledge documents delete [options]
@@ -2731,6 +2751,8 @@ sim logs get [options]
| Option | Required | Description |
| --- | --- | --- |
+| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged. |
+| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
@@ -2764,7 +2786,7 @@ sim logs stats [options]
### sim logs list
-List Logs
+List run logs, such as failed or errored runs, by workflow, trigger or time
```bash
sim logs list [options]
@@ -2806,7 +2828,7 @@ sim logs list [options]
### sim logs follow
-Watch runs as they arrive, printing each new run once
+Watch runs live as they arrive, printing each new run once
```bash
sim logs follow [options]
@@ -4074,7 +4096,7 @@ sim secrets list [options]
### sim secrets set
-Create or replace a named secret (OAuth login or personal API key required)
+Create or replace a named secret, such as an API key or environment variable (OAuth login or personal API key required)
```bash
sim secrets set [options]
@@ -4120,7 +4142,7 @@ sim selectors get [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). |
| `--id ` | Yes | Resource identifier. |
@@ -4140,7 +4162,7 @@ sim selectors list [options]
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). |
| `--search ` | No | Provider option search text. |
| `--cursor ` | No | Continue from nextCursor returned by a previous result. |
@@ -4763,7 +4785,7 @@ sim tables rows list [options]
### sim tables rows query
-Query Rows
+Query rows with a filter and sort
```bash
sim tables rows query [options]
@@ -6048,7 +6070,7 @@ sim workflows activate create [options]
### sim workflows operations apply
-Apply Workflow Operations (OAuth login or personal API key required)
+Edit a workflow’s blocks, connections and settings with a batch of operations (OAuth login or personal API key required)
```bash
sim workflows operations apply [options]
diff --git a/apps/docs/content/docs/cli/secrets.mdx b/apps/docs/content/docs/cli/secrets.mdx
index 67ce8e273eb..2b6d70dc1f5 100644
--- a/apps/docs/content/docs/cli/secrets.mdx
+++ b/apps/docs/content/docs/cli/secrets.mdx
@@ -60,13 +60,13 @@ List Secrets (OAuth login or personal API key required)
-## Create or replace a named secret
+## Create or replace a named secret, such as an API key or environment variable
```bash
sim secrets set [options]
```
-Create or replace a named secret (OAuth login or personal API key required)
+Create or replace a named secret, such as an API key or environment variable (OAuth login or personal API key required)
**Arguments**
diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx
index 4ff6c71849b..573a0a987c9 100644
--- a/apps/docs/content/docs/cli/selectors.mdx
+++ b/apps/docs/content/docs/cli/selectors.mdx
@@ -21,7 +21,7 @@ Get Selector Option (OAuth login or personal API key required)
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). |
| `--id ` | Yes | Resource identifier. |
@@ -41,7 +41,7 @@ List Selector Options (OAuth login or personal API key required)
| Option | Required | Description |
| --- | --- | --- |
-| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
+| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. |
| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). |
| `--search ` | No | Provider option search text. |
| `--cursor ` | No | Continue from nextCursor returned by a previous result. |
diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx
index 3f92acd7992..79f0a440134 100644
--- a/apps/docs/content/docs/cli/tables.mdx
+++ b/apps/docs/content/docs/cli/tables.mdx
@@ -382,7 +382,7 @@ sim tables rows list [options]
-## Query rows
+## Query rows with a filter and sort
```bash
sim tables rows query [options]
diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx
index f2c37240f5e..9a98eb0f113 100644
--- a/apps/docs/content/docs/cli/workflows.mdx
+++ b/apps/docs/content/docs/cli/workflows.mdx
@@ -38,13 +38,13 @@ Activate Workflow Version (OAuth login or personal API key required)
-## Apply workflow operations
+## Edit a workflow’s blocks, connections and settings with a batch of operations
```bash
sim workflows operations apply [options]
```
-Apply Workflow Operations (OAuth login or personal API key required)
+Edit a workflow’s blocks, connections and settings with a batch of operations (OAuth login or personal API key required)
**Arguments**
diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx
index cdd139e837c..4a8aee1dd2e 100644
--- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx
+++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Use an Atlassian service account for Jira, Jira Service Management, and Confluence workflows. One credential can serve all three products on the same site when its account access and token scopes cover each product.
-Setting up Search? Follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account) for its content and permission scopes. [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
+Setting up a Confluence knowledge base connector? Use the [connector scope list](#confluence-knowledge-base-connectors). For Search, follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account). [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
## Create the account and token
@@ -56,6 +56,28 @@ read:page:confluence
`read:confluence-user` covers the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get). The [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get) needs `read:space:confluence`; page reads and the page picker need `read:page:confluence`.
+### Confluence knowledge base connectors
+
+A Confluence knowledge base connector reads pages, blog posts, attachments, labels, and the space permissions and page restrictions it mirrors. Its service-account token needs every scope below, not only the Confluence scopes above:
+
+```text
+read:confluence-content.all
+read:page:confluence
+read:blogpost:confluence
+read:attachment:confluence
+read:space:confluence
+read:label:confluence
+search:confluence
+read:confluence-space.summary
+read:content.metadata:confluence
+read:space.permission:confluence
+read:confluence-user
+read:user:confluence
+read:group:confluence
+```
+
+Without `read:attachment:confluence`, pages still sync, but their PDF, Word, Excel, and PowerPoint attachments are not indexed and each sync reports a partial source listing. To fix a token that is missing scopes, create a replacement token with the full list. A credential admin then opens the credential in **Integrations**, selects **Reconnect**, and enters the new token and site domain. Connectors that use the credential keep using it.
+
### Workflow actions
Add scopes for the actions your workflow performs:
diff --git a/apps/docs/content/docs/integrations/bitbucket.mdx b/apps/docs/content/docs/integrations/bitbucket.mdx
index 8da2d740a3a..e89819972b9 100644
--- a/apps/docs/content/docs/integrations/bitbucket.mdx
+++ b/apps/docs/content/docs/integrations/bitbucket.mdx
@@ -10,6 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Bitbucket](https://bitbucket.org/) is Atlassian's Git hosting service for code, pull requests, and CI/CD with Bitbucket Pipelines.
+
+With the Bitbucket block, you can:
+
+- **Browse workspaces and repositories**: List workspaces and repositories, and read repository details
+- **Read source code**: List branches, commits, and directory contents, and read files and file metadata
+- **Manage branches**: Create and delete branches
+- **Work on pull requests**: List, read, create, approve, merge, and decline pull requests, read their diffs, and list or add comments
+- **Run pipelines**: List pipelines and their steps, trigger or stop a pipeline, and read step logs
+
+Connect your Bitbucket account with OAuth. The Bitbucket triggers can also start a workflow on pushes, repository changes, build status updates, and pull request events, with the webhook managed for you.
+
+In Sim, the Bitbucket block lets your agents take part in your development process: review a pull request when it opens, summarize a failed pipeline from its step logs, or cut a release branch and open the pull request for it.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, diagnose or control pipelines, and start workflows from repository and pull request events. OAuth is used for actions and automatic webhook management.
diff --git a/apps/docs/content/docs/integrations/mailgun.mdx b/apps/docs/content/docs/integrations/mailgun.mdx
index 035582636bc..2da9138b2cb 100644
--- a/apps/docs/content/docs/integrations/mailgun.mdx
+++ b/apps/docs/content/docs/integrations/mailgun.mdx
@@ -32,6 +32,7 @@ Send an email using Mailgun API
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `domain` | string | Yes | Mailgun sending domain \(e.g., mg.example.com\) |
| `from` | string | Yes | Sender email address \(e.g., sender@example.com or "Name <sender@example.com>"\) |
| `to` | string | Yes | Recipient email address \(e.g., user@example.com\). Use comma-separated values for multiple recipients |
@@ -59,6 +60,7 @@ Retrieve a stored message by its key
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `domain` | string | Yes | Mailgun domain for retrieving messages \(e.g., mg.example.com\) |
| `messageKey` | string | Yes | Message storage key |
@@ -89,6 +91,7 @@ List events (logs) for messages sent through Mailgun
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `domain` | string | Yes | Mailgun domain for listing events \(e.g., mg.example.com\) |
| `event` | string | No | Filter by event type \(accepted, delivered, failed, opened, clicked, etc.\) |
| `limit` | number | No | Maximum number of events to return \(default: 100\) |
@@ -110,6 +113,7 @@ Create a new mailing list
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `address` | string | Yes | Mailing list address \(e.g., newsletter@mg.example.com\) |
| `name` | string | No | Mailing list name |
| `description` | string | No | Mailing list description |
@@ -132,6 +136,7 @@ Get details of a mailing list
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `address` | string | Yes | Mailing list address to retrieve \(e.g., newsletter@mg.example.com\) |
#### Output
@@ -150,6 +155,7 @@ Add a member to a mailing list
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `listAddress` | string | Yes | Mailing list address \(e.g., list@mg.example.com\) |
| `address` | string | Yes | Member email address to add \(e.g., user@example.com\) |
| `name` | string | No | Member name |
@@ -173,6 +179,7 @@ List all domains for your Mailgun account
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
#### Output
@@ -191,6 +198,7 @@ Get details of a specific domain
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Mailgun API key |
+| `region` | string | No | Mailgun account region: "us" \(default\) or "eu" |
| `domain` | string | Yes | Domain name to retrieve details for \(e.g., mg.example.com\) |
#### Output
diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json
index d6e7a7213a6..3e9e15d234e 100644
--- a/apps/docs/content/docs/integrations/meta.json
+++ b/apps/docs/content/docs/integrations/meta.json
@@ -199,6 +199,7 @@
"pipedrive",
"pipedrive-service-account",
"pitchbook",
+ "planetscale",
"polymarket",
"postgresql",
"posthog",
diff --git a/apps/docs/content/docs/integrations/modal.mdx b/apps/docs/content/docs/integrations/modal.mdx
index 9986a47d34b..48e8899afcd 100644
--- a/apps/docs/content/docs/integrations/modal.mdx
+++ b/apps/docs/content/docs/integrations/modal.mdx
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#000000"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Modal](https://modal.com/) is a serverless platform for running Python code, AI models, and batch jobs on cloud CPUs and GPUs.
+
+With the Modal block, you can:
+
+- **Call your functions**: Send an HTTPS request to a deployed Modal Web Function or Server and get back its response
+- **Generate completions**: Get chat completions from a model you serve on a Modal Endpoint
+- **List models**: See which models a token can reach
+
+To connect, create a proxy auth token in your Modal workspace settings and enter its token ID and secret in the block.
+
+In Sim, the Modal block lets your agents use compute you already run on Modal: call a custom inference function, run a GPU job on data from an earlier block, or use your own hosted model inside a workflow.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Modal into your workflow to reach the serverless compute you already run there. Invoke a deployed Web Function or Server over HTTPS with proxy-token auth, generate completions from a model served by a Modal Endpoint, and list the models a token can reach.
diff --git a/apps/docs/content/docs/integrations/otter.mdx b/apps/docs/content/docs/integrations/otter.mdx
index 879091512a4..c049a657f41 100644
--- a/apps/docs/content/docs/integrations/otter.mdx
+++ b/apps/docs/content/docs/integrations/otter.mdx
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#FFFFFF"
/>
+{/* MANUAL-CONTENT-START:intro */}
+[Otter.ai](https://otter.ai/) is an AI meeting assistant that records, transcribes, and summarizes conversations.
+
+With the Otter block, you can:
+
+- **Read conversations**: List conversations and read each one's summary, action items, insights, outline, and transcript
+- **Get recordings**: Get audio download links and import recordings for transcription
+- **Browse your workspace**: List channels and their members, read workspace details, and list conversations across the workspace
+
+To connect, enter an API key from an Otter Enterprise workspace. The Otter triggers can also start a workflow when a conversation finishes processing or is shared.
+
+In Sim, the Otter block lets your agents act on your meetings: send action items to your task tracker, post summaries to Slack, or save transcripts to a knowledge base.
+{/* MANUAL-CONTENT-END */}
+
+
## Usage Instructions
Integrate Otter.ai into your workflow to list and read meeting conversations with their summaries, action items, insights, outlines, and transcripts, get audio download links, import recordings, and browse channels and workspace details. Otter can also trigger workflows when a conversation finishes processing or is shared. Requires an Otter Enterprise workspace.
diff --git a/apps/docs/content/docs/integrations/planetscale.mdx b/apps/docs/content/docs/integrations/planetscale.mdx
new file mode 100644
index 00000000000..098d48e02ec
--- /dev/null
+++ b/apps/docs/content/docs/integrations/planetscale.mdx
@@ -0,0 +1,571 @@
+---
+title: PlanetScale
+description: Manage PlanetScale databases, branches, backups, and deploy requests
+---
+
+import { BlockInfoCard } from "@/components/ui/block-info-card"
+
+
+
+{/* MANUAL-CONTENT-START:intro */}
+[PlanetScale](https://planetscale.com/) is a managed database platform for MySQL (built on Vitess) and PostgreSQL. It uses Git-style branches for schema changes and deploy requests to review and ship those changes safely.
+
+With the PlanetScale block, you can:
+
+- **Inspect databases and branches**: List the databases in an organization, read database details, and list or read branches
+- **Manage branches**: Create development branches from a parent, restore a backup into a new branch, or delete branches you no longer need
+- **Work with backups**: Create on-demand backups, list backups for a branch, and check a backup's state, size, and expiration
+- **Ship schema changes (Vitess)**: Create deploy requests, review or approve them, queue them for deployment, and close them
+
+To connect, create a service token in your PlanetScale organization settings and grant it access to the databases and actions your workflow needs. Enter the service token ID, the service token, and your organization slug in the block.
+
+In Sim, the PlanetScale block lets your agents manage database operations as part of a workflow: spin up a branch when a pull request opens, take a backup before a release, report on backup health every day, or open and track a deploy request for a schema change. To run SQL queries against a database, use the MySQL or PostgreSQL block.
+{/* MANUAL-CONTENT-END */}
+
+
+## Usage Instructions
+
+Manage PlanetScale databases and branches, create and inspect backups, and create, review, queue, and close Vitess deploy requests. Authenticate with an organization service token. Deploy-request actions require a Vitess database; SQL queries are available through the MySQL and PostgreSQL integrations.
+
+
+
+## Actions
+
+### PlanetScale List Databases
+
+List databases
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `q` | string | No | Search term to filter databases by name |
+| `page` | number | No | If provided, specifies the page offset of returned results |
+| `perPage` | number | No | If provided, specifies the number of returned results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pagination` | json | Pagination metadata |
+| ↳ `currentPage` | number | current page |
+| ↳ `perPage` | number | per page |
+| ↳ `nextPage` | number | next page |
+| ↳ `totalCount` | number | total count |
+| ↳ `totalPages` | number | total pages |
+| `databases` | array | databases |
+| ↳ `id` | string | The ID of the database |
+| ↳ `name` | string | Name of the database |
+| ↳ `kind` | string | The kind of database |
+| ↳ `state` | string | State of the database |
+| ↳ `ready` | boolean | If the database is ready to be used |
+| ↳ `defaultBranch` | string | The default branch for the database |
+| ↳ `branchesCount` | number | The total number of database branches |
+| ↳ `deletionProtected` | boolean | Whether deletion protection is enabled for the database |
+| ↳ `requireApprovalForDeploy` | boolean | Whether an approval is required to deploy schema changes to this database |
+| ↳ `createdAt` | string | When the database was created |
+| ↳ `updatedAt` | string | When the database was last updated |
+| ↳ `htmlUrl` | string | The URL to see this database's branches in the web UI |
+
+### PlanetScale Get Database
+
+Get a database
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `database` | json | Database details |
+| ↳ `id` | string | The ID of the database |
+| ↳ `name` | string | Name of the database |
+| ↳ `kind` | string | The kind of database |
+| ↳ `state` | string | State of the database |
+| ↳ `ready` | boolean | If the database is ready to be used |
+| ↳ `defaultBranch` | string | The default branch for the database |
+| ↳ `branchesCount` | number | The total number of database branches |
+| ↳ `deletionProtected` | boolean | Whether deletion protection is enabled for the database |
+| ↳ `requireApprovalForDeploy` | boolean | Whether an approval is required to deploy schema changes to this database |
+| ↳ `createdAt` | string | When the database was created |
+| ↳ `updatedAt` | string | When the database was last updated |
+| ↳ `htmlUrl` | string | The URL to see this database's branches in the web UI |
+
+### PlanetScale List Branches
+
+List branches
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. |
+| `q` | string | No | Search branches by name |
+| `production` | boolean | No | Filter branches by production status |
+| `safeMigrations` | boolean | No | Filter branches by safe migrations \(DDL protection\) |
+| `order` | string | No | Order branches by created_at time |
+| `page` | number | No | If provided, specifies the page offset of returned results |
+| `perPage` | number | No | If provided, specifies the number of returned results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pagination` | json | Pagination metadata |
+| ↳ `currentPage` | number | current page |
+| ↳ `perPage` | number | per page |
+| ↳ `nextPage` | number | next page |
+| ↳ `totalCount` | number | total count |
+| ↳ `totalPages` | number | total pages |
+| `branches` | array | branches |
+| ↳ `id` | string | The ID of the branch |
+| ↳ `name` | string | The name of the branch |
+| ↳ `kind` | string | The kind of branch |
+| ↳ `state` | string | The current state of the branch |
+| ↳ `ready` | boolean | Whether or not the branch is ready to serve queries |
+| ↳ `production` | boolean | Whether or not the branch is a production branch |
+| ↳ `safeMigrations` | boolean | Whether or not the branch has safe migrations enabled |
+| ↳ `deletionProtected` | boolean | Whether deletion protection is enabled for the branch |
+| ↳ `parentBranch` | string | The name of the parent branch from which the branch was created |
+| ↳ `createdAt` | string | When the branch was created |
+| ↳ `updatedAt` | string | When the branch was last updated |
+| ↳ `htmlUrl` | string | Planetscale app URL for the branch |
+
+### PlanetScale Get Branch
+
+Get a branch
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. |
+| `branch` | string | Yes | Branch name from `list_branches`. Example: `main`. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `branch` | json | Branch details |
+| ↳ `id` | string | The ID of the branch |
+| ↳ `name` | string | The name of the branch |
+| ↳ `kind` | string | The kind of branch |
+| ↳ `state` | string | The current state of the branch |
+| ↳ `ready` | boolean | Whether or not the branch is ready to serve queries |
+| ↳ `production` | boolean | Whether or not the branch is a production branch |
+| ↳ `safeMigrations` | boolean | Whether or not the branch has safe migrations enabled |
+| ↳ `deletionProtected` | boolean | Whether deletion protection is enabled for the branch |
+| ↳ `parentBranch` | string | The name of the parent branch from which the branch was created |
+| ↳ `createdAt` | string | When the branch was created |
+| ↳ `updatedAt` | string | When the branch was last updated |
+| ↳ `htmlUrl` | string | Planetscale app URL for the branch |
+
+### PlanetScale Create Branch
+
+Create a branch
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the database the branch belongs to |
+| `name` | string | Yes | The name of the branch to create |
+| `deletionProtected` | boolean | No | Whether deletion protection is enabled for the branch |
+| `parentBranch` | string | No | The name of the parent branch. Defaults to the database's default branch if not provided. |
+| `backupId` | string | No | If provided, restores the backup's schema and data to the new branch. Must have `restore_production_branch_backup\(s\)` or `restore_backup\(s\)` access to do this. |
+| `region` | string | No | The region to create the branch in. If not provided, the branch will be created in the default region for its database. |
+| `restorePoint` | string | No | Restore from a point-in-time recovery timestamp \(e.g. 2023-01-01T00:00:00Z\). Available only for PostgreSQL databases. |
+| `replicas` | number | No | For PostgreSQL backup restores and point-in-time recovery, the number of additional replicas from 0 to 8, subject to the target cluster size. 0 creates a single-node branch. If omitted, the target cluster size's minimum is used. |
+| `seedData` | string | No | If provided, restores the last successful backup's schema and data to the new branch. Must have `restore_production_branch_backup\(s\)` or `restore_backup\(s\)` access to do this, in addition to Data Branching™ being enabled for the branch. |
+| `clusterSize` | string | No | The database cluster size. Required when backupId is provided, optional otherwise. Options: PS_10, PS_20, PS_40, ..., PS_2800 |
+| `majorVersion` | string | No | For PostgreSQL and Neki databases, the PostgreSQL major version to use for the branch. Defaults to the major version of the parent branch if it exists or the database's default branch major version. Ignored for branches restored from backups. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `branch` | json | Branch details |
+| ↳ `id` | string | The ID of the branch |
+| ↳ `name` | string | The name of the branch |
+| ↳ `kind` | string | The kind of branch |
+| ↳ `state` | string | The current state of the branch |
+| ↳ `ready` | boolean | Whether or not the branch is ready to serve queries |
+| ↳ `production` | boolean | Whether or not the branch is a production branch |
+| ↳ `safeMigrations` | boolean | Whether or not the branch has safe migrations enabled |
+| ↳ `deletionProtected` | boolean | Whether deletion protection is enabled for the branch |
+| ↳ `parentBranch` | string | The name of the parent branch from which the branch was created |
+| ↳ `createdAt` | string | When the branch was created |
+| ↳ `updatedAt` | string | When the branch was last updated |
+| ↳ `htmlUrl` | string | Planetscale app URL for the branch |
+
+### PlanetScale Delete Branch
+
+Delete a branch
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the database the branch belongs to |
+| `branch` | string | Yes | The name of the branch |
+| `deleteDescendants` | boolean | No | If true, recursively delete all descendant branches along with this branch |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the branch was deleted |
+
+### PlanetScale List Backups
+
+List backups
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the database the branch belongs to |
+| `branch` | string | Yes | The name of the branch |
+| `all` | boolean | No | Whether to include all backups, including deleted ones |
+| `state` | string | No | Filter backups by state |
+| `policy` | string | No | Filter backups by backup policy ID |
+| `from` | string | No | Filter backups started after this date \(e.g. 2023-01-01T00:00:00Z\) |
+| `to` | string | No | Filter backups started before this date \(e.g. 2023-01-31T23:59:59Z\) |
+| `runningAt` | string | No | Filter backups that are running during a specific time \(e.g. 2023-01-01T00:00:00Z..2023-01-01T23:59:59Z\) |
+| `production` | boolean | No | Filter backups by production branch |
+| `page` | number | No | If provided, specifies the page offset of returned results |
+| `perPage` | number | No | If provided, specifies the number of returned results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pagination` | json | Pagination metadata |
+| ↳ `currentPage` | number | current page |
+| ↳ `perPage` | number | per page |
+| ↳ `nextPage` | number | next page |
+| ↳ `totalCount` | number | total count |
+| ↳ `totalPages` | number | total pages |
+| `backups` | array | backups |
+| ↳ `id` | string | The ID of the backup |
+| ↳ `name` | string | The name of the backup |
+| ↳ `state` | string | The current state of the backup |
+| ↳ `size` | number | The size of the backup in bytes |
+| ↳ `estimatedStorageCost` | number | The estimated storage cost of the backup |
+| ↳ `protected` | boolean | Whether or not the backup is protected from deletion |
+| ↳ `createdAt` | string | When the backup was created |
+| ↳ `updatedAt` | string | When the backup was last updated |
+| ↳ `startedAt` | string | When the backup started |
+| ↳ `completedAt` | string | When the backup completed |
+| ↳ `expiresAt` | string | When the backup expires |
+
+### PlanetScale Get Backup
+
+Get a backup
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `backupId` | string | Yes | The ID for the backup |
+| `database` | string | Yes | The name of the database the branch belongs to |
+| `branch` | string | Yes | The name of the branch |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `backup` | json | Backup details |
+| ↳ `id` | string | The ID of the backup |
+| ↳ `name` | string | The name of the backup |
+| ↳ `state` | string | The current state of the backup |
+| ↳ `size` | number | The size of the backup in bytes |
+| ↳ `estimatedStorageCost` | number | The estimated storage cost of the backup |
+| ↳ `protected` | boolean | Whether or not the backup is protected from deletion |
+| ↳ `createdAt` | string | When the backup was created |
+| ↳ `updatedAt` | string | When the backup was last updated |
+| ↳ `startedAt` | string | When the backup started |
+| ↳ `completedAt` | string | When the backup completed |
+| ↳ `expiresAt` | string | When the backup expires |
+
+### PlanetScale Create Backup
+
+Create a backup
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the database the branch belongs to |
+| `branch` | string | Yes | The name of the branch |
+| `name` | string | No | Name for the backup |
+| `retentionUnit` | string | No | Unit for the retention period of the backup |
+| `retentionValue` | number | No | Value between `1` and `1000` for the retention period of the backup \(i.e retention_value `6` and retention_unit `hour` means 6 hours\) |
+| `emergency` | boolean | No | Whether the backup is an immediate backup that may affect database performance. Emergency backups are only supported for PostgreSQL databases. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `backup` | json | Backup details |
+| ↳ `id` | string | The ID of the backup |
+| ↳ `name` | string | The name of the backup |
+| ↳ `state` | string | The current state of the backup |
+| ↳ `size` | number | The size of the backup in bytes |
+| ↳ `estimatedStorageCost` | number | The estimated storage cost of the backup |
+| ↳ `protected` | boolean | Whether or not the backup is protected from deletion |
+| ↳ `createdAt` | string | When the backup was created |
+| ↳ `updatedAt` | string | When the backup was last updated |
+| ↳ `startedAt` | string | When the backup started |
+| ↳ `completedAt` | string | When the backup completed |
+| ↳ `expiresAt` | string | When the backup expires |
+
+### PlanetScale List Deploy Requests
+
+List deploy requests (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the deploy request's database |
+| `state` | string | No | Filter by state of the deploy request \(open, closed, deployed\) |
+| `branch` | string | No | Filter by the name of the branch the deploy request is created from |
+| `intoBranch` | string | No | Filter by the name of the branch the deploy request will be merged into |
+| `deployedAt` | string | No | Filter deploy requests by the date they were deployed. \(e.g. 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z\) |
+| `runningAt` | string | No | Filter deploy requests by the date they were running. \(e.g. 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z\) |
+| `page` | number | No | If provided, specifies the page offset of returned results |
+| `perPage` | number | No | If provided, specifies the number of returned results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pagination` | json | Pagination metadata |
+| ↳ `currentPage` | number | current page |
+| ↳ `perPage` | number | per page |
+| ↳ `nextPage` | number | next page |
+| ↳ `totalCount` | number | total count |
+| ↳ `totalPages` | number | total pages |
+| `deployRequests` | array | deploy requests |
+| ↳ `id` | string | The ID of the deploy request |
+| ↳ `number` | number | The number of the deploy request |
+| ↳ `branch` | string | The name of the branch the deploy request was created from |
+| ↳ `intoBranch` | string | The name of the branch the deploy request will be merged into |
+| ↳ `state` | string | Whether the deploy request is open or closed |
+| ↳ `deploymentState` | string | The deployment state of the deploy request |
+| ↳ `approved` | boolean | Whether or not the deploy request is approved |
+| ↳ `numComments` | number | The number of comments on the deploy request |
+| ↳ `notes` | string | Notes on the deploy request |
+| ↳ `createdAt` | string | When the deploy request was created |
+| ↳ `updatedAt` | string | When the deploy request was last updated |
+| ↳ `closedAt` | string | When the deploy request was closed |
+| ↳ `deployedAt` | string | When the deploy request was deployed |
+| ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request |
+
+### PlanetScale Get Deploy Request
+
+Get a deploy request (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the deploy request's database |
+| `deployRequestNumber` | number | Yes | The number of the deploy request |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deployRequest` | json | DeployRequest details |
+| ↳ `id` | string | The ID of the deploy request |
+| ↳ `number` | number | The number of the deploy request |
+| ↳ `branch` | string | The name of the branch the deploy request was created from |
+| ↳ `intoBranch` | string | The name of the branch the deploy request will be merged into |
+| ↳ `state` | string | Whether the deploy request is open or closed |
+| ↳ `deploymentState` | string | The deployment state of the deploy request |
+| ↳ `approved` | boolean | Whether or not the deploy request is approved |
+| ↳ `numComments` | number | The number of comments on the deploy request |
+| ↳ `notes` | string | Notes on the deploy request |
+| ↳ `createdAt` | string | When the deploy request was created |
+| ↳ `updatedAt` | string | When the deploy request was last updated |
+| ↳ `closedAt` | string | When the deploy request was closed |
+| ↳ `deployedAt` | string | When the deploy request was deployed |
+| ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request |
+
+### PlanetScale Create Deploy Request
+
+Create a deploy request (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the deploy request's database |
+| `branch` | string | Yes | The name of the branch the deploy request is created from |
+| `intoBranch` | string | Yes | The name of the branch the deploy request will be merged into |
+| `notes` | string | No | Notes about the deploy request |
+| `autoCutover` | boolean | No | Whether or not to enable auto_cutover for the deploy request. When enabled, will auto cutover to the new schema as soon as it is ready. |
+| `autoDeleteBranch` | boolean | No | Whether or not to enable auto_delete_branch for the deploy request. When enabled, will delete the branch once the DR successfully completes. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deployRequest` | json | DeployRequest details |
+| ↳ `id` | string | The ID of the deploy request |
+| ↳ `number` | number | The number of the deploy request |
+| ↳ `branch` | string | The name of the branch the deploy request was created from |
+| ↳ `intoBranch` | string | The name of the branch the deploy request will be merged into |
+| ↳ `state` | string | Whether the deploy request is open or closed |
+| ↳ `deploymentState` | string | The deployment state of the deploy request |
+| ↳ `approved` | boolean | Whether or not the deploy request is approved |
+| ↳ `numComments` | number | The number of comments on the deploy request |
+| ↳ `notes` | string | Notes on the deploy request |
+| ↳ `createdAt` | string | When the deploy request was created |
+| ↳ `updatedAt` | string | When the deploy request was last updated |
+| ↳ `closedAt` | string | When the deploy request was closed |
+| ↳ `deployedAt` | string | When the deploy request was deployed |
+| ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request |
+
+### PlanetScale Review Deploy Request
+
+Review a deploy request (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the database the deploy request belongs to |
+| `deployRequestNumber` | number | Yes | The number of the deploy request |
+| `state` | string | No | Whether the review is a comment or approval. Service tokens must have corresponding access \(either `approve_deploy_request` or `review_deploy_request`\) |
+| `body` | string | No | Deploy request review comments |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `review` | json | Review details |
+| ↳ `id` | string | The ID of the review |
+| ↳ `state` | string | Whether the review is a comment or approval |
+| ↳ `body` | string | The text body of the review |
+| ↳ `createdAt` | string | When the review was created |
+| ↳ `updatedAt` | string | When the review was last updated |
+
+### PlanetScale Queue Deploy Request
+
+Queue a deploy request (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the deploy request's database |
+| `deployRequestNumber` | number | Yes | The number of the deploy request |
+| `instantDdl` | boolean | No | Whether or not to deploy the request with instant DDL. Defaults to false. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deployRequest` | json | DeployRequest details |
+| ↳ `id` | string | The ID of the deploy request |
+| ↳ `number` | number | The number of the deploy request |
+| ↳ `branch` | string | The name of the branch the deploy request was created from |
+| ↳ `intoBranch` | string | The name of the branch the deploy request will be merged into |
+| ↳ `state` | string | Whether the deploy request is open or closed |
+| ↳ `deploymentState` | string | The deployment state of the deploy request |
+| ↳ `approved` | boolean | Whether or not the deploy request is approved |
+| ↳ `numComments` | number | The number of comments on the deploy request |
+| ↳ `notes` | string | Notes on the deploy request |
+| ↳ `createdAt` | string | When the deploy request was created |
+| ↳ `updatedAt` | string | When the deploy request was last updated |
+| ↳ `closedAt` | string | When the deploy request was closed |
+| ↳ `deployedAt` | string | When the deploy request was deployed |
+| ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request |
+
+### PlanetScale Close Deploy Request
+
+Close a deploy request (Vitess only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `serviceTokenId` | string | Yes | PlanetScale service token ID |
+| `serviceToken` | string | Yes | PlanetScale service token secret |
+| `organization` | string | Yes | PlanetScale organization slug |
+| `database` | string | Yes | The name of the deploy request's database |
+| `deployRequestNumber` | number | Yes | The number of the deploy request |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deployRequest` | json | DeployRequest details |
+| ↳ `id` | string | The ID of the deploy request |
+| ↳ `number` | number | The number of the deploy request |
+| ↳ `branch` | string | The name of the branch the deploy request was created from |
+| ↳ `intoBranch` | string | The name of the branch the deploy request will be merged into |
+| ↳ `state` | string | Whether the deploy request is open or closed |
+| ↳ `deploymentState` | string | The deployment state of the deploy request |
+| ↳ `approved` | boolean | Whether or not the deploy request is approved |
+| ↳ `numComments` | number | The number of comments on the deploy request |
+| ↳ `notes` | string | Notes on the deploy request |
+| ↳ `createdAt` | string | When the deploy request was created |
+| ↳ `updatedAt` | string | When the deploy request was last updated |
+| ↳ `closedAt` | string | When the deploy request was closed |
+| ↳ `deployedAt` | string | When the deploy request was deployed |
+| ↳ `htmlUrl` | string | The PlanetScale app address for the deploy request |
+
+
diff --git a/apps/docs/content/docs/integrations/stripe.mdx b/apps/docs/content/docs/integrations/stripe.mdx
index fc442753326..0712d6d6d45 100644
--- a/apps/docs/content/docs/integrations/stripe.mdx
+++ b/apps/docs/content/docs/integrations/stripe.mdx
@@ -1222,7 +1222,7 @@ Cancel a subscription
### Stripe Resume Subscription
-Resume a subscription that was scheduled for cancellation
+Resume a paused subscription
#### Input
@@ -1299,7 +1299,7 @@ Resume a subscription that was scheduled for cancellation
### Stripe List Subscriptions
-List all subscriptions
+List one page of subscriptions. Follow starting_after while metadata.has_more is true.
#### Input
@@ -1309,6 +1309,8 @@ List all subscriptions
| `limit` | number | No | Number of results to return \(default 10, max 100\) |
| `customer` | string | No | Filter by customer ID |
| `status` | string | No | Filter by status \(active, past_due, unpaid, canceled, incomplete, incomplete_expired, trialing, all\) |
+| `starting_after` | string | No | Next-page cursor: last subscription ID from the previous page. Do not combine with ending_before. |
+| `ending_before` | string | No | Previous-page cursor: first subscription ID from the current page. Do not combine with starting_after. |
| `price` | string | No | Filter by price ID |
#### Output
@@ -1378,14 +1380,15 @@ List all subscriptions
### Stripe Search Subscriptions
-Search for subscriptions using query syntax
+Search one page of subscriptions using query syntax. Pass metadata.next_page as page to continue.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Stripe API key \(secret key\) |
-| `query` | string | Yes | Search query \(e.g., "status:'active' AND customer:'cus_xxx'"\) |
+| `query` | string | Yes | Search query \(e.g., "status:'active' AND metadata\['order_id'\]:'6735'"\) |
+| `page` | string | No | Pagination token from metadata.next_page. Omit for the first page and keep the same query. |
| `limit` | number | No | Number of results to return \(default 10, max 100\) |
#### Output
@@ -1452,6 +1455,7 @@ Search for subscriptions using query syntax
| `metadata` | json | Search metadata |
| ↳ `count` | number | Number of items returned |
| ↳ `has_more` | boolean | Whether more items exist beyond this page |
+| ↳ `next_page` | string | Token for the next page of search results, or null when there are no more results |
### Stripe Create Invoice
diff --git a/apps/docs/content/docs/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/platform/self-hosting/object-storage.mdx
index 216b377af23..4509bdfdeca 100644
--- a/apps/docs/content/docs/platform/self-hosting/object-storage.mdx
+++ b/apps/docs/content/docs/platform/self-hosting/object-storage.mdx
@@ -416,7 +416,8 @@ Sim works with any S3-compatible store by pointing the S3 client at a custom end
**The endpoint must be reachable from your users' browsers, and the bucket needs CORS.** Uploads use presigned `PUT` requests sent **directly from the browser** to `S3_ENDPOINT` (downloads are proxied back through the app, so they only need server-side reachability). This means:
- A purely internal endpoint (e.g. `https://minio.internal:9000` that only the app pods can resolve) will let the server start cleanly but **uploads will fail in the browser**. Use an endpoint your users can reach.
- - Configure a **CORS policy** on the bucket that allows your Sim origin (`PUT`, `GET`, and the `Authorization` / `Content-Type` / `x-amz-*` headers). This applies to AWS S3 too — R2 and MinIO are no different.
+ - Configure a **CORS policy** on the bucket that allows your Sim origin with `PUT` and `GET`, and `AllowedHeaders: ["*"]`. With a custom endpoint, the browser sends the upload's metadata as signed `x-amz-meta-*` headers alongside `Content-Type` and `If-None-Match`, because many S3-compatible stores ignore metadata passed in the URL. If you list headers individually, include all of them. MinIO applies its own server-wide CORS and needs no bucket rule.
+ - Sim adds the `S3_ENDPOINT` origin (and its bucket subdomains, unless `S3_FORCE_PATH_STYLE` is set) to its Content Security Policy, so the browser may upload there.
diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json
index a75e5fc7c7c..352407da6cc 100644
--- a/apps/docs/openapi-v2-logs.json
+++ b/apps/docs/openapi-v2-logs.json
@@ -383,6 +383,16 @@
"pattern": "^[A-Za-z0-9._:-]+$",
"description": "Unique workflow run identifier."
}
+ },
+ {
+ "name": "includeWorkflowState",
+ "in": "query",
+ "required": false,
+ "description": "Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged.",
+ "schema": {
+ "description": "Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged.",
+ "type": "boolean"
+ }
}
],
"responses": {
@@ -1602,7 +1612,7 @@
"type": "null"
}
],
- "description": "Workflow graph captured for the run, or null if unavailable. Sensitive values are redacted to null; environment-variable references may be preserved."
+ "description": "Workflow graph captured for the run, or null if unavailable or includeWorkflowState=false. Sensitive values are redacted to null; environment-variable references may be preserved."
},
"traceSpans": {
"type": "array",
diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json
index 9e1495379ee..efd74c8ae62 100644
--- a/apps/docs/openapi-v2-workflows.json
+++ b/apps/docs/openapi-v2-workflows.json
@@ -16152,6 +16152,10 @@
"webflow.sites",
"webflow.collections",
"webflow.items",
+ "planetscale.databases",
+ "planetscale.branches",
+ "planetscale.backups",
+ "planetscale.deployRequests",
"cloudwatch.logGroups",
"cloudwatch.logStreams",
"imap.mailboxes",
@@ -16327,6 +16331,10 @@
"webflow.sites",
"webflow.collections",
"webflow.items",
+ "planetscale.databases",
+ "planetscale.branches",
+ "planetscale.backups",
+ "planetscale.deployRequests",
"cloudwatch.logGroups",
"cloudwatch.logStreams",
"imap.mailboxes",
diff --git a/apps/sim/app/_shell/desktop-update-notification.tsx b/apps/sim/app/_shell/desktop-update-notification.tsx
new file mode 100644
index 00000000000..1ae21a07242
--- /dev/null
+++ b/apps/sim/app/_shell/desktop-update-notification.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { useToast } from '@sim/emcn'
+import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop'
+import { isShellOutdated } from '@/lib/desktop/min-version'
+import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
+
+interface DesktopUpdateNotificationProps {
+ [key: string]: never
+}
+
+/** Keeps optional desktop updates actionable across routes for the current window session. */
+export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) {
+ const { status, version, manual } = useDesktopUpdateState()
+ const { toast, dismiss } = useToast()
+ const dismissedOffer = useRef(null)
+
+ useEffect(() => {
+ const updates = getDesktopUpdates()
+ if (
+ !updates ||
+ isShellOutdated(getDesktopShellVersion()) ||
+ (status !== 'available' && status !== 'ready')
+ ) {
+ return
+ }
+
+ const offer = `${version ?? ''}:${status}:${Boolean(manual)}`
+ if (dismissedOffer.current === offer) return
+
+ let active = true
+ const id = toast({
+ message:
+ status === 'ready' ? 'A Sim update is ready to install' : 'A Sim update is available',
+ action: {
+ label: status === 'ready' ? 'Restart to update' : 'Download update',
+ onClick: () => {
+ if (!active) return
+ if (status === 'ready' || manual) updates.install()
+ else updates.check()
+ },
+ },
+ persistAcrossRoutes: true,
+ onUserDismiss: () => {
+ if (active) dismissedOffer.current = offer
+ },
+ })
+
+ return () => {
+ // Ignore clicks while a withdrawn toast finishes its exit animation.
+ active = false
+ dismiss(id)
+ }
+ }, [status, version, manual, toast, dismiss])
+
+ return null
+}
diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx
index a4a551f1a89..d5cd82d5dbb 100644
--- a/apps/sim/app/account/settings/[section]/page.tsx
+++ b/apps/sim/app/account/settings/[section]/page.tsx
@@ -1,4 +1,3 @@
-import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
@@ -58,14 +57,10 @@ export default async function AccountSettingsSectionPage({
}
/**
- * Sections read URL query params via nuqs, so the renderer must sit under a
- * Suspense boundary. The null fallback preserves the existing chunk-loading UI.
+ * No Suspense boundary: a fallback mounted with the section puts React's 300ms reveal floor
+ * under every switch (see the workspace section layout).
*/
- const content = (
-
-
-
- )
+ const content =
if (parsed === 'general') {
const queryClient = getQueryClient()
diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts
index b34ba42a32b..430b38b0b71 100644
--- a/apps/sim/app/api/copilot/confirm/route.ts
+++ b/apps/sim/app/api/copilot/confirm/route.ts
@@ -42,13 +42,13 @@ import {
retainSealedClientToolContext,
sealClientToolCompletion,
} from '@/lib/mothership/request/tools/client-completion-seal.server'
+import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
import {
createStructuralWorkflowToolCompletionData,
getWorkflowToolCompletionExecutionId,
getWorkflowToolCompletionMessage,
getWorkflowToolConfirmationStatus,
getWorkflowToolLaunchError,
- isWorkflowToolName,
resolveWorkflowToolTargetId,
WORKFLOW_EXECUTION_BUSY,
type WorkflowToolLaunchError,
diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
index 4410c6d8643..6f18ee9c73b 100644
--- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
+++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts
@@ -25,7 +25,7 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: getOrganizationWhitelabel,
- present: (data) => ({ success: true, data }),
+ present: ({ settings, isEnterprise }) => ({ success: true, data: settings, isEnterprise }),
})
export const PUT = defineInternalJsonRoute({
diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts
index 1bd2e63b9a2..bad5dd64acc 100644
--- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts
+++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts
@@ -99,7 +99,7 @@ describe('GET /api/v2/logs/[runId]', () => {
expect(body.data).not.toHaveProperty('executionData')
expect(mocks.execute).toHaveBeenCalledWith({
principal: auth.principal,
- input: { runId: 'run-1' },
+ input: { runId: 'run-1', includeWorkflowState: true },
request,
})
})
diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts
index af541aa72e9..4b848dca1c8 100644
--- a/apps/sim/app/api/v2/logs/[runId]/route.ts
+++ b/apps/sim/app/api/v2/logs/[runId]/route.ts
@@ -36,7 +36,10 @@ export const GET = defineV2JsonRoute({
operation: logOperations.readDetail,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2LogErrorPolicies.concealDetailAuthorization,
- mapInput: ({ params }) => ({ runId: params.runId }),
+ mapInput: ({ params, query }) => ({
+ runId: params.runId,
+ includeWorkflowState: query.includeWorkflowState,
+ }),
useCase: getPublicLog,
present: ({ log, workflowFolderPath, executionData, costLedger }) => {
const detail: V2LogDetail = {
diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts
index 4a70ec8cf45..e3732bfceb5 100644
--- a/apps/sim/app/api/webhooks/tiktok/route.ts
+++ b/apps/sim/app/api/webhooks/tiktok/route.ts
@@ -96,11 +96,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const webhooks = await findWebhooksByRoutingKey(envelope.user_openid, requestId, 'tiktok')
let dispatched = 0
let failed = 0
- for (const { webhook, workflow } of webhooks) {
+ for (const { webhook, workflow, triggerBlockDeployed } of webhooks) {
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
requestId,
receivedAt,
triggerTimestampMs: envelope.create_time * 1000,
+ triggerBlockDeployed,
})
if (result.outcome === 'queued') dispatched += 1
if (result.outcome === 'failed') failed += 1
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 41d0d4b8179..32082e4466f 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -275,7 +275,11 @@ async function handleWebhookDelivery(
}
const dispatchTargetCount = directWebhooksForPath.length + legacySlackDispatchResults.length
- for (const { webhook: foundWebhook, workflow: foundWorkflow } of directWebhooksForPath) {
+ for (const {
+ webhook: foundWebhook,
+ workflow: foundWorkflow,
+ triggerBlockDeployed,
+ } of directWebhooksForPath) {
const provider = foundWebhook.provider
if (!provider) {
const missingProviderResponse = NextResponse.json(
@@ -321,6 +325,7 @@ async function handleWebhookDelivery(
path,
receivedAt,
triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
+ triggerBlockDeployed,
}
)
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index 7fc27f4970f..d542ca8eb17 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -17,6 +17,7 @@ import {
} from '@/lib/core/config/env-flags'
import { ConsentProvider } from '@/app/_shell/consent/consent-provider'
import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate'
+import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification'
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
@@ -44,6 +45,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const themeCSS = generateThemeCSS()
const application = (
+
diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index 82e18bbdacb..5658ec0818e 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -1,6 +1,7 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
+import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
@@ -81,12 +82,14 @@ export default async function OrganizationLayout({
- {/*
- Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless
- sidebar column against the app surface, meeting the content pane on a
- single hairline divider with no gutter. Keep the two in step — a settings
- page should look the same whether it is reached inside a workspace or not.
- */}
-
-
-
-
-
-
- {children}
-
-
-
-
+
+
+ {/*
+ Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless
+ sidebar column against the app surface, meeting the content pane on a
+ single hairline divider with no gutter. Keep the two in step — a settings
+ page should look the same whether it is reached inside a workspace or not.
+ */}
+
+