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 + {pathname} +
+
+
+ ) +} + +/** 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({
- } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + +
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx index 4e0889175a7..a75b393eee1 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx @@ -1,13 +1,8 @@ import type { ReactNode } from 'react' import { notFound, redirect } from 'next/navigation' -import { - getSettingsSectionMeta, - ORGANIZATION_SETTINGS_ITEMS, - toSettingsHeaderMeta, -} from '@/components/settings/navigation' import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { organizationRoutes } from '@/lib/navigation/paths' -import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation' +import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation' interface OrganizationSettingsSectionLayoutProps { children: ReactNode @@ -24,18 +19,12 @@ export default async function OrganizationSettingsSectionLayout({ `${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps` ) } - const resolved = resolveOrganizationSurfaceSection(section) - const meta = - resolved?.plane === 'organization' - ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section) - : resolved - ? getSettingsSectionMeta('account', resolved.section) - : null + const meta = resolveOrganizationSurfaceHeaderMeta(section) if (!meta) notFound() return ( - {children} + {children} ) } diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx deleted file mode 100644 index 8c85b14beed..00000000000 --- a/apps/sim/app/o/[organizationId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -/** Lets the section layout show its heading while authorization and content finish loading. */ -export default function OrganizationSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx index 9da86653b73..54211800776 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx +++ b/apps/sim/app/o/[organizationId]/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' @@ -82,12 +81,13 @@ export default async function OrganizationSettingsSectionPage({ return } - /** Account sections read URL params via nuqs, so the renderer sits under a boundary; nothing stands in for it. */ + /** + * 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 = ( - - - + ) diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx index 52759b2ceb0..84e588433a7 100644 --- a/apps/sim/app/o/[organizationId]/settings/layout.tsx +++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx @@ -1,7 +1,9 @@ 'use client' import type { ReactNode } from 'react' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' +import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation' interface OrganizationSettingsLayoutProps { children: ReactNode @@ -9,5 +11,11 @@ interface OrganizationSettingsLayoutProps { export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) { useSettingsBeforeUnload() - return
{children}
+ return ( +
+ + {children} + +
+ ) } diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts index f559e4ae62e..ce3aac17ac5 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts @@ -1,6 +1,7 @@ import { ACCOUNT_SETTINGS_ITEMS, type AccountSettingsSection, + getSettingsSectionMeta, isOrganizationSettingsSectionAvailable, ORGANIZATION_SETTINGS_GROUPS, ORGANIZATION_SETTINGS_ITEMS, @@ -9,7 +10,9 @@ import { parseSettingsPathSection, resolveOrganizationSectionAccess, type SettingsNavigationItem, + toSettingsHeaderMeta, } from '@/components/settings/navigation' +import type { SettingsHeaderMeta } from '@/components/settings/settings-header' /** * A section on the organization surface's settings, tagged with the plane that @@ -64,6 +67,18 @@ export function resolveOrganizationSurfaceSection( return account ? { plane: 'account', section: account } : null } +/** The heading an organization-surface section path renders with, or null for an unknown path. */ +export function resolveOrganizationSurfaceHeaderMeta(path: string): SettingsHeaderMeta | null { + const resolved = resolveOrganizationSurfaceSection(path) + const item = + resolved?.plane === 'organization' + ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section) + : resolved + ? getSettingsSectionMeta('account', resolved.section) + : null + return item ? toSettingsHeaderMeta(item) : null +} + export function organizationSettingsNavigation( isAdmin: boolean, features: OrganizationSettingsFeatures, diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx index 933b783a082..e5b0d30a7a4 100644 --- a/apps/sim/app/selfhost/settings/[section]/page.tsx +++ b/apps/sim/app/selfhost/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' @@ -48,14 +47,10 @@ export default async function SelfHostSettingsSectionPage({ if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general')) /** - * 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/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx new file mode 100644 index 00000000000..80fb8472a62 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx @@ -0,0 +1,96 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ActivityStatus } from '@/components/ui/activity-status' +import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' + +const executingSearch: ToolCallData = { + id: 'search-1', + toolName: 'search_workspace', + displayTitle: 'Searching workspace', + status: 'executing', +} + +const completedSearch: ToolCallData = { + ...executingSearch, + status: 'success', + result: { + success: true, + output: { + success: true, + data: { + results: [ + { + citationId: 'document:guide', + citationUrl: 'https://example.test/guide', + documentName: 'Guide', + }, + ], + }, + }, + }, +} + +function TestToolCall({ displayTitle, renderStatus }: ToolCallItemProps) { + const status = { label: displayTitle, activeLabel: displayTitle, isActive: false, icon: null } + return renderStatus ? renderStatus(status) : +} + +describe('ToolActivityGroup search disclosure', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + function render(tool: ToolCallData, isLive: boolean) { + act(() => + root.render( + + ) + ) + } + + function disclosure() { + const button = container.querySelector('[role="button"][aria-expanded]') + if (!button) throw new Error('Expected a search activity disclosure') + return button + } + + it('opens when live results arrive, closes for the answer, and respects manual choices', () => { + render(executingSearch, true) + expect(container.querySelector('[role="button"][aria-expanded]')).toBeNull() + + render(completedSearch, true) + expect(disclosure().getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('a[href="https://example.test/guide"]')).not.toBeNull() + + render(completedSearch, false) + expect(disclosure().getAttribute('aria-expanded')).toBe('false') + + act(() => disclosure().click()) + expect(disclosure().getAttribute('aria-expanded')).toBe('true') + render(completedSearch, false) + expect(disclosure().getAttribute('aria-expanded')).toBe('true') + render(completedSearch, true) + expect(disclosure().getAttribute('aria-expanded')).toBe('true') + + act(() => disclosure().click()) + render(completedSearch, false) + render(completedSearch, true) + expect(disclosure().getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx index 1baf4c81d87..6e5b6880956 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -220,7 +220,7 @@ export function ToolActivityGroup({ autoScrollActivity = true, isLive = false, }: ToolActivityGroupProps) { - const [expanded, setExpanded] = useState(false) + const [manualExpanded, setManualExpanded] = useState(null) const tools = withoutRetriedSearchFailures(calls) const statusTool = getActivityStatusTool(tools) if (!statusTool) return null @@ -236,6 +236,7 @@ export function ToolActivityGroup({ const attentionKey = getActivityAttentionKey(tools) const entries = tools.map((tool) => ({ tool, sources: getSearchActivitySources(tool) })) const hasSearchDetails = entries.some(({ sources }) => sources !== undefined) + const expanded = manualExpanded ?? (isLive && hasSearchDetails) return ( 1 ? groupedActivity?.title : undefined} collapsible={tools.length > 1 || hasSearchDetails} expanded={expanded} - onToggle={() => setExpanded(!expanded)} + onToggle={() => setManualExpanded(!expanded)} isStreaming={working && autoScrollActivity} unbounded={entries.some(({ sources }) => (sources?.length ?? 0) > 0)} > diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx index a3f9e23198d..8a1f2bc6539 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar.tsx @@ -141,6 +141,8 @@ export function BrowserFindBar({ inputRef, onClose, scopeId }: BrowserFindBarPro onKeyDown={(event) => { // The panel and the global command layer both listen for these. event.stopPropagation() + // Keys during an IME composition edit the composed text. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return if (event.key === 'Escape') { event.preventDefault() dismiss() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx index 199aa2b8be9..d59a858afd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx @@ -125,13 +125,25 @@ export function browserPageIssueCopy(issue: BrowserPageIssue): BrowserPageIssueC } } +/** + * Whether nothing in Sim holds focus, as when it was in the native page that + * just failed. Any focused element, from the chat composer to the omnibox, + * keeps it. `document.activeElement` survives a window blur, so a caret left + * in chat still counts. + */ +function isFocusFree(): boolean { + const active = document.activeElement + return !active || active === document.body +} + /** Replaces a hidden native page and optionally claims renderer focus for keyboard recovery. */ export function BrowserPageIssueView({ issue, onReload, focusRecovery }: BrowserPageIssueProps) { const headingRef = useRef(null) const copy = browserPageIssueCopy(issue) useEffect(() => { - if (focusRecovery) headingRef.current?.focus() + // Keyboard recovery for someone who was in the browser. + if (focusRecovery && isFocusFree()) headingRef.current?.focus() }, [focusRecovery, issue]) const Icon = issue.kind === 'load-error' ? Globe : CircleAlert diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index e8ecc6223b6..fa9fc4b5bd2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -34,6 +34,10 @@ import { useTheme } from 'next-themes' import { createPortal } from 'react-dom' import { BrowserImportDialog } from '@/components/browser-import/browser-import-dialog' import { EmptyState } from '@/components/empty-state/empty-state' +import { + onBrowserOmniboxFocusRequest, + takeBrowserOmniboxFocusRequest, +} from '@/lib/browser-agent/omnibox-focus' import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' import { loadBrowserSearchSuggestions, @@ -643,16 +647,17 @@ export function BrowserSession({ useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId]) - // A fresh blank tab coming on screen — opened from the resource strip or by - // Cmd+T — gets the omnibox, the way Chrome's new-tab page does. A tab with a - // page keeps its content. - const focusedBlankTabIdRef = useRef(null) + // A blank tab the user opened from the resource strip gets the omnibox once + // it is on screen, the way Chrome's new-tab page does. Cmd+T arrives from the + // shell above. A blank tab the agent opened must never take the caret. useEffect(() => { - if (!visible || !activeTabId || !showEmptyState) return - if (focusedBlankTabIdRef.current === activeTabId) return - focusedBlankTabIdRef.current = activeTabId - focusOmnibox('clear') - }, [activeTabId, focusOmnibox, showEmptyState, visible]) + if (!visible || !activeTabId) return + const claimFocusRequest = () => { + if (takeBrowserOmniboxFocusRequest(activeTabId, scopeId)) focusOmnibox('clear') + } + claimFocusRequest() + return onBrowserOmniboxFocusRequest(claimFocusRequest) + }, [activeTabId, focusOmnibox, scopeId, visible]) // Sim owns keyboard events while its renderer has focus. Claim Cmd+L here // before the workspace's global "Go to Logs" command can navigate away. @@ -1151,6 +1156,8 @@ export function BrowserSession({ }} onKeyDown={(event) => { event.stopPropagation() + // Keys during an IME composition edit the composed text, not the URL. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { // Never move a highlight through a list that is not on screen. if (!suggestionsOpen) return @@ -1167,10 +1174,17 @@ export function BrowserSession({ } if (event.key === 'Enter') submitUrl() if (event.key === 'Escape') { - // Dismiss the list first; leave the omnibox only once - // there is no highlight left to back out of. - if (activeSuggestion !== null) setActiveSuggestion(null) - else urlInputRef.current?.blur() + // Back out one step at a time, as Chrome does: the + // highlight, then the edited text, then the omnibox. + if (activeSuggestion !== null) { + setActiveSuggestion(null) + } else if ((urlDraft ?? '') !== (pageState?.url ?? '')) { + setUrlDraft(pageState?.url ?? '') + setSuggestionsVisible(false) + selectFocusedOmniboxOnNextFrame(event.currentTarget) + } else { + urlInputRef.current?.blur() + } } }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index c5d8a5e8095..2071fb84190 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -23,6 +23,7 @@ import { import { Columns3, Eye, Pencil } from '@sim/emcn/icons' import type { TerminalTabState } from '@sim/terminal-protocol' import { useQueries } from '@tanstack/react-query' +import { requestBrowserOmniboxFocus } from '@/lib/browser-agent/omnibox-focus' import { browserTabTitle } from '@/lib/browser-agent/tab-label' import { openBrowserTab, @@ -342,7 +343,9 @@ export function ResourceTabs({ if (resource.type === 'browser') { void openBrowserTab(desktopScopeId) .then((state) => { - if (state?.activeTabId) selectResource(state.activeTabId) + if (!state?.activeTabId) return + requestBrowserOmniboxFocus(state.activeTabId, state.scopeId) + selectResource(state.activeTabId) }) .catch(() => toast.error('Could not open a new browser tab. Please try again.')) return diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.test.ts new file mode 100644 index 00000000000..6a1a931cd34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import { markMessageStopped } from '@/app/workspace/[workspaceId]/home/hooks/message-reconcile' + +describe('markMessageStopped', () => { + it.each(['executing', 'pending', 'awaiting_approval'] as const)( + 'settles a %s tool row as stopped', + (state) => { + const message: PersistedMessage = { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: '2026-09-29T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + endedAt: 1, + toolCall: { id: 'call-1', name: 'gmail_read_v2', state }, + }, + ], + } + + const stopped = markMessageStopped(message) + + expect(stopped.contentBlocks?.[0].toolCall?.state).toBe('cancelled') + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts index dccc95ad1a9..47d5373c2df 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts @@ -8,7 +8,11 @@ import { isBrowserToolName } from '@sim/browser-protocol' import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages' import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' -import { normalizeMessage, withBlockTiming } from '@/lib/mothership/chat/persisted-message' +import { + isUnsettledToolState, + normalizeMessage, + withBlockTiming, +} from '@/lib/mothership/chat/persisted-message' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -18,7 +22,7 @@ import { MothershipStreamV1ToolPhase, } from '@/lib/mothership/generated/mothership-stream-v1' import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' -import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' +import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools' import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' import { isZeroStreamCursor } from './stream-protocol' @@ -142,18 +146,18 @@ export function buildAssistantSnapshotMessage(params: { } export function markMessageStopped(message: PersistedMessage): PersistedMessage { - const hasExecutingTool = message.contentBlocks?.some( - (block) => block.toolCall?.state === 'executing' + const hasUnsettledTool = message.contentBlocks?.some((block) => + isUnsettledToolState(block.toolCall?.state) ) const hasOpenBlock = message.contentBlocks?.some((block) => block.endedAt === undefined) - if (!hasExecutingTool && !hasOpenBlock) { + if (!hasUnsettledTool && !hasOpenBlock) { return message } const stopTs = Date.now() const nextBlocks = (message.contentBlocks ?? []).map((block) => { const stamped = block.endedAt === undefined ? { ...block, endedAt: stopTs } : block - if (stamped.toolCall?.state !== 'executing') { + if (!stamped.toolCall || !isUnsettledToolState(stamped.toolCall.state)) { return stamped } return { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts index 8ed6eb19032..2b83cc3d72d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { FilePreviewSession } from '@/lib/mothership/request/session' -import { deriveFilePreviewSession } from './apply-file-preview-phase' +import { + deriveFilePreviewSession, + previewHoldsFinalContent, +} from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' const NOW = '2026-06-08T00:00:00.000Z' @@ -94,3 +97,40 @@ describe('deriveFilePreviewSession', () => { expect(third?.previewText).toBe('ABC') }) }) + +describe('previewHoldsFinalContent', () => { + const complete = (previewVersion?: number) => ({ + previewPhase: 'file_preview_complete' as const, + toolCallId: 'tool-1', + toolName: 'prepare_file_edit' as const, + ...(previewVersion !== undefined ? { previewVersion } : {}), + }) + + it('holds the final content when the last content received is the completed version', () => { + const prev = session({ previewText: 'final text', previewVersion: 7 }) + + expect(previewHoldsFinalContent(prev, complete(7))).toBe(true) + }) + + it('does not hold it when later versions were never received, so the stored file must load', () => { + const prev = session({ previewText: 'an earlier draft', previewVersion: 5 }) + + expect(previewHoldsFinalContent(prev, complete(7))).toBe(false) + }) + + it('does not hold it when no content was received at all', () => { + expect(previewHoldsFinalContent(undefined, complete(7))).toBe(false) + }) + + it('does not hold it when the session exists but received no text', () => { + const prev = session({ previewText: '', previewVersion: 7 }) + + expect(previewHoldsFinalContent(prev, complete(7))).toBe(false) + }) + + it('holds the received text when the completion carries no version to compare', () => { + const prev = session({ previewText: 'final text', previewVersion: 3 }) + + expect(previewHoldsFinalContent(prev, complete())).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts index f9d4c083070..a2d678e9856 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts @@ -99,3 +99,17 @@ export function deriveFilePreviewSession( } } } + +/** + * Whether the preview text the client holds is the edit's final content. The + * server skips a content frame too large for the stream, so a completion whose + * version is newer than the last content received means the text is an earlier + * draft, and only the stored file is the saved result. + */ +export function previewHoldsFinalContent( + prev: FilePreviewSession | undefined, + completion: Extract +): boolean { + if (!prev || prev.previewText.length === 0) return false + return completion.previewVersion === undefined || prev.previewVersion >= completion.previewVersion +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts index f4916b7c51a..15f6ea479c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts @@ -12,7 +12,10 @@ import { useQueryClient } from '@tanstack/react-query' import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' -import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' +import { + deriveFilePreviewSession, + previewHoldsFinalContent, +} from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' import { buildCompletedPreviewSessions, type FilePreviewSessionsState, @@ -381,7 +384,7 @@ export function useFilePreviewController({ sessionId: nextSession.id, suppressActivation: !shouldActivateOnComplete, }) - if (hasRenderableFilePreviewContent(nextSession)) { + if (previewHoldsFinalContent(prevSession, payload)) { seedCompletedPreviewContentCache(fileId, nextSession.previewText) } if (workspaceId) invalidateResourceQueries(queryClient, workspaceId, 'file', fileId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index b5a13580024..e42181d47c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -4,9 +4,6 @@ vi.mock('@/lib/mothership/resources/extraction', () => ({ isResourceToolName: vi.fn(() => false), extractResourcesFromToolResult: vi.fn(() => []), })) -vi.mock('@/lib/mothership/tools/workflow-tools', () => ({ - isWorkflowToolName: vi.fn(() => false), -})) vi.mock( '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry', () => ({ invalidateResourceQueries: vi.fn() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index 0fb9877fa43..1d92bcf8ddb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -14,8 +14,10 @@ import { extractResourcesFromToolResult, isResourceToolName, } from '@/lib/mothership/resources/extraction' -import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' -import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' +import { + isClientExecutedToolCall, + isWorkflowToolName, +} from '@/lib/mothership/tools/client-executed-tools' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { @@ -188,56 +190,23 @@ export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void const name = payload.toolName const isPartial = payload.partial === true || payload.status === MothershipStreamV1ToolStatus.generating - if (isWorkflowToolName(name) && !isPartial) { - const shouldStartWorkflowTool = - !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && - node?.kind === 'tool' && - node.status === 'running' && - !node.result - if (shouldStartWorkflowTool) { - const args = payload.arguments as Record | undefined + const args = payload.arguments as Record | undefined + const shouldStartClientTool = + isClientExecutedToolCall(name, args) && + !isPartial && + !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && + node?.kind === 'tool' && + node.status === 'running' && + !node.result + if (shouldStartClientTool) { + if (isWorkflowToolName(name)) { deps.startClientWorkflowTool(rawId, name, args ?? {}) - } - } - const localFilesystemArgs = payload.arguments as Record | undefined - if ((isNativeFileTool(name) || isUserLocalVfsToolCall(name, localFilesystemArgs)) && !isPartial) { - const shouldStartLocalFilesystemTool = - !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && - node?.kind === 'tool' && - node.status === 'running' && - !node.result - if (shouldStartLocalFilesystemTool) { - deps.startClientLocalFilesystemTool(rawId, name, localFilesystemArgs ?? {}) - } - } - if (isCurrentBrowserToolName(name) && !isPartial) { - const shouldStartBrowserTool = - !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && - node?.kind === 'tool' && - node.status === 'running' && - !node.result - if (shouldStartBrowserTool) { - deps.startClientBrowserTool( - rawId, - name, - (payload.arguments as Record | undefined) ?? {}, - parsed.ts - ) - } - } - if (isTerminalToolName(name) && !isPartial) { - const shouldStartTerminalTool = - !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && - node?.kind === 'tool' && - node.status === 'running' && - !node.result - if (shouldStartTerminalTool) { - deps.startClientTerminalTool( - rawId, - name, - (payload.arguments as Record | undefined) ?? {}, - parsed.ts - ) + } else if (isCurrentBrowserToolName(name)) { + deps.startClientBrowserTool(rawId, name, args ?? {}, parsed.ts) + } else if (isTerminalToolName(name)) { + deps.startClientTerminalTool(rawId, name, args ?? {}, parsed.ts) + } else { + deps.startClientLocalFilesystemTool(rawId, name, args ?? {}) } } ops.flush() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.test.ts new file mode 100644 index 00000000000..501e1fd2de0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { finalizeResidualToolCalls } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' + +describe('finalizeResidualToolCalls', () => { + it.each(['executing', 'awaiting_approval'] as const)( + 'settles a %s tool row with the turn outcome on Stop', + (status) => { + const blocks: ContentBlock[] = [ + { type: 'tool_call', toolCall: { id: 'call-1', name: 'gmail_read_v2', status } }, + ] + + finalizeResidualToolCalls(blocks, 'cancelled') + + expect(blocks[0].toolCall?.status).toBe('cancelled') + } + ) + + it('reports whether any tool row was left to settle', () => { + const open: ContentBlock[] = [ + { type: 'tool_call', toolCall: { id: 'call-1', name: 'read', status: 'awaiting_approval' } }, + ] + const settled: ContentBlock[] = [ + { type: 'tool_call', toolCall: { id: 'call-2', name: 'read', status: 'success' } }, + ] + + expect(finalizeResidualToolCalls(open, 'error')).toBe(true) + expect(finalizeResidualToolCalls(settled, 'error')).toBe(false) + }) + + it('reports closing an open subagent lane as a change to persist', () => { + const blocks: ContentBlock[] = [{ type: 'subagent', content: 'research' }] + + expect(finalizeResidualToolCalls(blocks, 'error')).toBe(true) + expect(blocks[0].endedAt).toEqual(expect.any(Number)) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 99d455dd7b1..100cbda798a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' +import { isUnsettledToolState } from '@/lib/mothership/chat/persisted-message' import { CallIntegrationTool, CreateEmptyFile, @@ -68,17 +69,17 @@ export function asPayloadRecord(value: unknown): StreamPayload | undefined { } /** - * Settles any tool row still `executing` at a turn terminal by propagating the - * turn's outcome — the deterministic replacement for the old `interrupted` - * invention. A clean `complete` means the turn succeeded, so a straggler is - * settled `success` (with explicit tool/span terminals from the backend there - * are normally none); a stop settles `cancelled`; an error settles `error`. + * Settles every unfinished tool row (running, pending, or awaiting approval) at + * a turn terminal by propagating the turn's outcome: a clean `complete` settles + * a straggler `success`, a stop `cancelled`, an error `error`. Also closes any + * open subagent lane. Returns whether it settled a row or closed a lane. */ export function finalizeResidualToolCalls( blocks: ContentBlock[], turnTerminal: 'complete' | 'cancelled' | 'error' -): void { +): boolean { const endedAt = Date.now() + let settled = false const propagated = turnTerminal === 'cancelled' ? ToolCallStatus.cancelled @@ -93,10 +94,12 @@ export function finalizeResidualToolCalls( // transport-based gating. if (block.type === 'subagent' && block.endedAt === undefined) { block.endedAt = endedAt + settled = true continue } const tc = block.toolCall - if (!tc || tc.status !== ToolCallStatus.executing) continue + if (!tc || !isUnsettledToolState(tc.status)) continue + settled = true tc.status = propagated if (propagated === ToolCallStatus.cancelled) { tc.displayTitle = 'Stopped by user' @@ -105,6 +108,7 @@ export function finalizeResidualToolCalls( block.endedAt = endedAt } } + return settled } function stringParam(value: unknown): string | undefined { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2885e0fd8f1..92da00740fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -45,9 +45,10 @@ import { import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' import { toDisplayMessage } from '@/lib/mothership/chat/display-message' import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id' -import type { - PersistedFileAttachment, - PersistedMessage, +import { + isUnsettledToolState, + type PersistedFileAttachment, + type PersistedMessage, } from '@/lib/mothership/chat/persisted-message' import { type RevealedSimKeysByMessage, @@ -82,8 +83,8 @@ import { } from '@/lib/mothership/tools/client/run-tool-execution' import { executeTerminalToolOnClient } from '@/lib/mothership/tools/client/terminal-tool-execution' import { setCurrentChatTraceparent } from '@/lib/mothership/tools/client/trace-context' +import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools' import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' -import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' @@ -3227,8 +3228,7 @@ export function useChat( const isError = !!options?.error if (isError) { const blocks = streamingBlocksRef.current - if (blocks.some((block) => block.toolCall?.status === 'executing')) { - finalizeResidualToolCalls(blocks, 'error') + if (finalizeResidualToolCalls(blocks, 'error')) { const assistantId = activeTurnRef.current?.assistantMessageId ?? (streamIdRef.current ? getLiveAssistantMessageId(streamIdRef.current) : undefined) @@ -4370,11 +4370,11 @@ export function useChat( } else { setPendingMessages((prev) => prev.map((msg) => { - const hasExecutingTool = msg.contentBlocks?.some( - (block) => block.toolCall?.status === 'executing' + const hasUnsettledTool = msg.contentBlocks?.some((block) => + isUnsettledToolState(block.toolCall?.status) ) const hasOpenBlock = msg.contentBlocks?.some((block) => block.endedAt === undefined) - if (!hasExecutingTool && !hasOpenBlock) { + if (!hasUnsettledTool && !hasOpenBlock) { return msg } const updatedBlocks: ContentBlock[] = (msg.contentBlocks ?? []).map((block) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 64ba769d9d1..9a884ac45b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -13,12 +13,14 @@ const { mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, mockPrefetchWorkspaceAccess, + mockPrefetchWorkspaceForkAvailability, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), mockPrefetchWorkspaceAccess: vi.fn(), + mockPrefetchWorkspaceForkAvailability: vi.fn(), })) vi.mock('@sim/emcn', () => emcnMock) @@ -38,6 +40,7 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({ prefetchWorkspaceHostContext: mockPrefetchWorkspaceHostContext, prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar, + prefetchWorkspaceForkAvailability: mockPrefetchWorkspaceForkAvailability, })) vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({ @@ -149,6 +152,7 @@ describe('WorkspaceLayout host context', () => { mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT) mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined) mockPrefetchWorkspaceAccess.mockResolvedValue(undefined) + mockPrefetchWorkspaceForkAvailability.mockResolvedValue(undefined) mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index d875a37ba6a..79019d63b37 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/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' @@ -12,6 +13,7 @@ import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { + prefetchWorkspaceForkAvailability, prefetchWorkspaceHostContext, prefetchWorkspaceSidebar, } from '@/app/workspace/[workspaceId]/prefetch' @@ -50,6 +52,11 @@ export default async function WorkspaceLayout({ } const activeOrganizationId = getActiveOrganizationId(session) + const principal = { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + } as const const [ cookieStore, initialOrgSettings, @@ -74,11 +81,8 @@ export default async function WorkspaceLayout({ isMothershipModelSelectorEnabled(), isPlanModeEnabled(), resolveOrganizationEntryPath(session), - prefetchWorkspaceAccess(queryClient, workspaceId, { - kind: 'session', - userId: session.user.id, - sessionId: session.session.id, - }), + prefetchWorkspaceAccess(queryClient, workspaceId, principal), + prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -108,12 +112,14 @@ export default async function WorkspaceLayout({ - } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + + diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 3ca4f3ed1f4..d69738630c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,3 +1,4 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import type { QueryClient } from '@tanstack/react-query' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { isChatEnabled } from '@/lib/core/config/env-flags' @@ -8,6 +9,10 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' +import { + FORK_AVAILABILITY_STALE_TIME, + forkAvailabilityKeys, +} from '@/ee/workspace-forking/hooks/use-forking-available' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, @@ -114,3 +119,28 @@ export async function prefetchWorkspaceSidebar( seedWorkspaceList(queryClient, userId, activeOrganizationId), ]) } + +/** + * Seeds fork availability, which decides whether the settings sidebar lists Workspace Forks, so + * the row renders with the rest of the sidebar. Only admins can read it + * (`forkOperations.discover`), so it is skipped for everyone else. It runs the availability + * route's own use case; a failed read stays out of hydration and the client refetches it. + */ +export async function prefetchWorkspaceForkAvailability( + queryClient: QueryClient, + workspaceId: string, + principal: SessionPrincipal, + hostContext: WorkspaceHostContext +): Promise { + if (hostContext.viewer.permission !== 'admin') return + await queryClient.prefetchQuery({ + queryKey: forkAvailabilityKeys.detail(workspaceId), + queryFn: async () => { + const { getWorkspaceForkAvailability } = await import( + '@/ee/workspace-forking/application/discovery' + ) + return getWorkspaceForkAvailability.execute({ principal, input: { workspaceId } }) + }, + staleTime: FORK_AVAILABILITY_STALE_TIME, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index a2965fd9ab0..e1499f22311 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -25,13 +25,20 @@ const TOP_LEVEL_REDIRECTS: Readonly stri * The heading is resolved here rather than pushed up from the section body, so it renders with * the shell instead of waiting on the body's lazily-loaded chunk. * - * Whether a segment names a section at all is decided here too, above the sibling - * `loading.tsx`. Inside that Suspense boundary a `notFound()` or `redirect()` can no longer set - * the response status — React replays the boundary on the client and the shell still flushes - * 200 — so a bad or legacy URL loaded directly would answer 200 and redirect in a second round - * trip. Deciding it above the boundary keeps the 404 and the 307. Whether the *viewer* may open - * a section is a different question and stays in the page, where it belongs; those checks need - * the database and are reached almost entirely by client navigation. + * Whether a segment names a section at all is decided here too, before the shell renders, so a + * bad or legacy URL loaded directly answers 404 or 307 without running section authorization. + * Whether the *viewer* may open a section is a different question and stays in the page, where it + * belongs; those checks need the database and are reached almost entirely by client navigation. + * + * There is deliberately no sibling `loading.tsx`, and the page renders its body under no Suspense + * boundary of its own. React holds content that resolves inside a freshly committed fallback for + * at least 300ms, so any boundary mounted with the section — a route fallback, or a page-level + * `` around the code-split body — put that floor under every section switch. Without + * one, a switch is a transition: the sidebar moves its selection and the settings layout paints + * the incoming heading over the outgoing section, which stays mounted but invisible until the + * incoming section and its chunk are ready. A boundary above this layout (a + * `settings/loading.tsx`, or a `` in the settings layout) would also swallow the 404 + * and 307 above. * * Authentication is already enforced by the ancestor workspace layout, so this runs only for a * signed-in viewer. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx deleted file mode 100644 index 925929408ab..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Route-transition fallback for the workspace settings sections. - * - * Its job is to exist. Without a loading boundary the App Router holds the outgoing section on - * screen until the incoming page's access gate resolves, so a click reads as a dead click; with - * one, the navigation commits immediately and the heading changes with it. It is also what - * makes the sidebar's `router.prefetch` worth anything — with no loading boundary in the - * subtree the scheduler skips the segment request entirely, and an `AUTO` prefetch caches the - * shell only as far as the nearest boundary. - * - * It renders no body of its own because the shell that owns the header, heading and scroll - * region renders above it and is already resolved by this point. That lands in the same place - * as the two neighbouring settings fallbacks — credit-usage renders its title and description - * over an empty body, `ResourceChromeFallback` renders a real header and column headers over - * zero rows — without restating chrome this route already has. - */ -export default function WorkspaceSettingsSectionLoading() { - return null -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index d845f3b6b41..0ee633ade62 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -111,9 +111,7 @@ export default async function WorkspaceSettingsSectionPage({ return ( - - - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index fa715bb3687..a6354b4d3fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -14,121 +14,56 @@ import { getSettingsSectionMeta, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { SECTION_MODULES } from '@/app/workspace/[workspaceId]/settings/section-warmers' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' -const Admin = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) -) -const ApiKeys = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( - (m) => m.ApiKeys - ) -) -const BYOK = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) -) -const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) -const Secrets = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) -) +const Admin = dynamic(() => SECTION_MODULES.admin().then((m) => m.Admin)) +const ApiKeys = dynamic(() => SECTION_MODULES.apikeys().then((m) => m.ApiKeys)) +const BYOK = dynamic(() => SECTION_MODULES.byok().then((m) => m.BYOK)) +const Forks = dynamic(() => SECTION_MODULES.forks().then((m) => m.Forks)) +const Secrets = dynamic(() => SECTION_MODULES.secrets().then((m) => m.Secrets)) const OrganizationConnectedAccounts = dynamic(() => - import('@/ee/credential-groups/components/organization-connected-accounts').then( - (m) => m.OrganizationConnectedAccounts - ) -) -const Sandboxes = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( - (m) => m.Sandboxes - ) -) -const CustomTools = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( - (m) => m.CustomTools - ) -) -const Inbox = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox) -) -const MCP = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP) -) -const Mothership = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( - (m) => m.Mothership - ) + SECTION_MODULES['connected-accounts']().then((m) => m.OrganizationConnectedAccounts) ) +const Sandboxes = dynamic(() => SECTION_MODULES.sandboxes().then((m) => m.Sandboxes)) +const CustomTools = dynamic(() => SECTION_MODULES['custom-tools']().then((m) => m.CustomTools)) +const Inbox = dynamic(() => SECTION_MODULES.inbox().then((m) => m.Inbox)) +const MCP = dynamic(() => SECTION_MODULES.mcp().then((m) => m.MCP)) +const Mothership = dynamic(() => SECTION_MODULES.mothership().then((m) => m.Mothership)) const RecentlyDeleted = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' - ).then((m) => m.RecentlyDeleted) -) -const SelfHost = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then( - (m) => m.SelfHost - ) -) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing) -) -const Teammates = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then( - (m) => m.Teammates - ) -) -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (m) => m.TeamManagement - ) + SECTION_MODULES['recently-deleted']().then((m) => m.RecentlyDeleted) ) +const SelfHost = dynamic(() => SECTION_MODULES['self-host']().then((m) => m.SelfHost)) +const Billing = dynamic(() => SECTION_MODULES.billing().then((m) => m.Billing)) +const Teammates = dynamic(() => SECTION_MODULES.teammates().then((m) => m.Teammates)) +const TeamManagement = dynamic(() => SECTION_MODULES.organization().then((m) => m.TeamManagement)) const WorkflowMcpServers = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' - ).then((m) => m.WorkflowMcpServers) + SECTION_MODULES['workflow-mcp-servers']().then((m) => m.WorkflowMcpServers) ) const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) + SECTION_MODULES['access-control']().then((m) => m.AccessControl) ) const AccessRequestsSettings = dynamic(() => - import('@/ee/access-requests/components/access-requests-settings').then( - (m) => m.AccessRequestsSettings - ) -) -const CustomBlocks = dynamic(() => - import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) -) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) + SECTION_MODULES.requests().then((m) => m.AccessRequestsSettings) ) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const CustomBlocks = dynamic(() => SECTION_MODULES['custom-blocks']().then((m) => m.CustomBlocks)) +const AuditLogs = dynamic(() => SECTION_MODULES['audit-logs']().then((m) => m.AuditLogs)) +const SSO = dynamic(() => SECTION_MODULES.sso().then((m) => m.SSO)) const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (m) => m.DataRetentionSettings - ) + SECTION_MODULES['data-retention']().then((m) => m.DataRetentionSettings) ) const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings) + SECTION_MODULES['data-drains']().then((m) => m.DataDrainsSettings) ) const OrganizationSecuritySettings = dynamic(() => - import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings) -) -const UsageMonitoring = dynamic(() => - import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring) -) -const Desktop = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop) -) -const Browser = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/browser/browser').then((m) => m.Browser) -) -const Terminal = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal').then( - (m) => m.Terminal - ) + SECTION_MODULES.security().then((m) => m.OrganizationSecuritySettings) ) +const UsageMonitoring = dynamic(() => SECTION_MODULES.usage().then((m) => m.UsageMonitoring)) +const Desktop = dynamic(() => SECTION_MODULES.desktop().then((m) => m.Desktop)) +const Browser = dynamic(() => SECTION_MODULES.browser().then((m) => m.Browser)) +const Terminal = dynamic(() => SECTION_MODULES.terminal().then((m) => m.Terminal)) const WhitelabelingSettings = dynamic(() => - import('@/ee/whitelabeling/components/whitelabeling-settings').then( - (m) => m.WhitelabelingSettings - ) + SECTION_MODULES.whitelabeling().then((m) => m.WhitelabelingSettings) ) interface SettingsPageProps { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index c79380f8966..d6ac817c5c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Plus, Wrench } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -11,7 +12,6 @@ import { customToolIdParam, customToolIdUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' -import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -22,6 +22,20 @@ import { import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useCustomTools } from '@/hooks/queries/custom-tools' +/** + * The editor reaches the code-generation and tag-dropdown tooling, which pulls in the block + * registry. Loaded on open (or on Add-chip intent), so the list does not download it. `loading` + * gives it its own boundary: opening is a plain state update, and the section page has no + * boundary of its own. + */ +const loadCustomToolDetail = () => + import( + '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' + ) +const CustomToolDetail = dynamic(() => loadCustomToolDetail().then((m) => m.CustomToolDetail), { + loading: () => null, +}) + export function CustomTools() { const params = useParams() const workspaceId = params.workspaceId as string @@ -68,6 +82,7 @@ export function CustomTools() { icon: Plus, variant: 'primary', onSelect: () => setIsCreating(true), + onPrefetch: () => void loadCustomToolDetail(), disabled: isLoading, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index bc7e01d96b6..5d1f48293c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -51,10 +51,13 @@ import { useUserProfile, } from '@/hooks/queries/user-profile' -const AuthorizedApps = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( - (module) => module.AuthorizedApps - ) +/** `loading` gives the view its own boundary; the section page has none to suspend into. */ +const AuthorizedApps = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps' + ).then((module) => module.AuthorizedApps), + { loading: () => null } ) const logger = createLogger('General') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx index f3b58312271..ed92a72a23a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx @@ -1,8 +1,48 @@ 'use client' +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useSettingsIntentHandler } from '@/components/settings/settings-navigation-provider' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { + resolveSettingsSection, + type SettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' +import { warmSettingsSection } from '@/app/workspace/[workspaceId]/settings/section-warmers' +function pendingSectionMeta(section: string) { + return resolveSettingsSection(section)?.meta ?? null +} + +/** + * Persists across every settings route — sections and detail pages alike — so it owns what the + * sidebar needs while any of them is open: the pending-section preview and the warmer that + * sidebar navigation intent runs. + */ export default function SettingsLayout({ children }: { children: React.ReactNode }) { useSettingsBeforeUnload() - return
{children}
+ const queryClient = useQueryClient() + const hostContext = useWorkspaceHostContext() + const workspaceId = hostContext.workspace.id + const billingOrganizationId = hostContext.hostOrganizationId + + useSettingsIntentHandler( + useCallback( + (section: string) => + warmSettingsSection( + queryClient, + { workspaceId, billingOrganizationId }, + section as SettingsSection + ), + [queryClient, workspaceId, billingOrganizationId] + ) + ) + + return ( +
+ {children} +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts b/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts new file mode 100644 index 00000000000..7e8bf2571b0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts @@ -0,0 +1,114 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { customToolsQueryOptions } from '@/hooks/queries/custom-tools' +import { + personalEnvironmentQueryOptions, + workspaceEnvironmentQueryOptions, +} from '@/hooks/queries/environment' +import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations' +import { mcpServersQueryOptions } from '@/hooks/queries/mcp' +import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' +import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' +import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' + +/** + * Each code-split section's module, shared by its `dynamic()` body and the navigation-intent + * warmer so a section's chunk can start loading on hover or press rather than after its route. + */ +export const SECTION_MODULES = { + admin: () => import('@/app/workspace/[workspaceId]/settings/components/admin/admin'), + apikeys: () => import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys'), + byok: () => import('@/app/workspace/[workspaceId]/settings/components/byok/byok'), + forks: () => import('@/ee/workspace-forking/components/forks'), + secrets: () => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets'), + 'connected-accounts': () => + import('@/ee/credential-groups/components/organization-connected-accounts'), + sandboxes: () => import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes'), + 'custom-tools': () => + import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools'), + inbox: () => import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox'), + mcp: () => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp'), + mothership: () => + import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership'), + 'recently-deleted': () => + import('@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'), + 'self-host': () => + import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host'), + billing: () => import('@/app/workspace/[workspaceId]/settings/components/billing/billing'), + teammates: () => import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates'), + organization: () => + import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management'), + 'workflow-mcp-servers': () => + import( + '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' + ), + 'access-control': () => import('@/ee/access-control/components/access-control'), + requests: () => import('@/ee/access-requests/components/access-requests-settings'), + 'custom-blocks': () => import('@/ee/custom-blocks/components/custom-blocks'), + 'audit-logs': () => import('@/ee/audit-logs/components/audit-logs'), + sso: () => import('@/ee/sso/components/sso-settings'), + 'data-retention': () => import('@/ee/data-retention/components/data-retention-settings'), + 'data-drains': () => import('@/ee/data-drains/components/data-drains-settings'), + security: () => import('@/components/settings/organization-security'), + usage: () => import('@/ee/organization-usage/components/usage-monitoring'), + desktop: () => import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop'), + browser: () => import('@/app/workspace/[workspaceId]/settings/components/browser/browser'), + terminal: () => import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal'), + whitelabeling: () => import('@/ee/whitelabeling/components/whitelabeling-settings'), +} satisfies Partial Promise>> + +/** Each section's first-content queries. */ +const SETTINGS_QUERY_WARMERS: Partial< + Record void> +> = { + secrets: (queryClient, { workspaceId }) => { + prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions()) + prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId)) + prefetchQueryOnIntent( + queryClient, + workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') + ) + }, + forks: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)), + teammates: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)), + apikeys: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), + 'custom-tools': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)), + mcp: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), + 'workflow-mcp-servers': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), + billing: (queryClient, { billingOrganizationId }) => { + if (billingOrganizationId) { + prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) + return + } + prefetchQueryOnIntent(queryClient, subscriptionDataQueryOptions(false)) + }, +} + +export interface SettingsQueryWarmContext { + workspaceId: string + billingOrganizationId: string | null +} + +/** + * Warms a section on sidebar navigation intent: its code-split chunk and its first-content + * queries start alongside the route payload instead of after the section mounts. + */ +export function warmSettingsSection( + queryClient: QueryClient, + context: SettingsQueryWarmContext, + section: SettingsSection +): void { + // A failed warm is not an error: the section's own load reports it if the navigation happens. + SECTION_MODULES[section as keyof typeof SECTION_MODULES]?.().catch(() => {}) + SETTINGS_QUERY_WARMERS[section]?.(queryClient, context) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts deleted file mode 100644 index cd3f68d822c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { QueryClient } from '@tanstack/react-query' -import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' -import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' -import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' -import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' - -const SETTINGS_QUERY_WARMERS: Partial< - Record void> -> = { - secrets: (queryClient, { workspaceId }) => - prefetchQueryOnIntent( - queryClient, - workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') - ), - billing: (queryClient, { billingOrganizationId }) => { - if (billingOrganizationId) { - prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) - return - } - prefetchQueryOnIntent(queryClient, subscriptionDataQueryOptions(false)) - }, -} - -export interface SettingsQueryWarmContext { - workspaceId: string - billingOrganizationId: string | null -} - -/** Starts approved first-content data within the workspace graph's enforced module budget. */ -export function warmSettingsSectionQuery( - queryClient: QueryClient, - context: SettingsQueryWarmContext, - section: SettingsSection -): boolean { - const warmer = SETTINGS_QUERY_WARMERS[section] - if (!warmer) return false - - warmer(queryClient, context) - return true -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 3593e96d90b..bc0731add9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -14,7 +14,6 @@ import { useScrollEdges, } from '@sim/emcn' import { ArrowUpRight, Building, ChevronLeft, Lock } from '@sim/emcn/icons' -import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' import { type DesktopSettingsSurface, @@ -24,19 +23,20 @@ import { ORGANIZATION_PLANE_UNIFIED_SECTIONS, } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' +import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { hasBrowserAgent, hasDesktopSettings, hasTerminal } from '@/lib/desktop' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { allNavigationItems, sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' -import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { @@ -55,31 +55,6 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' -/** - * Sections whose JS chunk is warmed when a row receives navigation intent. - * - * Deliberately not all of them, and the reason is the boundary audit rather than bundle weight. - * Each section is already `dynamic()`-imported by the settings panel, so naming it here adds an - * async-chunk reference, not parsed JS — but `check-tool-registry-boundary` counts `import()` - * as a graph edge on purpose, and listing all of them measured +126..+172 modules against six of - * the app's hottest route baselines. Code-splitting this sidebar does not help: measured, it - * moves exactly one module, because the audit follows the dynamic edge either way. - * - * These six predate this map and are already inside those baselines, so warming them is free. - * Widening it means either raising the ratchet on the routes it exists to protect, or teaching - * the audit to track async reach separately from initial-chunk weight. - * - * Every section still gets its route payload warmed through {@link SettingsIntentLink}. - */ -const SECTION_CHUNK_WARMERS: Partial Promise>> = { - general: () => import('@/app/workspace/[workspaceId]/settings/components/general/general'), - secrets: () => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets'), - billing: () => import('@/app/workspace/[workspaceId]/settings/components/billing/billing'), - desktop: () => import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop'), - browser: () => import('@/app/workspace/[workspaceId]/settings/components/browser/browser'), - terminal: () => import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal'), -} - interface SettingsSidebarProps { isCollapsed?: boolean showCollapsedTooltips?: boolean @@ -97,8 +72,6 @@ export function SettingsSidebar({ const pathname = usePathname() const router = useRouter() - const queryClient = useQueryClient() - const requestLeave = useSettingsDirtyStore((s) => s.requestLeave) const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave) const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave) @@ -129,7 +102,13 @@ export function SettingsSidebar({ const accessRequests = useWorkspaceAccessRequestFeatures() const accessRequestsEnabled = accessRequests.data?.enabled === true const forkingAvailable = useForkingAvailable(workspaceId) - const { canAdmin: canAdminWorkspace } = useUserPermissionsContext() + const { workspacePermissions, userPermissions } = useWorkspacePermissionsContext() + // The server-seeded viewer permission, read directly: the derived admin flag waits on the client + // session. Offline mode still withdraws admin; without the viewer field, fall back to the flag. + const viewerPermission = workspacePermissions?.viewer?.permissionType + const canAdminWorkspace = viewerPermission + ? viewerPermission === 'admin' && !userPermissions.isOfflineMode + : userPermissions.canAdmin const userId = session?.user?.id @@ -294,22 +273,15 @@ export function SettingsSidebar({ const segments = pathname?.split('/') ?? [] const settingsIndex = segments.indexOf('settings') - const activeSection: SettingsSection = + const routeSection: SettingsSection = settingsIndex !== -1 && segments[settingsIndex + 1] ? (segments[settingsIndex + 1] as SettingsSection) : 'general' + const { activeSection, navigateToSection } = usePendingSettingsSelection(routeSection) + const { signalIntent } = useSettingsNavigationState() const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() - const handleIntent = (section: SettingsSection) => { - void SECTION_CHUNK_WARMERS[section]?.() - warmSettingsSectionQuery( - queryClient, - { workspaceId, billingOrganizationId: hostContext.hostOrganizationId }, - section - ) - } - const handleBack = useCallback(() => { requestLeave(() => { router.push(popSettingsReturnUrl(`/workspace/${workspaceId}`)) @@ -482,15 +454,18 @@ export function SettingsSidebar({ permissionRestricted ? `${item.label}: access required` : undefined } className={itemClassName} - onIntent={() => !permissionRestricted && handleIntent(section)} + onIntent={() => !permissionRestricted && signalIntent(section)} onNavigate={(event) => { if (active) { event.preventDefault() return } - if (!useSettingsDirtyStore.getState().isDirty) return event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + if (!useSettingsDirtyStore.getState().isDirty) { + navigateToSection(section, href) + return + } + requestLeave(() => navigateToSection(section, href)) }} > {content} diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index 3ab7ed54623..7e7436c49ba 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -64,13 +64,14 @@ export async function executeQuickBooksWebhookIngress( const targets = await findWebhooksByRoutingKey(routingKey, payload.requestId, 'quickbooks') targetCount += targets.length - for (const { webhook, workflow } of targets) { + for (const { webhook, workflow, triggerBlockDeployed } of targets) { try { const result = await dispatchResolvedWebhookTarget(webhook, workflow, event, request, { requestId: payload.requestId, path: webhook.path ?? undefined, receivedAt: payload.receivedAt, triggerTimestampMs: Date.parse(event.time), + triggerBlockDeployed, }) if (result.outcome === 'queued') processed += 1 else if (result.outcome === 'ignored') ignored += 1 diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 95f7b1ba9a8..827cd55e684 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -72,6 +72,7 @@ import { SlackExecutionStreamController } from '@/lib/webhooks/slack-execution-s import { readSlackStreamResponseConfig } from '@/lib/webhooks/slack-stream-config' import { executeWorkflowCore, + type PreloadedExecutionEnvironment, wasExecutionFinalizedByCore, } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' @@ -665,6 +666,8 @@ export async function resolveWebhookExecutionProviderConfig< options?: WebhookEnvResolutionOptions & { onEnvironmentSnapshot?: (snapshot: EnvironmentResolutionSnapshot) => void | Promise actorUserId?: string + /** The same environment load, already started by a caller that had its identities early. */ + environment?: Promise } ): Promise }> { try { @@ -672,12 +675,12 @@ export async function resolveWebhookExecutionProviderConfig< return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId) } - const { onEnvironmentSnapshot, actorUserId, ...resolutionOptions } = options + const { onEnvironmentSnapshot, actorUserId, environment, ...resolutionOptions } = options if (onEnvironmentSnapshot && resolutionOptions.envVars === undefined) { - const snapshot = - actorUserId && workspaceId - ? await getExecutionEnvironment(userId, actorUserId, workspaceId) - : await getEffectiveEnvironmentSnapshot(userId, workspaceId) + const snapshot = await (environment ?? + (actorUserId && workspaceId + ? getExecutionEnvironment(userId, actorUserId, workspaceId) + : getEffectiveEnvironmentSnapshot(userId, workspaceId))) await onEnvironmentSnapshot(snapshot) resolutionOptions.envVars = { ...snapshot.personalDecrypted, @@ -838,6 +841,12 @@ async function executeWebhookJobInternal( try { return await withResourceOutboundScope({ workspaceId }, async () => { + /** + * The run's environment depends only on identities preprocessing already + * settled, so it loads alongside the workflow state rather than after it. + */ + const environment = getExecutionEnvironment(workflowRecord.userId, actorUserId, workspaceId) + environment.catch(() => {}) const workflowStatePromise = payload.deploymentVersionId ? loadWorkflowDeploymentVersionState( payload.workflowId, @@ -883,6 +892,7 @@ async function executeWebhookJobInternal( const secretScope = { userId: workflowRecord.userId, workspaceId } let resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope) + let preloadedEnvironment: PreloadedExecutionEnvironment | undefined const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig( webhookRecord, payload.provider, @@ -896,7 +906,14 @@ async function executeWebhookJobInternal( * selection derived from the workflow owner. */ actorUserId, + environment, onEnvironmentSnapshot: async (secretEnvironment) => { + preloadedEnvironment = { + personalUserId: workflowRecord.userId, + workspaceUserId: actorUserId, + workspaceId, + snapshot: secretEnvironment, + } try { resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ personalEncrypted: secretEnvironment.personalEncrypted, @@ -1151,6 +1168,7 @@ async function executeWebhookJobInternal( loggingSession, trustedInitialResolvedSecretTraceProvenance: resolvedSecretTraceRegistry.exportProvenanceForValue(triggerInput), + preloadedEnvironment, includeFileBase64: false, base64MaxBytes: undefined, abortSignal: timeoutController.signal, diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index d9601d2f223..cbd43a3b95f 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -866,6 +866,7 @@ async function runWorkflowAndWriteTerminal( triggerType: 'workflow', checkDeployment: false, checkRateLimit: false, + includeActorSubscription: true, skipConcurrencyReservation: true, logPreprocessingErrors: false, billingAttribution, diff --git a/apps/sim/blocks/blocks/mailgun.ts b/apps/sim/blocks/blocks/mailgun.ts index 056870dc0bd..f677e28c4b2 100644 --- a/apps/sim/blocks/blocks/mailgun.ts +++ b/apps/sim/blocks/blocks/mailgun.ts @@ -70,6 +70,16 @@ export const MailgunBlock: BlockConfig = { placeholder: 'Enter your Mailgun API key', required: true, }, + { + id: 'region', + title: 'Region', + type: 'dropdown', + options: [ + { label: 'US', id: 'us' }, + { label: 'EU', id: 'eu' }, + ], + value: () => 'us', + }, { id: 'domain', title: 'Domain', @@ -405,6 +415,7 @@ Return ONLY the JSON object - no explanations or markdown.`, inputs: { operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'Mailgun API key' }, + region: { type: 'string', description: 'Mailgun account region: "us" or "eu"' }, domain: { type: 'string', description: 'Mailgun domain' }, // Message inputs from: { type: 'string', description: 'Sender email address' }, diff --git a/apps/sim/blocks/blocks/planetscale.ts b/apps/sim/blocks/blocks/planetscale.ts new file mode 100644 index 00000000000..aa6013ae855 --- /dev/null +++ b/apps/sim/blocks/blocks/planetscale.ts @@ -0,0 +1,1187 @@ +import { PlanetScaleIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' + +function optionalBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') return undefined + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + throw new Error('Expected a boolean') +} +function optionalNumber(value: unknown): number | undefined { + return value === undefined || value === null || value === '' ? undefined : Number(value) +} +export const PlanetScaleBlock: BlockConfig = { + type: 'planetscale', + name: 'PlanetScale', + description: 'Manage PlanetScale databases, branches, backups, and deploy requests', + longDescription: + '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.', + docsLink: 'https://docs.sim.ai/integrations/planetscale', + category: 'tools', + integrationType: IntegrationType.Databases, + authMode: AuthMode.ApiKey, + bgColor: '#111111', + icon: PlanetScaleIcon, + canvasPresentation: { + defaultTitle: 'PlanetScale', + sentences: { + byOperation: { + list_databases: ['List databases', { text: 'in', field: 'organization', core: true }], + get_database: [ + { text: 'Read database', field: ['databaseSelector', 'manualDatabase'], core: true }, + ], + list_branches: [ + { text: 'List branches in', field: ['databaseSelector', 'manualDatabase'], core: true }, + ], + get_branch: [{ text: 'Get branch', field: ['branchSelector', 'manualBranch'], core: true }], + create_branch: [ + { text: 'Create branch', field: 'name', core: true }, + { text: 'from', field: ['parentBranchSelector', 'manualParentBranch'] }, + ], + delete_branch: [ + { text: 'Delete branch', field: ['branchSelector', 'manualBranch'], core: true }, + ], + list_backups: [ + { text: 'List backups for', field: ['branchSelector', 'manualBranch'], core: true }, + ], + get_backup: [ + { text: 'Read backup', field: ['backupIdSelector', 'manualBackupId'], core: true }, + ], + create_backup: [ + { text: 'Create backup for', field: ['branchSelector', 'manualBranch'], core: true }, + ], + list_deploy_requests: [ + { + text: 'List deploy requests in', + field: ['databaseSelector', 'manualDatabase'], + core: true, + }, + ], + get_deploy_request: [ + { + text: 'Get deploy request', + field: ['deployRequestNumberSelector', 'manualDeployRequestNumber'], + core: true, + }, + ], + create_deploy_request: [ + { + text: 'Create deploy request from', + field: ['branchSelector', 'manualBranch'], + core: true, + }, + { text: 'into', field: ['intoBranchSelector', 'manualIntoBranch'], core: true }, + ], + review_deploy_request: [ + { + text: 'Review deploy request', + field: ['deployRequestNumberSelector', 'manualDeployRequestNumber'], + core: true, + }, + ], + queue_deploy_request: [ + { + text: 'Queue deploy request', + field: ['deployRequestNumberSelector', 'manualDeployRequestNumber'], + core: true, + }, + ], + close_deploy_request: [ + { + text: 'Close deploy request', + field: ['deployRequestNumberSelector', 'manualDeployRequestNumber'], + core: true, + }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'list_databases', label: 'List Databases' }, + { id: 'get_database', label: 'Get Database' }, + { id: 'list_branches', label: 'List Branches' }, + { id: 'get_branch', label: 'Get Branch' }, + { id: 'create_branch', label: 'Create Branch' }, + { id: 'delete_branch', label: 'Delete Branch' }, + { id: 'list_backups', label: 'List Backups' }, + { id: 'get_backup', label: 'Get Backup' }, + { id: 'create_backup', label: 'Create Backup' }, + { id: 'list_deploy_requests', label: 'List Deploy Requests (Vitess)' }, + { id: 'get_deploy_request', label: 'Get Deploy Request (Vitess)' }, + { id: 'create_deploy_request', label: 'Create Deploy Request (Vitess)' }, + { id: 'review_deploy_request', label: 'Review Deploy Request (Vitess)' }, + { id: 'queue_deploy_request', label: 'Queue Deploy Request (Vitess)' }, + { id: 'close_deploy_request', label: 'Close Deploy Request (Vitess)' }, + ], + value: () => 'list_databases', + }, + { + id: 'serviceTokenId', + title: 'Service Token ID', + type: 'short-input', + placeholder: 'Enter your service token ID', + password: true, + required: true, + paramVisibility: 'user-only', + }, + { + id: 'serviceToken', + title: 'Service Token', + type: 'short-input', + placeholder: 'Enter your service token', + password: true, + required: true, + paramVisibility: 'user-only', + }, + { + id: 'organization', + title: 'Organization', + type: 'short-input', + placeholder: 'Enter your organization slug', + required: true, + }, + { + id: 'q', + title: 'Search', + type: 'short-input', + placeholder: 'Filter by name', + condition: { field: 'operation', value: ['list_databases', 'list_branches'] }, + required: false, + mode: 'advanced', + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '1', + condition: { + field: 'operation', + value: ['list_databases', 'list_branches', 'list_backups', 'list_deploy_requests'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'perPage', + title: 'Per Page', + type: 'short-input', + placeholder: '25', + condition: { + field: 'operation', + value: ['list_databases', 'list_branches', 'list_backups', 'list_deploy_requests'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'databaseSelector', + title: 'Database', + type: 'file-selector', + canonicalParamId: 'database', + selectorKey: 'planetscale.databases', + dependsOn: ['serviceTokenId', 'serviceToken', 'organization'], + placeholder: 'Select database', + mode: 'basic', + required: { + field: 'operation', + value: [ + 'get_database', + 'list_branches', + 'create_branch', + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_database', + 'list_branches', + 'create_branch', + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + }, + { + id: 'manualDatabase', + title: 'Database', + type: 'short-input', + placeholder: 'Enter database name', + canonicalParamId: 'database', + mode: 'advanced', + required: { + field: 'operation', + value: [ + 'get_database', + 'list_branches', + 'create_branch', + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_database', + 'list_branches', + 'create_branch', + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + }, + { + id: 'production', + title: 'Production', + type: 'dropdown', + condition: { field: 'operation', value: ['list_branches', 'list_backups'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'safeMigrations', + title: 'Safe Migrations', + type: 'dropdown', + condition: { field: 'operation', value: ['list_branches'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'order', + title: 'Order', + type: 'dropdown', + condition: { field: 'operation', value: ['list_branches'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Asc', id: 'asc' }, + { label: 'Desc', id: 'desc' }, + ], + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + placeholder: 'Enter a name', + condition: { field: 'operation', value: ['create_branch', 'create_backup'] }, + required: { field: 'operation', value: ['create_branch'] }, + }, + { + id: 'deletionProtected', + title: 'Deletion Protected', + type: 'dropdown', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'parentBranchSelector', + title: 'Parent Branch', + type: 'file-selector', + canonicalParamId: 'parentBranch', + selectorKey: 'planetscale.branches', + dependsOn: ['serviceTokenId', 'serviceToken', 'organization', 'databaseSelector'], + placeholder: 'Select parent branch', + mode: 'basic', + required: false, + condition: { field: 'operation', value: ['create_branch'] }, + }, + { + id: 'manualParentBranch', + title: 'Parent Branch Name', + type: 'short-input', + placeholder: 'Defaults to the default branch', + canonicalParamId: 'parentBranch', + mode: 'advanced', + required: false, + condition: { field: 'operation', value: ['create_branch'] }, + }, + { + id: 'backupIdSelector', + title: 'Backup', + type: 'file-selector', + canonicalParamId: 'backupId', + selectorKey: 'planetscale.backups', + dependsOn: { + all: ['serviceTokenId', 'serviceToken', 'organization', 'databaseSelector'], + // Database enables the default-parent lookup; branch dependencies retain explicit context. + any: ['databaseSelector', 'branchSelector', 'parentBranchSelector'], + }, + placeholder: 'Select backup', + mode: 'basic', + required: { field: 'operation', value: ['get_backup'] }, + condition: { field: 'operation', value: ['create_branch', 'get_backup'] }, + }, + { + id: 'manualBackupId', + title: 'Backup ID', + type: 'short-input', + placeholder: 'Enter backup ID', + canonicalParamId: 'backupId', + mode: 'advanced', + required: { field: 'operation', value: ['get_backup'] }, + condition: { field: 'operation', value: ['create_branch', 'get_backup'] }, + }, + { + id: 'region', + title: 'Region', + type: 'short-input', + placeholder: 'Defaults to the database region', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + }, + { + id: 'restorePoint', + title: 'Restore Point', + type: 'short-input', + placeholder: '2023-01-01T00:00:00Z', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: + 'Convert the described date and time to an ISO 8601 UTC timestamp such as 2023-01-01T00:00:00Z. Preserve existing workflow or environment references unchanged. Return ONLY the timestamp or reference.', + placeholder: 'Describe the date and time', + }, + }, + { + id: 'replicas', + title: 'Replicas', + type: 'short-input', + placeholder: '0 to 8', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + }, + { + id: 'seedData', + title: 'Seed Data', + type: 'dropdown', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Last Successful Backup', id: 'last_successful_backup' }, + ], + }, + { + id: 'clusterSize', + title: 'Cluster Size', + type: 'short-input', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + placeholder: 'Required for backup restores, e.g. PS_10', + }, + { + id: 'majorVersion', + title: 'Major Version', + type: 'short-input', + placeholder: 'Defaults to the parent branch version', + condition: { field: 'operation', value: ['create_branch'] }, + required: false, + mode: 'advanced', + }, + { + id: 'branchSelector', + title: 'Branch', + type: 'file-selector', + canonicalParamId: 'branch', + selectorKey: 'planetscale.branches', + dependsOn: ['serviceTokenId', 'serviceToken', 'organization', 'databaseSelector'], + placeholder: 'Select branch', + mode: 'basic', + required: { + field: 'operation', + value: [ + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'create_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + ], + }, + }, + { + id: 'manualBranch', + title: 'Branch Name', + type: 'short-input', + placeholder: 'Enter branch name', + canonicalParamId: 'branch', + mode: 'advanced', + required: { + field: 'operation', + value: [ + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'create_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_branch', + 'delete_branch', + 'list_backups', + 'create_backup', + 'get_backup', + 'list_deploy_requests', + 'create_deploy_request', + ], + }, + }, + { + id: 'deleteDescendants', + title: 'Delete Descendants', + type: 'dropdown', + condition: { field: 'operation', value: ['delete_branch'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'all', + title: 'Include Deleted Backups', + type: 'dropdown', + condition: { field: 'operation', value: ['list_backups'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'backupState', + title: 'Backup State', + type: 'dropdown', + condition: { field: 'operation', value: ['list_backups'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Pending', id: 'pending' }, + { label: 'Running', id: 'running' }, + { label: 'Success', id: 'success' }, + { label: 'Failed', id: 'failed' }, + { label: 'Canceled', id: 'canceled' }, + { label: 'Ignored', id: 'ignored' }, + ], + }, + { + id: 'policy', + title: 'Policy', + type: 'short-input', + placeholder: 'Enter backup policy ID', + condition: { field: 'operation', value: ['list_backups'] }, + required: false, + mode: 'advanced', + }, + { + id: 'from', + title: 'From', + type: 'short-input', + placeholder: '2023-01-01T00:00:00Z', + condition: { field: 'operation', value: ['list_backups'] }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: + 'Convert the described date and time to an ISO 8601 UTC timestamp such as 2023-01-01T00:00:00Z. Preserve existing workflow or environment references unchanged. Return ONLY the timestamp or reference.', + placeholder: 'Describe the date and time', + }, + }, + { + id: 'to', + title: 'To', + type: 'short-input', + placeholder: '2023-01-31T23:59:59Z', + condition: { field: 'operation', value: ['list_backups'] }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: + 'Convert the described date and time to an ISO 8601 UTC timestamp such as 2023-01-01T00:00:00Z. Preserve existing workflow or environment references unchanged. Return ONLY the timestamp or reference.', + placeholder: 'Describe the date and time', + }, + }, + { + id: 'runningAt', + title: 'Running At', + type: 'short-input', + placeholder: '2023-01-01T00:00:00Z..2023-01-31T23:59:59Z', + condition: { field: 'operation', value: ['list_backups', 'list_deploy_requests'] }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: + 'Convert the described time interval to two ISO 8601 UTC timestamps separated by .., such as 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z. Preserve existing workflow or environment references unchanged. Return ONLY the start..end interval or reference.', + placeholder: 'Describe the time interval', + }, + }, + { + id: 'retentionUnit', + title: 'Retention Unit', + type: 'dropdown', + condition: { field: 'operation', value: ['create_backup'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Hour', id: 'hour' }, + { label: 'Day', id: 'day' }, + { label: 'Week', id: 'week' }, + { label: 'Month', id: 'month' }, + { label: 'Year', id: 'year' }, + ], + }, + { + id: 'retentionValue', + title: 'Retention Value', + type: 'short-input', + placeholder: '1 to 1000', + condition: { field: 'operation', value: ['create_backup'] }, + required: false, + mode: 'advanced', + }, + { + id: 'emergency', + title: 'Emergency', + type: 'dropdown', + condition: { field: 'operation', value: ['create_backup'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'deployRequestState', + title: 'Deploy Request State', + type: 'short-input', + placeholder: 'open, closed, or deployed', + condition: { field: 'operation', value: ['list_deploy_requests'] }, + required: false, + mode: 'advanced', + }, + { + id: 'intoBranchSelector', + title: 'Target Branch', + type: 'file-selector', + canonicalParamId: 'intoBranch', + selectorKey: 'planetscale.branches', + dependsOn: ['serviceTokenId', 'serviceToken', 'organization', 'databaseSelector'], + placeholder: 'Select target branch', + mode: 'basic', + required: { field: 'operation', value: ['create_deploy_request'] }, + condition: { field: 'operation', value: ['list_deploy_requests', 'create_deploy_request'] }, + }, + { + id: 'manualIntoBranch', + title: 'Target Branch Name', + type: 'short-input', + placeholder: 'Enter target branch name', + canonicalParamId: 'intoBranch', + mode: 'advanced', + required: { field: 'operation', value: ['create_deploy_request'] }, + condition: { field: 'operation', value: ['list_deploy_requests', 'create_deploy_request'] }, + }, + { + id: 'deployedAt', + title: 'Deployed At', + type: 'short-input', + placeholder: '2023-01-01T00:00:00Z..2023-01-31T23:59:59Z', + condition: { field: 'operation', value: ['list_deploy_requests'] }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: + 'Convert the described time interval to two ISO 8601 UTC timestamps separated by .., such as 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z. Preserve existing workflow or environment references unchanged. Return ONLY the start..end interval or reference.', + placeholder: 'Describe the time interval', + }, + }, + { + id: 'notes', + title: 'Notes', + type: 'long-input', + placeholder: 'Describe the schema change', + condition: { field: 'operation', value: ['create_deploy_request'] }, + required: false, + }, + { + id: 'autoCutover', + title: 'Auto Cutover', + type: 'dropdown', + condition: { field: 'operation', value: ['create_deploy_request'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'autoDeleteBranch', + title: 'Auto Delete Branch', + type: 'dropdown', + condition: { field: 'operation', value: ['create_deploy_request'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'deployRequestNumberSelector', + title: 'Deploy Request', + type: 'file-selector', + canonicalParamId: 'deployRequestNumber', + selectorKey: 'planetscale.deployRequests', + dependsOn: ['serviceTokenId', 'serviceToken', 'organization', 'databaseSelector'], + placeholder: 'Select deploy request', + mode: 'basic', + required: { + field: 'operation', + value: [ + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + }, + { + id: 'manualDeployRequestNumber', + title: 'Deploy Request Number', + type: 'short-input', + placeholder: 'Enter deploy request number', + canonicalParamId: 'deployRequestNumber', + mode: 'advanced', + required: { + field: 'operation', + value: [ + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_deploy_request', + 'close_deploy_request', + 'queue_deploy_request', + 'review_deploy_request', + ], + }, + }, + { + id: 'instantDdl', + title: 'Instant DDL', + type: 'dropdown', + condition: { field: 'operation', value: ['queue_deploy_request'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Default', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'reviewState', + title: 'Review State', + type: 'dropdown', + condition: { field: 'operation', value: ['review_deploy_request'] }, + required: false, + options: [ + { label: 'Default', id: '' }, + { label: 'Commented', id: 'commented' }, + { label: 'Approved', id: 'approved' }, + ], + value: () => 'commented', + }, + { + id: 'body', + title: 'Body', + type: 'long-input', + placeholder: 'Add review comments', + condition: { field: 'operation', value: ['review_deploy_request'] }, + required: false, + }, + ], + inputs: { + serviceTokenId: { type: 'string', description: 'Service token ID' }, + serviceToken: { type: 'string', description: 'Service token secret' }, + organization: { type: 'string', description: 'PlanetScale organization slug' }, + q: { type: 'string', description: 'Search databases or branches by name' }, + page: { + type: 'number', + description: 'If provided, specifies the page offset of returned results', + }, + perPage: { + type: 'number', + description: 'If provided, specifies the number of returned results', + }, + database: { + type: 'string', + description: 'PlanetScale database name', + }, + production: { type: 'boolean', description: 'Filter branches or backups by production status' }, + safeMigrations: { + type: 'boolean', + description: 'Filter branches by safe migrations (DDL protection)', + }, + order: { type: 'string', description: 'Order branches by created_at time' }, + name: { type: 'string', description: 'Name for the new branch or backup' }, + deletionProtected: { + type: 'boolean', + description: 'Whether deletion protection is enabled for the branch', + }, + parentBranch: { + type: 'string', + description: + "The name of the parent branch. Defaults to the database's default branch if not provided.", + }, + backupId: { type: 'string', description: 'The ID for the backup' }, + region: { + type: 'string', + description: + 'The region to create the branch in. If not provided, the branch will be created in the default region for its database.', + }, + restorePoint: { + type: 'string', + description: + 'Restore from a point-in-time recovery timestamp (e.g. 2023-01-01T00:00:00Z). Available only for PostgreSQL databases.', + }, + replicas: { + type: 'number', + description: + "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: { + type: 'string', + description: + "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\u2122 being enabled for the branch.", + }, + clusterSize: { + type: 'string', + description: + 'The database cluster size. Required when backupId is provided, optional otherwise. Options: PS_10, PS_20, PS_40, ..., PS_2800', + }, + majorVersion: { + type: 'string', + description: + "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.", + }, + branch: { + type: 'string', + description: 'Branch name for the selected operation', + }, + deleteDescendants: { + type: 'boolean', + description: 'If true, recursively delete all descendant branches along with this branch', + }, + all: { type: 'boolean', description: 'Whether to include all backups, including deleted ones' }, + backupState: { type: 'string', description: 'Filter backups by state' }, + policy: { type: 'string', description: 'Filter backups by backup policy ID' }, + from: { + type: 'string', + description: 'Filter backups started after this date (e.g. 2023-01-01T00:00:00Z)', + }, + to: { + type: 'string', + description: 'Filter backups started before this date (e.g. 2023-01-31T23:59:59Z)', + }, + runningAt: { + type: 'string', + description: + 'Filter backups or deploy requests by when they were running. (e.g. 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z)', + }, + retentionUnit: { type: 'string', description: 'Unit for the retention period of the backup' }, + retentionValue: { + type: 'number', + description: + '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: { + type: 'boolean', + description: + 'Whether the backup is an immediate backup that may affect database performance. Emergency backups are only supported for PostgreSQL databases.', + }, + deployRequestState: { + type: 'string', + description: 'Filter by state of the deploy request (open, closed, deployed)', + }, + intoBranch: { + type: 'string', + description: 'The name of the branch the deploy request will be merged into', + }, + deployedAt: { + type: 'string', + description: + 'Filter deploy requests by the date they were deployed. (e.g. 2023-01-01T00:00:00Z..2023-01-31T23:59:59Z)', + }, + notes: { type: 'string', description: 'Notes about the deploy request' }, + autoCutover: { + type: 'boolean', + description: + '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: { + type: 'boolean', + description: + 'Whether or not to enable auto_delete_branch for the deploy request. When enabled, will delete the branch once the DR successfully completes.', + }, + deployRequestNumber: { type: 'number', description: 'The number of the deploy request' }, + instantDdl: { + type: 'boolean', + description: 'Whether or not to deploy the request with instant DDL. Defaults to false.', + }, + reviewState: { + type: 'string', + description: + 'Whether the review is a comment or approval. Service tokens must have corresponding access (either `approve_deploy_request` or `review_deploy_request`)', + }, + body: { type: 'string', description: 'Deploy request review comments' }, + }, + tools: { + access: [ + 'planetscale_list_databases', + 'planetscale_get_database', + 'planetscale_list_branches', + 'planetscale_get_branch', + 'planetscale_create_branch', + 'planetscale_delete_branch', + 'planetscale_list_backups', + 'planetscale_get_backup', + 'planetscale_create_backup', + 'planetscale_list_deploy_requests', + 'planetscale_get_deploy_request', + 'planetscale_create_deploy_request', + 'planetscale_review_deploy_request', + 'planetscale_queue_deploy_request', + 'planetscale_close_deploy_request', + ], + config: { + tool: (params) => `planetscale_${params.operation}`, + params: (params) => ({ + ...params, + page: optionalNumber(params.page), + perPage: optionalNumber(params.perPage), + production: optionalBoolean(params.production), + safeMigrations: optionalBoolean(params.safeMigrations), + deletionProtected: optionalBoolean(params.deletionProtected), + replicas: optionalNumber(params.replicas), + deleteDescendants: optionalBoolean(params.deleteDescendants), + all: optionalBoolean(params.all), + retentionValue: optionalNumber(params.retentionValue), + emergency: optionalBoolean(params.emergency), + autoCutover: optionalBoolean(params.autoCutover), + autoDeleteBranch: optionalBoolean(params.autoDeleteBranch), + deployRequestNumber: optionalNumber(params.deployRequestNumber), + instantDdl: optionalBoolean(params.instantDdl), + state: + params.operation === 'list_backups' + ? params.backupState + : params.operation === 'list_deploy_requests' + ? params.deployRequestState + : params.reviewState, + }), + }, + }, + outputs: { + databases: { + type: 'array', + description: + 'Databases (id, name, kind, state, ready, defaultBranch, branchesCount, deletionProtected, requireApprovalForDeploy, createdAt, updatedAt, htmlUrl)', + condition: { field: 'operation', value: ['list_databases'] }, + }, + database: { + type: 'json', + description: + 'Database (id, name, kind, state, ready, defaultBranch, branchesCount, deletionProtected, requireApprovalForDeploy, createdAt, updatedAt, htmlUrl)', + condition: { field: 'operation', value: ['get_database'] }, + }, + branches: { + type: 'array', + description: + 'Branches (id, name, kind, state, ready, production, safeMigrations, deletionProtected, parentBranch, createdAt, updatedAt, htmlUrl)', + condition: { field: 'operation', value: ['list_branches'] }, + }, + branch: { + type: 'json', + description: + 'Branch (id, name, kind, state, ready, production, safeMigrations, deletionProtected, parentBranch, createdAt, updatedAt, htmlUrl)', + condition: { field: 'operation', value: ['get_branch', 'create_branch'] }, + }, + deleted: { + type: 'boolean', + description: 'Whether the selected branch was deleted', + condition: { field: 'operation', value: ['delete_branch'] }, + }, + backups: { + type: 'array', + description: + 'Backups (id, name, state, size, estimatedStorageCost, protected, createdAt, updatedAt, startedAt, completedAt, expiresAt)', + condition: { field: 'operation', value: ['list_backups'] }, + }, + backup: { + type: 'json', + description: + 'Backup (id, name, state, size, estimatedStorageCost, protected, createdAt, updatedAt, startedAt, completedAt, expiresAt)', + condition: { field: 'operation', value: ['get_backup', 'create_backup'] }, + }, + deployRequests: { + type: 'array', + description: + 'Vitess deploy requests (id, number, branch, intoBranch, state, deploymentState, approved, numComments, notes, createdAt, updatedAt, closedAt, deployedAt, htmlUrl)', + condition: { field: 'operation', value: ['list_deploy_requests'] }, + }, + deployRequest: { + type: 'json', + description: + 'Vitess deploy request (id, number, branch, intoBranch, state, deploymentState, approved, numComments, notes, createdAt, updatedAt, closedAt, deployedAt, htmlUrl)', + condition: { + field: 'operation', + value: [ + 'get_deploy_request', + 'create_deploy_request', + 'queue_deploy_request', + 'close_deploy_request', + ], + }, + }, + review: { + type: 'json', + description: 'Deploy-request review (id, state, body, createdAt, updatedAt)', + condition: { field: 'operation', value: ['review_deploy_request'] }, + }, + pagination: { + type: 'json', + description: 'Pagination (currentPage, perPage, nextPage, totalCount, totalPages)', + condition: { + field: 'operation', + value: ['list_databases', 'list_branches', 'list_backups', 'list_deploy_requests'], + }, + }, + }, +} +export const PlanetScaleBlockMeta = { + tags: ['cloud', 'ci-cd', 'automation'], + url: 'https://planetscale.com', + templates: [ + { + icon: PlanetScaleIcon, + title: 'Database inventory', + prompt: + 'Build a workflow. On a daily schedule, list PlanetScale databases and summarize their readiness and branch counts.', + modules: ['workflows', 'scheduled'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: PlanetScaleIcon, + title: 'Feature branch setup', + prompt: + 'Build a workflow. When a GitHub pull request opens, create a PlanetScale development branch from its configured parent and report the new branch state.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + alsoIntegrations: ['github'], + }, + { + icon: PlanetScaleIcon, + title: 'Pre-deployment backup', + prompt: + 'Build a workflow. Before an approved release, create a backup of the selected PlanetScale branch and return its ID and current state.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: PlanetScaleIcon, + title: 'Backup health report', + prompt: + 'Build a workflow. On a daily schedule, list backups for a PlanetScale branch and summarize successful and failed backups.', + modules: ['workflows', 'scheduled'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: PlanetScaleIcon, + title: 'Restore rehearsal', + prompt: + 'Build a workflow. On a scheduled restore drill, select a successful backup, create a separate branch from it, and report the branch readiness. Run subsequent checks with the appropriate SQL integration.', + modules: ['workflows', 'scheduled'], + category: 'engineering', + tags: ['automation'], + alsoIntegrations: ['mysql', 'postgresql'], + }, + { + icon: PlanetScaleIcon, + title: 'Schema release request', + prompt: + 'Build a workflow. When a release is ready for schema review, create a Vitess deploy request from the development branch into the target branch and return its number and URL.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: PlanetScaleIcon, + title: 'Deployment status report', + prompt: + 'Build a workflow. On a schedule, list Vitess deploy requests and report their deployment states to Slack.', + modules: ['workflows', 'scheduled'], + category: 'engineering', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: PlanetScaleIcon, + title: 'Review and queue deployment', + prompt: + 'Build a workflow. After explicit release approval, approve a Vitess deploy request, queue it, and report its current deployment state.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + }, + ], + skills: [ + { + name: 'inspect-databases', + description: 'Inventory PlanetScale databases and branches before planning maintenance.', + content: + '# Inspect Databases\n\n## Steps\nList databases in the organization. Get details for the selected database. List its branches and summarize readiness and safe-migration settings.\n\n## Output\nReturn resource identifiers and current states.\n\n## Reference\nhttps://planetscale.com/docs/api/reference/list_databases', + }, + { + name: 'prepare-schema-release', + description: 'Create and review a Vitess deploy request for a prepared schema change.', + content: + '# Prepare Schema Release\n\n## Steps\nCreate a deploy request from the prepared source branch into the target branch. Get its state. Present the number and URL for approval. Only review or queue after explicit authorization.\n\n## Output\nReturn resource identifiers and current states.\n\n## Reference\nhttps://planetscale.com/docs/vitess/schema-changes/deploy-requests', + }, + { + name: 'check-backup-health', + description: 'Inspect branch backups and report their completion state.', + content: + '# Check Backup Health\n\n## Steps\nList backups for the chosen branch. Get details for the selected backup. Report completion state, size, and expiration.\n\n## Output\nReturn resource identifiers and current states.\n\n## Reference\nhttps://planetscale.com/docs/vitess/backups', + }, + { + name: 'rehearse-backup-restore', + description: 'Restore a successful backup into a separate branch for a recovery drill.', + content: + '# Rehearse Backup Restore\n\n## Steps\nList backups and choose a successful backup after confirming scope. Confirm a supported target cluster size, then create a new branch with the backup ID, parent branch, and cluster size. Get branch readiness. Do not delete the restored branch without authorization.\n\n## Output\nReturn resource identifiers and current states.\n\n## Reference\nhttps://planetscale.com/learn/courses/vitess/vitess-at-planetscale', + }, + { + name: 'track-schema-deployment', + description: 'Report the current state of queued Vitess schema deployments.', + content: + '# Track Schema Deployment\n\n## Steps\nList deploy requests. Get the selected request. Report its current deployment state and URL. This skill does not wait for completion.\n\n## Output\nReturn resource identifiers and current states.\n\n## Reference\nhttps://planetscale.com/docs/vitess/schema-changes/deploy-requests', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/stripe.ts b/apps/sim/blocks/blocks/stripe.ts index 505aed577e1..c306b1288e2 100644 --- a/apps/sim/blocks/blocks/stripe.ts +++ b/apps/sim/blocks/blocks/stripe.ts @@ -708,6 +708,30 @@ export const StripeBlock: BlockConfig = { }, required: true, }, + { + id: 'starting_after', + title: 'Starting After', + type: 'short-input', + placeholder: 'Last subscription ID from the previous page', + condition: { field: 'operation', value: 'list_subscriptions' }, + mode: 'advanced', + }, + { + id: 'ending_before', + title: 'Ending Before', + type: 'short-input', + placeholder: 'First subscription ID from the current page', + condition: { field: 'operation', value: 'list_subscriptions' }, + mode: 'advanced', + }, + { + id: 'page', + title: 'Search Page', + type: 'short-input', + placeholder: 'Token from metadata.next_page; omit for the first page', + condition: { field: 'operation', value: 'search_subscriptions' }, + mode: 'advanced', + }, // Additional filters for specific list operations { id: 'status', @@ -976,6 +1000,9 @@ export const StripeBlock: BlockConfig = { recurring: { type: 'json', description: 'Recurring billing configuration' }, // List/Search inputs limit: { type: 'number', description: 'Maximum results to return' }, + starting_after: { type: 'string', description: 'Next-page subscription ID cursor' }, + ending_before: { type: 'string', description: 'Previous-page subscription ID cursor' }, + page: { type: 'string', description: 'Subscription search token from metadata.next_page' }, query: { type: 'string', description: 'Search query' }, status: { type: 'string', description: 'Status filter' }, type: { type: 'string', description: 'Event type filter' }, @@ -1096,19 +1123,20 @@ export const StripeBlockMeta = { description: 'Create and confirm a Stripe payment intent to collect a charge from a customer.', content: - '# Collect Payment\n\nCharge a customer by creating and confirming a payment intent.\n\n## Steps\n1. Run Create Payment Intent with the amount, currency, and customer.\n2. Confirm the intent with Confirm Payment Intent, or Capture Payment Intent if it was created for manual capture.\n3. If a charge needs to be aborted, run Cancel Payment Intent instead.\n\n## Output\nReturn the payment intent ID, its status (succeeded, requires action, or canceled), and the captured amount.', + '# Collect Payment\n\nCharge a customer by creating and confirming a payment intent.\n\n## Steps\n1. Run Create Payment Intent with the amount, currency, and customer.\n2. Confirm the intent with Confirm Payment Intent and a payment method. If the confirmed intent has status requires_capture, run Capture Payment Intent to collect the authorized funds.\n3. If a charge needs to be aborted, run Cancel Payment Intent instead.\n\n## Output\nReturn the payment intent ID, its status (succeeded, requires action, or canceled), and the captured amount.', }, { name: 'manage-subscription', - description: 'Create, update, pause, or cancel a Stripe subscription for a customer.', + description: + 'Create a Stripe subscription, manage its cancellation, or resume a paused subscription.', content: - '# Manage Subscription\n\nHandle the lifecycle of a recurring subscription.\n\n## Steps\n1. To start a subscription, run Create Subscription with the customer and price items.\n2. To change a plan, run Update Subscription with the new items. To pause and later restart, use Cancel Subscription or Resume Subscription as appropriate.\n3. Confirm the current state with Retrieve Subscription.\n\n## Output\nReturn the subscription ID, its status, current period end, and the plan items, and note exactly what changed.', + '# Manage Subscription\n\nHandle the lifecycle of a recurring subscription.\n\n## Steps\n1. To start a subscription, run Create Subscription with the customer and price items.\n2. Use Update Subscription with cancel_at_period_end to schedule or reverse a cancellation before it takes effect. Use Cancel Subscription only for immediate cancellation. Resume Subscription applies only to a paused subscription; it does not restart a canceled subscription.\n3. Confirm the current state with Retrieve Subscription.\n\n## Output\nReturn the subscription ID, its status, and the returned subscription items, and note exactly what changed.', }, { name: 'issue-invoice', description: 'Create, finalize, and send a Stripe invoice to a customer, then track payment.', content: - '# Issue Invoice\n\nBill a customer with a Stripe invoice.\n\n## Steps\n1. Run Create Invoice for the customer with the line items.\n2. Run Finalize Invoice to lock it, then Send Invoice to deliver it to the customer.\n3. Track payment with Retrieve Invoice, or run Pay Invoice to charge a saved payment method. Use Void Invoice to cancel an unpaid invoice.\n\n## Output\nReturn the invoice ID, its status (draft, open, paid, or void), the amount due, and the hosted invoice URL when available.', + '# Issue Invoice\n\nBill a customer with a Stripe invoice.\n\n## Steps\n1. Run Create Invoice for the customer. This tool does not add line items; add them in Stripe and verify the draft with Retrieve Invoice before finalizing.\n2. Run Finalize Invoice to lock it, then Send Invoice to deliver it to the customer.\n3. Track payment with Retrieve Invoice, or run Pay Invoice to charge a saved payment method. Use Void Invoice to cancel an unpaid invoice.\n\n## Output\nReturn the invoice ID, its status (draft, open, paid, or void), the amount due, and the hosted invoice URL when available.', }, { name: 'find-customer-activity', diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 67dd311e621..18d3914c14b 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -266,6 +266,7 @@ import { PiBlock } from '@/blocks/blocks/pi' import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PitchBookBlock, PitchBookBlockMeta } from '@/blocks/blocks/pitchbook' +import { PlanetScaleBlock, PlanetScaleBlockMeta } from '@/blocks/blocks/planetscale' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql' import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog' @@ -619,6 +620,7 @@ export const BLOCK_REGISTRY: Record = { pinecone: PineconeBlock, pipedrive: PipedriveBlock, pitchbook: PitchBookBlock, + planetscale: PlanetScaleBlock, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, @@ -950,6 +952,7 @@ export const BLOCK_META_REGISTRY: Record = { pinecone: PineconeBlockMeta, pipedrive: PipedriveBlockMeta, pitchbook: PitchBookBlockMeta, + planetscale: PlanetScaleBlockMeta, polymarket: PolymarketBlockMeta, postgresql: PostgreSQLBlockMeta, posthog: PostHogBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 53cb7eb66b8..01ba721eec7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -10012,3 +10012,18 @@ export function OtterIcon(props: SVGProps) { ) } + +export function PlanetScaleIcon(props: SVGProps) { + return ( + + + + + ) +} diff --git a/apps/sim/components/settings/settings-navigation-provider.tsx b/apps/sim/components/settings/settings-navigation-provider.tsx new file mode 100644 index 00000000000..e19b6f7d695 --- /dev/null +++ b/apps/sim/components/settings/settings-navigation-provider.tsx @@ -0,0 +1,94 @@ +'use client' + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useOptimistic, + useRef, + useTransition, +} from 'react' +import { useRouter } from 'next/navigation' + +interface SettingsNavigationState { + /** The section a sidebar click is navigating to, until that navigation settles. */ + pendingSection: string | null + /** Navigates to a section, previewing `pendingSection` until it settles; null previews nothing. */ + navigateToSection: (pendingSection: string | null, href: string) => void + /** Hands a sidebar row's navigation intent to the mounted settings layout's handler, if any. */ + signalIntent: (section: string) => void + registerIntentHandler: (handler: (section: string) => void) => () => void +} + +const SettingsNavigationContext = createContext(null) + +interface SettingsNavigationProviderProps { + children: ReactNode +} + +/** + * Shares an in-flight settings section navigation between the sidebar that starts it and the + * content area that previews it. + * + * Section routes have no loading boundary (see the workspace section layout), so without this the + * outgoing section stays on screen for the whole round trip. The pending section is optimistic + * state set inside the navigation's own transition: React drops it the moment that transition + * settles — on commit, and equally when the server redirects back or the navigation fails — and + * because it is state rather than a Suspense fallback, the incoming section is never held back. + */ +export function SettingsNavigationProvider({ children }: SettingsNavigationProviderProps) { + const router = useRouter() + const [pendingSection, setPendingSection] = useOptimistic(null) + const [, startTransition] = useTransition() + + const navigateToSection = useCallback( + (section: string | null, href: string) => { + startTransition(() => { + setPendingSection(section) + router.replace(href, { scroll: false }) + }) + }, + [router, setPendingSection] + ) + + const intentHandlerRef = useRef<((section: string) => void) | null>(null) + const signalIntent = useCallback((section: string) => intentHandlerRef.current?.(section), []) + const registerIntentHandler = useCallback((handler: (section: string) => void) => { + intentHandlerRef.current = handler + return () => { + if (intentHandlerRef.current === handler) intentHandlerRef.current = null + } + }, []) + + const value = useMemo( + () => ({ pendingSection, navigateToSection, signalIntent, registerIntentHandler }), + [pendingSection, navigateToSection, signalIntent, registerIntentHandler] + ) + + return ( + + {children} + + ) +} + +export function useSettingsNavigationState(): SettingsNavigationState { + const state = useContext(SettingsNavigationContext) + if (!state) { + throw new Error('useSettingsNavigationState must be used within a SettingsNavigationProvider') + } + return state +} + +/** + * Registers the mounted settings layout's warmer for sidebar navigation intent. The settings route + * owns it, not the sidebar, so warming every section's chunk and data adds nothing to the + * workspace chrome's module graph, which every workspace page loads. + */ +export function useSettingsIntentHandler(handler: (section: string) => void): void { + const { registerIntentHandler } = useSettingsNavigationState() + useEffect(() => registerIntentHandler(handler), [registerIntentHandler, handler]) +} diff --git a/apps/sim/components/settings/settings-pending-section.tsx b/apps/sim/components/settings/settings-pending-section.tsx new file mode 100644 index 00000000000..6ff1616025f --- /dev/null +++ b/apps/sim/components/settings/settings-pending-section.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { ReactNode } from 'react' +import { + type SettingsHeaderMeta, + SettingsHeaderProvider, + SettingsHeaderShell, +} from '@/components/settings/settings-header' +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' + +interface SettingsPendingSectionProps { + /** The header a pending section paints with, or null to keep showing the current section. */ + resolveMeta: (section: string) => SettingsHeaderMeta | null + children: ReactNode +} + +/** + * Paints a clicked section's heading over an empty body while its route resolves — the same frame + * the route commits with, minus the body — so the click lands immediately. The current section + * stays mounted and laid out underneath, invisible and inert, so its scroll position and state are + * intact if the navigation settles back on it. With nothing pending both wrappers are + * `display: contents` and add no box of their own. + */ +export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) { + const { pendingSection } = useSettingsNavigationState() + const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null + + return ( +
+ {pendingMeta && ( +
+ + {null} + +
+ )} +
+ {children} +
+
+ ) +} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index c8e6a0810ba..e26d05a36a7 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -23,6 +23,7 @@ import { type StandaloneSettingsPlane, } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' +import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' @@ -91,6 +92,8 @@ export function SettingsSidebar
({ const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) const router = useRouter() + const { activeSection: selectedSection, navigateToSection } = + usePendingSettingsSelection(activeSection) const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave) @@ -167,7 +170,7 @@ export function SettingsSidebar
({
{group.items.map((item) => { const Icon = item.icon - const active = activeSection === item.id + const active = selectedSection === item.id const href = hrefForSection(item.id) return ( ({ event.preventDefault() return } - const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() - if (!isDirty && !navigationBlocked) return event.preventDefault() - requestLeave(() => router.replace(href, { scroll: false })) + const { isDirty, navigationBlocked } = useSettingsDirtyStore.getState() + if (!isDirty && !navigationBlocked) { + navigateToSection(item.id, href) + return + } + requestLeave(() => navigateToSection(item.id, href)) }} > diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 61b01132f72..35ec77e0f8f 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -9,13 +9,17 @@ import { ACCOUNT_SETTINGS_PATH_ALIASES, getAccountSettingsHref, getSelfHostSettingsHref, + getSettingsSectionMeta, parseSettingsPathSection, SELFHOST_SETTINGS_GROUPS, SELFHOST_SETTINGS_ITEMS, SETTINGS_PLANE_CHROME, + toSettingsHeaderMeta, } from '@/components/settings/navigation' import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { SettingsSectionProvider } from '@/components/settings/settings-panel' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' import type { DeploymentShape } from '@/lib/api/contracts/workspaces' @@ -41,6 +45,11 @@ interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps { type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettingsShellProps +function pendingSectionMeta(plane: 'account' | 'selfhost', section: string) { + const item = getSettingsSectionMeta(plane, section) + return item ? toSettingsHeaderMeta(item) : null +} + export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSeedDeploymentShape(props.deployment) @@ -96,33 +105,37 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { ) return ( -
- {/* - 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. + */} + +
+
+ pendingSectionMeta(plane, section)}> + + + + {children} + + + + +
+
-
+ ) } diff --git a/apps/sim/components/settings/use-pending-settings-selection.ts b/apps/sim/components/settings/use-pending-settings-selection.ts new file mode 100644 index 00000000000..ae4c0744489 --- /dev/null +++ b/apps/sim/components/settings/use-pending-settings-selection.ts @@ -0,0 +1,19 @@ +'use client' + +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' + +/** + * The sidebar's selected row: the section a click is navigating to until that navigation settles + * (see `SettingsNavigationProvider`), otherwise the section the route resolved to. + */ +export function usePendingSettingsSelection( + routeSection: TSection +): { activeSection: TSection; navigateToSection: (section: TSection, href: string) => void } { + const { pendingSection, navigateToSection } = useSettingsNavigationState() + return { + activeSection: (pendingSection as TSection | null) ?? routeSection, + // Returning to the section already on screen previews nothing: its content is what will show. + navigateToSection: (section, href) => + navigateToSection(section === routeSection ? null : section, href), + } +} diff --git a/apps/sim/connectors/confluence/attachments.test.ts b/apps/sim/connectors/confluence/attachments.test.ts index dcff9b8117e..86a5e00915b 100644 --- a/apps/sim/connectors/confluence/attachments.test.ts +++ b/apps/sim/connectors/confluence/attachments.test.ts @@ -1,3 +1,4 @@ +import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock' import JSZip from 'jszip' import { PDFDocument, StandardFonts } from 'pdf-lib' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -249,6 +250,133 @@ describe('Confluence attachment listing', () => { } ) + describe('server errors', () => { + const parents = (ids: string[]) => + vi.fn( + async (): Promise => ({ + documents: ids.map((id) => parent(id)), + hasMore: false, + }) + ) + + /** Answers a parent's attachment listing with `status` while `failing` says so. */ + function listingFails(failing: (parentId: string) => boolean, status = 500) { + fetchMock.mockImplementation(async (input) => { + const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? '' + if (failing(parentId)) return new Response('upstream error', { status }) + return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] }) + }) + } + + /** Runs a listing past the shared backoff (~31 s for five retries) without real waits. */ + async function listPastBackoff(input: Parameters[0]) { + vi.useFakeTimers() + try { + const listing = listConfluenceAttachments(input) + const settled = listing.then( + () => undefined, + () => undefined + ) + for (let i = 0; i < 20; i++) await vi.advanceTimersByTimeAsync(10_000) + await settled + return await listing + } finally { + vi.useRealTimers() + } + } + + it.each([ + ['a complete body', () => 'upstream error'], + ['a body that never finishes', () => new ReadableStream()], + [ + 'a body stream that already errored', + () => + new ReadableStream({ + start: (controller) => controller.error(new Error('connection reset')), + }), + ], + ])('retries a transient 500 with %s instead of failing the listing', async (_, body) => { + let failed = false + fetchMock.mockImplementation(async () => { + if (failed) return Response.json({ results: [file({ id: 'p1-file' })] }) + failed = true + return new Response(body(), { status: 500 }) + }) + const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) }) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + 'p1', + 'attachment:page:p1:p1-file', + ]) + expect(result.listingFailures).toBeUndefined() + }) + + it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => { + listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5') + const context: Record = {} + const result = await listPastBackoff({ + ...INPUT, + listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']), + syncContext: context, + }) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'attachment:page:p2:p2-file', + 'attachment:page:p4:p4-file', + ]) + expect(result.listingFailures).toEqual({ + count: 3, + samples: ['p1', 'p3', 'p5'].map((scope) => ({ + scope, + operation: 'confluence.attachments.list', + status: 500, + reasons: ['attachment_listing_unavailable'], + })), + }) + expect(result.reconciliationSafe).toBe(false) + expect(context.reconciliationUnsafe).toBe(true) + }) + + it('logs Atlassian trace identifiers for a 500 but never its body', async () => { + let failed = false + fetchMock.mockImplementation(async () => { + if (failed) return Response.json({ results: [] }) + failed = true + return Response.json( + { + errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }], + }, + { + status: 500, + headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id;