isContentHeading(item) && (slug?.[0] !== 'integrations' || item.depth <= 3) )} full={data.full} @@ -359,7 +356,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> className='max-sm:[&>h1]:basis-0 max-sm:[&_a]:size-10' >
- +
diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 17e36be5d4b..84f6a15fc6e 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -4,13 +4,16 @@ import { DOCS_BASE_URL } from '@/lib/urls' export const revalidate = 3600 -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { // The docs root redirects to /introduction, which is listed on its own. - return source - .getPages() - .filter((page) => page.url !== '/') - .map((page) => ({ + const entries: MetadataRoute.Sitemap = [] + for (const page of source.getPages()) { + if (page.url === '/') continue + const data = page.data.type === 'docs' ? await page.data.load() : undefined + entries.push({ url: `${DOCS_BASE_URL}${page.url}`, - lastModified: 'lastModified' in page.data ? page.data.lastModified : undefined, - })) + lastModified: data?.lastModified, + }) + } + return entries } diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index 4ea13bc7417..a632e2cd87c 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -112,7 +112,7 @@ export function SidebarFolder({ item, children }: SidebarFolderProps) { )} {hasChildren && ( )} diff --git a/apps/docs/components/page-actions.tsx b/apps/docs/components/page-actions.tsx index 53c92c8d087..7be1cd3ac9d 100644 --- a/apps/docs/components/page-actions.tsx +++ b/apps/docs/components/page-actions.tsx @@ -1,19 +1,57 @@ 'use client' -import { Chip } from '@sim/emcn' +import { useState } from 'react' +import { Chip, useCopyToClipboard } from '@sim/emcn' import { Check, Duplicate } from '@sim/emcn/icons' -import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button' -export function LLMCopyButton({ content }: { content: string }) { - const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content)) +interface LLMCopyButtonProps { + markdownUrl: string +} + +export function LLMCopyButton({ markdownUrl }: LLMCopyButtonProps) { + const { copied, copy } = useCopyToClipboard() + const [pending, setPending] = useState(false) + const [failed, setFailed] = useState(false) + const checked = copied && !pending && !failed + + async function onClick() { + setPending(true) + setFailed(false) + const prepare = async () => { + const response = await fetch(markdownUrl, { + signal: typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(10000) : undefined, + }) + if (!response.ok) throw new Error('Could not load page Markdown') + return response.text() + } + try { + // Promise-backed writes preserve Safari's transient user activation during the fetch. + const success = + typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function' + ? await copy({ prepare, fallback: '' }) + : await copy(await prepare()) + setFailed(!success) + } catch { + setFailed(true) + } finally { + setPending(false) + } + } return ( - - {checked ? 'Copied' : 'Copy page'} - +
+ + {checked ? 'Copied' : pending ? 'Copying…' : 'Copy page'} + + + {failed ? 'Could not copy. Try again.' : ''} + +
) } diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index 4fee2a7debe..f26468d8908 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -129,4 +129,5 @@ export type PageData = DocData & description?: string full?: boolean integration?: { type: string; color: string } + load: () => Promise } diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index fd57395c691..8c62df1caee 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -6,7 +6,6 @@ const withMDX = createMDX() const config: NextConfig = { reactStrictMode: true, - productionBrowserSourceMaps: true, transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'], images: { unoptimized: true, diff --git a/apps/docs/package.json b/apps/docs/package.json index 32dc1dee5fc..1191be6c832 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -18,7 +18,8 @@ "lint:check": "biome check .", "format": "biome format --write .", "format:check": "biome format .", - "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts" + "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts", + "test:navigation:e2e": "bun scripts/test-navigation-e2e.ts" }, "dependencies": { "@sim/db": "workspace:*", @@ -46,6 +47,7 @@ "zod": "4.3.6" }, "devDependencies": { + "@playwright/test": "1.61.1", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@types/node": "24.19.2", diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts new file mode 100644 index 00000000000..dd9f4bfe93b --- /dev/null +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -0,0 +1,190 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { type Browser, chromium, expect } from '@playwright/test' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' + +const logger = createLogger('DocsNavigationE2E', { logLevel: 'INFO' }) +const origin = process.env.DOCS_E2E_BASE_URL +const reportPath = process.env.DOCS_NAVIGATION_REPORT_PATH +if (!origin || !reportPath) { + throw new Error('A docs base URL and DOCS_NAVIGATION_REPORT_PATH are required') +} +const target = new URL(origin) +if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) { + throw new Error('The documentation server must use a loopback origin') +} + +const pages = [ + '/introduction', + '/getting-started', + '/files', + '/integrations/apollo', + '/api-reference/workflows/listWorkflows', +] as const +const viewports = [ + { name: 'desktop', width: 1440, height: 1000 }, + { name: 'mobile', width: 390, height: 844 }, +] as const +const results: Record[] = [] +let failed = false +await mkdir(dirname(reportPath), { recursive: true }) +let browser: Browser | undefined +try { + browser = await chromium.launch() + for (const viewport of viewports) { + for (const path of pages) { + const started = performance.now() + const context = await browser.newContext({ viewport, reducedMotion: 'reduce' }) + const page = await context.newPage() + const errors: string[] = [] + const markdownRequests: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/llms.mdx')) { + markdownRequests.push(request.url()) + } + }) + try { + const response = await page.goto(new URL(path, origin).toString(), { + waitUntil: 'networkidle', + }) + if (!response?.ok()) throw new Error(`Page returned ${response?.status() ?? 'no response'}`) + await page.getByRole('heading', { level: 1 }).waitFor() + await page.evaluate(async () => { + await document.fonts.ready + }) + if (path.startsWith('/api-reference/')) { + const tabs = page.getByRole('tablist', { name: 'Request language' }).getByRole('tab') + const count = await tabs.count() + if (count < 2) throw new Error('API request-language controls are missing') + const selectedIndex = await tabs.evaluateAll((elements) => + elements.findIndex((element) => element.getAttribute('aria-selected') === 'true') + ) + if (selectedIndex < 0) throw new Error('API request language has no selected tab') + for (let offset = 1; offset <= count; offset += 1) { + const tab = tabs.nth((selectedIndex + offset) % count) + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') + await page.waitForFunction(() => { + const selected = document.querySelector( + '[aria-label="Request language"] [aria-selected="true"]' + ) + const panelId = selected?.getAttribute('aria-controls') + const panel = panelId ? document.getElementById(panelId) : null + return Boolean( + panel && + panel.getBoundingClientRect().height > 0 && + panel.querySelector('code')?.textContent?.trim() + ) + }) + } + } + if (viewport.name === 'desktop') { + const markdownUrl = new URL(`/llms.mdx${path}`, origin).toString() + const markdown = await page.request.get(markdownUrl) + expect(markdown.ok()).toBe(true) + const expected = await markdown.text() + const copy = page.getByRole('button', { name: 'Copy page content', exact: true }) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + expect(markdownRequests).toEqual([]) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + expect(markdownRequests).toEqual([markdownUrl]) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(expected) + await page.route(markdownUrl, (route) => + route.fulfill({ status: 503, body: 'Unavailable' }) + ) + await page.getByRole('button', { name: 'Copied to clipboard' }).click() + await expect(page.getByRole('status')).toHaveText('Could not copy. Try again.') + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toHaveCount(0) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(expected) + await page.unroute(markdownUrl) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + } + await page.screenshot({ + path: join(dirname(reportPath), `${viewport.name}${path.replaceAll('/', '-')}.png`), + }) + if (path === '/introduction') { + const mobile = viewport.name === 'mobile' + if (mobile) await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click() + const navigation = page.getByRole(mobile ? 'dialog' : 'complementary', { + name: 'Documentation navigation', + }) + const platform = navigation.getByRole('button', { name: 'Platform', exact: true }) + const costs = navigation.getByRole('link', { + name: 'Credits, billing and limits', + exact: true, + }) + await platform.click() + await expect(costs).toBeVisible() + await platform.press('Enter') + await expect(costs).toBeHidden() + await platform.press('Enter') + await costs.click() + await expect(page).toHaveURL(new URL('/platform/costs', origin).toString()) + await expect( + page.getByRole('heading', { + level: 1, + name: 'Credits, billing and limits', + exact: true, + }) + ).toBeVisible() + if (mobile) { + await expect(navigation).not.toBeVisible() + await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click() + } + await expect(costs).toHaveAttribute('aria-current', 'page') + await expect(platform).toHaveAttribute('aria-expanded', 'true') + if (!mobile) { + const markdown = await page.request.get( + new URL('/llms.mdx/platform/costs', origin).toString() + ) + expect(markdown.ok()).toBe(true) + await page.getByRole('button', { name: 'Copy page content', exact: true }).click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(await markdown.text()) + } + } + results.push({ + path, + viewport: viewport.name, + status: errors.length ? 'failed' : 'passed', + durationMs: Math.round(performance.now() - started), + errors, + }) + if (errors.length) failed = true + } catch (error) { + failed = true + results.push({ + path, + viewport: viewport.name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + errors, + }) + } finally { + await context.close() + } + } + } +} catch (error) { + failed = true + results.push({ path: 'browser', status: 'failed', error: getErrorMessage(error) }) +} finally { + await browser?.close() + await writeFile( + reportPath, + `${JSON.stringify({ checkedAt: new Date().toISOString(), origin: target.origin, results }, null, 2)}\n` + ) +} +logger.info('Verified documentation navigation', { pages: results.length, reportPath }) +if (failed) process.exitCode = 1 diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index a1fcf8495a3..c69f7fb4e50 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -12,6 +12,7 @@ const DOCS_DIR = 'content/docs' export const docs = defineDocs({ dir: DOCS_DIR, docs: { + async: true, schema: frontmatterSchema.extend({ integration: z.object({ type: z.string(), color: z.string() }).optional(), }), diff --git a/bun.lock b/bun.lock index 00fc94716bf..43a6da0e39d 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "zod": "4.3.6", }, "devDependencies": { + "@playwright/test": "1.61.1", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@types/node": "24.19.2", From 20b946162166876581be3ee273ba9b48a9bdd007 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Oct 2026 12:14:36 -0700 Subject: [PATCH 11/18] fix(mothership): let the agent copy files between workspaces without Projects (#9288) * fix(mothership): let the agent copy files between workspaces without Projects A copy between workspaces needs only the turn's resource admission, but the copy authorizer required the Projects rollout for every copy and the agent was offered copies only through Project file-owner negotiation, so with Projects off every agent copy failed. The authorizer now requires Projects only for a copy that names a Project owner. Admitted chat turns declare the worker's workspace-file-copy capability whether or not Projects are on, the recovery marker restoring admission is written for every admitted turn, and the sandbox lease records and exports the same so the workbench terminal binds workspace copies under the paired grant. * fix(mothership): keep a turn's payload shape unless it declares workspace copies * chore(mothership): sync the workspace-file-copy capability wording from the worker contract --- .../application/file-owner-context.ts | 35 ++++++---- apps/sim/lib/mothership/chat/post.ts | 2 +- apps/sim/lib/mothership/generated/protocol.ts | 10 ++- .../request/lifecycle/recovery-config.test.ts | 25 ++++--- .../request/lifecycle/recovery-config.ts | 15 ++--- .../lib/mothership/tools/sandbox-file-copy.ts | 16 +++-- .../tools/sandbox-resource-transport.test.ts | 65 ++++++++++++++++--- .../tools/sandbox-resource-transport.ts | 18 ++--- .../tools/sandbox-resources.test.ts | 3 +- .../lib/mothership/tools/sandbox-resources.ts | 10 +++ .../mothership/tools/sandbox-session.test.ts | 12 +++- .../lib/mothership/tools/sandbox-session.ts | 2 + .../authorization-bulk.integration.ts | 27 ++++++++ .../authorization-product.integration.ts | 17 ++++- .../application/copy-authorization.ts | 3 +- .../application/copy-operation.ts | 14 ++++ 16 files changed, 215 insertions(+), 59 deletions(-) diff --git a/apps/sim/lib/mothership/application/file-owner-context.ts b/apps/sim/lib/mothership/application/file-owner-context.ts index 8d4dd2dff6b..9a405a40595 100644 --- a/apps/sim/lib/mothership/application/file-owner-context.ts +++ b/apps/sim/lib/mothership/application/file-owner-context.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { omit } from '@sim/utils/object' +import { omit, toArray } from '@sim/utils/object' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { executeCopilotProjectDiscovery } from '@/lib/mothership/application/execute-project-use-case' import { @@ -10,6 +10,7 @@ import { } from '@/lib/mothership/auth/application-delegation' import { getCopilotFileOwnerAdapter } from '@/lib/mothership/file-owners' import type { FileOwnerContext } from '@/lib/mothership/generated/file-owner' +import { WORKSPACE_FILE_COPY_CAPABILITY } from '@/lib/mothership/generated/protocol' import { supportsFileOwnerProtocol } from '@/lib/mothership/request/lifecycle/file-owner-protocol' import { getCurrentWorkspaceProject } from '@/lib/projects/application' import { isProjectsEnabled } from '@/lib/projects/rollout.server' @@ -17,11 +18,13 @@ import { isProjectsEnabled } from '@/lib/projects/rollout.server' const logger = createLogger('CopilotFileOwnerContext') /** - * Adds current owner hints to admitted authoring turns using the worker's generated contract. - * Hints are best-effort: a failed Project lookup sends the turn with no Project rather than failing - * it. Support is probed on the selected worker URL, which may front several worker releases, and a - * worker that predates the hint keys rejects them; rolling the worker back therefore requires - * turning the `projects` flag off first. + * Adds what admitted authoring turns may do with files, using the worker's generated contract. + * Every such turn declares `workspace-file-copy`: copies between workspaces need the turn's + * admission but not Projects, and a worker that predates the token ignores it. With Projects on, + * it also gets current owner hints. Hints are best-effort: a failed Project lookup sends the turn + * with no Project rather than failing it. Support is probed on the selected worker URL, which may + * front several worker releases, and a worker that predates the hint keys rejects them; rolling + * the worker back therefore requires turning the `projects` flag off first. */ export async function withFileOwnerContext( payload: Record, @@ -30,14 +33,18 @@ export async function withFileOwnerContext( route: string ): Promise> { const clean = omit(payload, ['project', 'fileOwnerCapabilities', 'fileOwnerProtocolVersion']) - if ( - !['/api/copilot', '/api/mothership'].includes(route) || - context.requestMode === 'assistant' || - !hasCopilotResourceAdmission(context) || - !(await isProjectsEnabled()) || - !(await supportsFileOwnerProtocol(baseURL)) + if (!['/api/copilot', '/api/mothership'].includes(route)) return clean + const admitted = context.requestMode !== 'assistant' && hasCopilotResourceAdmission(context) + const declared = toArray(clean.clientCapabilities).filter( + (capability) => capability !== WORKSPACE_FILE_COPY_CAPABILITY ) - return clean + const turn = admitted + ? { ...clean, clientCapabilities: [...declared, WORKSPACE_FILE_COPY_CAPABILITY] } + : clean.clientCapabilities === undefined + ? clean + : { ...clean, clientCapabilities: declared } + if (!admitted || !(await isProjectsEnabled()) || !(await supportsFileOwnerProtocol(baseURL))) + return turn const hints: FileOwnerContext = { fileOwnerProtocolVersion: 1, @@ -70,5 +77,5 @@ export async function withFileOwnerContext( }) } } - return { ...clean, ...hints } + return { ...turn, ...hints } } diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index bde5019d46e..79ae8be9976 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -1408,7 +1408,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { if (!lease || !sendClaim?.claimToken || sendClaim.storageMethod !== 'database') { throw new Error('Chat admission requires its controller and durable send claim') } - const resourceAuthoringVersion = await durableResourceAuthoringVersion( + const resourceAuthoringVersion = durableResourceAuthoringVersion( executionContext.copilotResourceAdmission !== undefined ) admittedRun = await admitChatTurn.execute({ diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 7b05e68d04d..43aaed612f2 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -303,6 +303,13 @@ export const TOOL_DEADLINE_SETTLE_MARGIN_MS = 90_000; /** The `clientCapabilities` token a Sim sends when it honors `StreamToolDeadline`. */ export const TOOL_DEADLINE_CAPABILITY = "tool-deadline"; +/** + * The `clientCapabilities` token a Sim sends when this turn may copy files within or between + * workspaces it can reach (`files copy`) without negotiated file owners, so also where Projects + * are off. + */ +export const WORKSPACE_FILE_COPY_CAPABILITY = "workspace-file-copy"; + /** POST /api/mothership — the chat request sim sends. */ export interface ChatRequest extends StreamResponseReceipt { fileOwnerProtocolVersion?: 1 | undefined; @@ -348,7 +355,8 @@ export interface ChatRequest extends StreamResponseReceipt { * grace windows and run tools server-side immediately. ABSENT = older/unknown caller — * dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates * this field still gets waited on). Known capability: "workflow-tool-pickup". Sim also - * declares its own {@link TOOL_DEADLINE_CAPABILITY} here. + * declares its own {@link TOOL_DEADLINE_CAPABILITY} and {@link WORKSPACE_FILE_COPY_CAPABILITY} + * here. */ clientCapabilities?: string[] | undefined; /** diff --git a/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts b/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts index a00a2fba458..bad76508c79 100644 --- a/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts @@ -104,15 +104,22 @@ describe('recovery marker written by an admitted turn', () => { }), } - it('stays off a recovery config older builds must still parse while Projects are off', async () => { - featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) - const recovery = await streamRecoveryConfig(options, workspaceRequest) - expect(recovery).not.toHaveProperty('resourceAuthoringVersion') - }) + it.each([true, false])( + 'restores authoring admission on recovery whether Projects are on (%s) or off', + async (projects) => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => projects && flag === 'projects' + ) + const recovery = await streamRecoveryConfig(options, workspaceRequest) + expect(recovery?.resourceAuthoringVersion).toBe(1) + } + ) - it('restores authoring admission on recovery while Projects are on', async () => { - featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') - const recovery = await streamRecoveryConfig(options, workspaceRequest) - expect(recovery?.resourceAuthoringVersion).toBe(1) + it('stays off a turn that was never admitted', async () => { + const recovery = await streamRecoveryConfig( + { ...options, copilotResourceAdmission: undefined }, + workspaceRequest + ) + expect(recovery).not.toHaveProperty('resourceAuthoringVersion') }) }) diff --git a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts index e6a4334178f..68b51d5b05d 100644 --- a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts +++ b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts @@ -3,7 +3,6 @@ import { hasCopilotResourceAdmission } from '@/lib/mothership/auth/application-d import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants' import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run' -import { isProjectsEnabled } from '@/lib/projects/rollout.server' export const BillingAdmissionSchema = z .object({ @@ -28,14 +27,14 @@ export const DurableChatRequestSchema = ChatPayloadSchema.safeExtend({ export const RESOURCE_AUTHORING_VERSION = 1 /** - * The marker an admitted turn stores for recovery. Builds before it parse recovery strictly and - * would refuse to recover the run, and only Project authoring reads it, so it is written only - * while Projects are on. + * The marker an admitted turn stores for recovery, so a recovered turn keeps its admission. It is + * written whether or not Projects are on, since copies between workspaces need the admission + * without them; every release since the marker shipped parses it. */ -export async function durableResourceAuthoringVersion( +export function durableResourceAuthoringVersion( admitted: boolean -): Promise { - return admitted && (await isProjectsEnabled()) ? RESOURCE_AUTHORING_VERSION : undefined +): typeof RESOURCE_AUTHORING_VERSION | undefined { + return admitted ? RESOURCE_AUTHORING_VERSION : undefined } export const StreamRecoveryConfigSchema = z @@ -66,7 +65,7 @@ export async function streamRecoveryConfig( request: Record ) { if (options.interactive !== true) return undefined - const resourceAuthoringVersion = await durableResourceAuthoringVersion( + const resourceAuthoringVersion = durableResourceAuthoringVersion( hasCopilotResourceAdmission({ ...options, ...options.executionContext, diff --git a/apps/sim/lib/mothership/tools/sandbox-file-copy.ts b/apps/sim/lib/mothership/tools/sandbox-file-copy.ts index 1cf7e30d7f1..f9212ee994a 100644 --- a/apps/sim/lib/mothership/tools/sandbox-file-copy.ts +++ b/apps/sim/lib/mothership/tools/sandbox-file-copy.ts @@ -13,6 +13,7 @@ import { recordSandboxResourceEffects, type SandboxResourceScope, } from '@/lib/mothership/tools/sandbox-resources' +import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation' const logger = createLogger('SandboxFileCopy') @@ -22,15 +23,17 @@ export async function proxySandboxFileCopyRequest( token: string, scope: SandboxResourceScope ): Promise { + const unavailable = () => + Response.json( + { error: 'Paired file owner routing is unavailable or contradictory' }, + { status: 403 } + ) if ( - scope.fileOwnerProtocolVersion !== 1 || + (scope.fileOwnerProtocolVersion !== 1 && !scope.workspaceFileCopy) || request.headers.has('x-mothership-file-owner') || request.headers.has('x-mothership-workspace-id') ) - return Response.json( - { error: 'Paired file owner routing is unavailable or contradictory' }, - { status: 403 } - ) + return unavailable() if (request.method !== 'POST') return Response.json({ error: 'Method not allowed' }, { status: 405 }) const parsed = await parseRequest( @@ -43,6 +46,9 @@ export async function proxySandboxFileCopyRequest( } ) if (!parsed.success) return parsed.response + // Without the negotiated protocol only workspace owners exist for this lease. + if (scope.fileOwnerProtocolVersion !== 1 && !copiesWithinWorkspaces(parsed.data.body)) + return unavailable() const endpoint = getInternalApiBaseUrl() const transport = createFileCopyCliTransport( endpoint, diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts index 6c0f18210db..789bf606cb6 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -16,6 +16,7 @@ import { assertWorkspaceInvocationScope } from '@/lib/core/application/workspace import { OrchestrationError } from '@/lib/core/orchestration/types' import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' import { fileOperations } from '@/lib/workspace-files/application/operations' const { readScope, recordEffects, fetcher, routeMatcher, recordInput } = vi.hoisted(() => ({ @@ -436,26 +437,72 @@ it.each([ } ) -it('refuses a Project copy destination on a lease without the file-owner protocol', async () => { - readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: undefined }) - routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) }) +it('binds a workspace pair under the paired copy grant on an admitted lease without the protocol', async () => { + readScope.mockResolvedValue({ + ...scope, + fileOwnerProtocolVersion: undefined, + workspaceFileCopy: true, + }) + routeMatcher.mockReturnValue({ + pattern: '/api/v2/files/copy', + params: {}, + load: async () => ({ POST: fetcher }), + }) + fetcher.mockImplementation(async (forwarded: Request) => { + expect( + copilotRequestPrincipal(forwarded, fileCopyOperation, { + operation: fileCopyOperation, + delegationAudience: fileCopyOperation.delegationAudience, + }) + ).toMatchObject({ + kind: 'resource_delegated', + subjectUserId: scope.userId, + invocation: { kind: 'chat', chatId: scope.chatId }, + scope: { kind: 'file_copy', destination: { owner: workspaceOwner } }, + }) + return Response.json({ data: { copied: [] } }) + }) const response = await proxySandboxResourceRequest( request('/api/v2/files/copy', { method: 'POST', body: JSON.stringify({ source: { owner: workspaceOwner, fileIds: ['file'] }, - destination: { owner: { entityType: 'project', entityId: 'project' } }, + destination: { owner: workspaceOwner }, }), }), token ) - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: 'Paired file owner routing is unavailable or contradictory', - }) - expect(fetcher).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(fetcher).toHaveBeenCalledTimes(1) }) +it.each([undefined, true as const])( + 'refuses a Project copy destination on a lease without the file-owner protocol (workspace copies %s)', + async (workspaceFileCopy) => { + readScope.mockResolvedValue({ + ...scope, + fileOwnerProtocolVersion: undefined, + workspaceFileCopy, + }) + routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) }) + const response = await proxySandboxResourceRequest( + request('/api/v2/files/copy', { + method: 'POST', + body: JSON.stringify({ + source: { owner: workspaceOwner, fileIds: ['file'] }, + destination: { owner: { entityType: 'project', entityId: 'project' } }, + }), + }), + token + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Paired file owner routing is unavailable or contradictory', + }) + expect(fetcher).not.toHaveBeenCalled() + } +) + it('validates a copy body itself once the lease negotiated the file-owner protocol', async () => { readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: 1 }) routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) }) diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts index 3e0daa2a5c8..699cbdca1ed 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts @@ -28,6 +28,7 @@ import { type SandboxResourceScope, } from '@/lib/mothership/tools/sandbox-resources' import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' +import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation' const logger = createLogger('MothershipSandboxResourceTransport') @@ -54,15 +55,20 @@ export async function proxySandboxResourceRequest( if (!scope) return Response.json({ error: 'Sandbox tool execution is no longer active' }, { status: 403 }) /** - * Paired owners need the negotiated protocol. Without it, only a copy that names a non-workspace - * owner is refused there; workspace copies and invalid bodies keep the workspace route and its + * Copies bind both owners under the paired grant. Project owners need the negotiated protocol, + * and an admitted lease copies between workspaces without it; a copy naming a Project owner is + * refused there. Otherwise workspace copies and invalid bodies keep the workspace route and its * v2 validation response. */ if (path === v2CopyFileItemsContract.path) { const buffered = await bufferCopyRequest(request) if (buffered instanceof Response) return buffered request = buffered - if (scope.fileOwnerProtocolVersion === 1 || (await copiesBeyondWorkspaces(request))) + if ( + scope.fileOwnerProtocolVersion === 1 || + scope.workspaceFileCopy || + (await copiesBeyondWorkspaces(request)) + ) return proxySandboxFileCopyRequest(request, token, scope) } const ownerHeader = request.headers.get('x-mothership-file-owner') @@ -245,9 +251,5 @@ async function copiesBeyondWorkspaces(request: Request): Promise { {}, V2_PARSE_DEFAULTS ) - return ( - parsed.success && - (parsed.data.body.source.owner.entityType !== 'workspace' || - parsed.data.body.destination.owner.entityType !== 'workspace') - ) + return parsed.success && !copiesWithinWorkspaces(parsed.data.body) } diff --git a/apps/sim/lib/mothership/tools/sandbox-resources.test.ts b/apps/sim/lib/mothership/tools/sandbox-resources.test.ts index 29e1aa6e9fe..f737879fee2 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resources.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resources.test.ts @@ -308,7 +308,7 @@ it.each([ [true, false, true, undefined], [true, true, false, undefined], ] as const)( - 'negotiates the callback protocol only with admission=%s projects=%s peer=%s', + 'negotiates the callback protocol only with admission=%s projects=%s peer=%s; copies follow admission alone', async (admitted, enabled, supported, expected) => { featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(enabled) vi.stubGlobal( @@ -334,6 +334,7 @@ it.each([ if (!token) throw new Error('Callback token missing') const scope = await readSandboxResourceScope(token, 'secret') expect(scope?.fileOwnerProtocolVersion).toBe(expected) + expect(scope?.workspaceFileCopy).toBe(admitted ? true : undefined) } ) } diff --git a/apps/sim/lib/mothership/tools/sandbox-resources.ts b/apps/sim/lib/mothership/tools/sandbox-resources.ts index 6f0245558cd..4fb021da7fb 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resources.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resources.ts @@ -28,6 +28,7 @@ const scopeSchema = z organizationId: z.string().optional(), apiKeyHash: z.string(), fileOwnerProtocolVersion: z.literal(1).optional(), + workspaceFileCopy: z.literal(true).optional(), }) .refine( (scope) => Boolean(scope.workspaceId) !== Boolean(scope.organizationId), @@ -46,6 +47,7 @@ interface ActiveSandboxResourceScope { signal: AbortSignal token?: string fileOwnerProtocolVersion?: 1 + workspaceFileCopy?: true } const resourceScope = new AsyncLocalStorage() @@ -60,6 +62,11 @@ export function activeSandboxFileOwnerProtocol() { return resourceScope.getStore()?.fileOwnerProtocolVersion } +/** An admitted tool's lease copies between workspaces whether or not Projects are on. */ +export function activeSandboxWorkspaceFileCopy() { + return resourceScope.getStore()?.workspaceFileCopy +} + const contextKey = (token: string) => `mothership:sandbox-resources:${token}:context` const inboxKey = (token: string) => `mothership:sandbox-resources:${token}:inbox` const seenKey = (token: string) => `mothership:sandbox-resources:${token}:seen` @@ -96,6 +103,7 @@ export async function sandboxResourceEndpoint( (await supportsFileOwnerProtocol(identity.mothershipBaseURL)) ? 1 : undefined + const workspaceFileCopy = identity.resourceAdmitted ? (true as const) : undefined const scope: SandboxResourceScope = { toolCallId: identity.toolCallId, runId: identity.runId, @@ -106,8 +114,10 @@ export async function sandboxResourceEndpoint( organizationId: identity.organizationId, apiKeyHash: sha256Hex(apiKey), fileOwnerProtocolVersion, + workspaceFileCopy, } active.fileOwnerProtocolVersion = fileOwnerProtocolVersion + active.workspaceFileCopy = workspaceFileCopy await redisClient().set(contextKey(token), JSON.stringify(scope), 'EX', CONTEXT_TTL_SECONDS) active.token = token if (active.signal.aborted) { diff --git a/apps/sim/lib/mothership/tools/sandbox-session.test.ts b/apps/sim/lib/mothership/tools/sandbox-session.test.ts index 75f6d6eeff7..4d3a2e14046 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.test.ts @@ -10,11 +10,12 @@ import { import { urlsMockFns } from '@sim/testing/mocks/urls.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { read, mint, connection, endpoint } = vi.hoisted(() => ({ +const { read, mint, connection, endpoint, workspaceFileCopy } = vi.hoisted(() => ({ connection: vi.fn(), endpoint: vi.fn(), read: vi.fn(), mint: vi.fn(), + workspaceFileCopy: vi.fn(), })) vi.mock('@/lib/mothership/transport/connection', () => ({ getSimConnection: connection })) vi.mock('node:fs/promises', () => ({ readFile: read })) @@ -85,6 +86,14 @@ describe('deployment-owned workbench tooling', () => { }) }) + it('tells the workbench an admitted lease copies between workspaces without Projects', async () => { + read.mockResolvedValue('bundle') + workspaceFileCopy.mockReturnValue(true) + const session = await buildMothershipSandboxSession(request) + expect(session.envs).toMatchObject({ SIM_WORKSPACE_FILE_COPY: '1' }) + expect(session.envs).not.toHaveProperty('SIM_FILE_OWNER_PROTOCOL') + }) + it('refuses setup when the deployment artifact is missing instead of fetching another CLI release', async () => { read.mockRejectedValue(new Error('missing workbench bundle')) await expect(buildMothershipSandboxSession(request)).rejects.toThrow('missing workbench bundle') @@ -130,6 +139,7 @@ vi.mock('@/lib/mothership/tools/sandbox-resources', () => ({ sandboxResourceEndpoint: endpoint, activeSandboxChatOwner: () => undefined, activeSandboxFileOwnerProtocol: () => undefined, + activeSandboxWorkspaceFileCopy: workspaceFileCopy, })) it('does not inject authentication when no active scoped callback can be established', async () => { diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index 1cbd26b006c..b502314401c 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -20,6 +20,7 @@ import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' import { activeSandboxChatOwner, activeSandboxFileOwnerProtocol, + activeSandboxWorkspaceFileCopy, sandboxResourceEndpoint, } from '@/lib/mothership/tools/sandbox-resources' import { getSimConnection } from '@/lib/mothership/transport/connection' @@ -153,6 +154,7 @@ export async function buildMothershipSandboxSession(args: { : { SIM_WORKSPACE: args.workspaceId! }), SIM_ENDPOINT: scopedEndpoint, ...(activeSandboxFileOwnerProtocol() === 1 ? { SIM_FILE_OWNER_PROTOCOL: '1' } : {}), + ...(activeSandboxWorkspaceFileCopy() ? { SIM_WORKSPACE_FILE_COPY: '1' } : {}), } } } catch (error) { diff --git a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts index a9c4e7c77e1..db4c7cde905 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts @@ -390,6 +390,33 @@ describe('compound copy owner authority', () => { await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) }) + check( + 'copy between workspaces runs with Projects off; a Project owner still needs them', + async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async () => false) + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const withProject = selection(f) + const between = { + ...withProject, + source: { + ...withProject.source, + owner: { entityType: 'workspace' as const, entityId: f.workspaces[0] }, + }, + } + for (const principal of [f.reader, copyPrincipal(f, between)]) { + const authorize = await createFileCopyAuthorizer(principal, between) + expect(await db.transaction(authorize)).toMatchObject({ + source: { owner: between.source.owner }, + destination: { owner: between.destination.owner }, + }) + } + await expect(createFileCopyAuthorizer(f.reader, withProject)).rejects.toMatchObject({ + statusCode: 503, + }) + } + ) + check( 'copy locks opposite Project directions consistently in overlapping transactions', async () => { diff --git a/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts index a206ddd632c..26b8ed56aad 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts @@ -30,6 +30,7 @@ import { type CopilotExecutionContext, createCopilotResourceAdmission, } from '@/lib/mothership/auth/application-delegation' +import { WORKSPACE_FILE_COPY_CAPABILITY } from '@/lib/mothership/generated/protocol' import { listProjects } from '@/lib/projects/application' import { getProjectFileMetadata } from '@/lib/projects/files/application' import { projectFileOperations } from '@/lib/projects/files/application/operations' @@ -391,8 +392,10 @@ describe('Mothership origin navigation and independent Project authority', () => }, ], } + const copies = { clientCapabilities: [WORKSPACE_FILE_COPY_CAPABILITY] } const noProject = { message: 'hello', + ...copies, fileOwnerProtocolVersion: 1, project: null, fileOwnerCapabilities: [], @@ -407,7 +410,19 @@ describe('Mothership origin navigation and independent Project authority', () => const send = () => withFileOwnerContext(forged, context(f), workerURL, '/api/mothership') featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async () => false) - expect(await send()).toEqual({ message: 'hello' }) + // Projects off: the admitted turn still copies between workspaces, and only it does. + expect(await send()).toEqual({ message: 'hello', ...copies }) + expect( + await withFileOwnerContext( + forged, + { ...context(f), requestMode: 'assistant' }, + workerURL, + '/api/mothership' + ) + ).toEqual({ message: 'hello' }) + expect( + await withFileOwnerContext(forged, context(f), workerURL, '/api/tools/resume') + ).toEqual({ message: 'hello' }) featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( async (flag) => flag === 'projects' diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts index a7a185bb07b..ce6258c86eb 100644 --- a/apps/sim/lib/workspace-files/application/copy-authorization.ts +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -27,6 +27,7 @@ import { import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' import { + copiesWithinWorkspaces, FILE_COPY_DELEGATION_TTL_MS, fileCopyOperation, } from '@/lib/workspace-files/application/copy-operation' @@ -149,7 +150,7 @@ export async function createFileCopyAuthorizer( input: CopyFileItemsInput ): Promise<(tx: DbTransaction) => Promise> { requireCopyPrincipal(principal, input) - await requireProjectApiEnabled() + if (!copiesWithinWorkspaces(input)) await requireProjectApiEnabled() // actorless-unsupported: files.copy rejects executors and workspace keys; both owner policies require the acting human. const userId = await requireFileSubject(principal) const invocationScope = diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts index 9e70495fe8f..8503d24e930 100644 --- a/apps/sim/lib/workspace-files/application/copy-operation.ts +++ b/apps/sim/lib/workspace-files/application/copy-operation.ts @@ -21,3 +21,17 @@ Object.freeze(policy.delegatedServices) /** Compound copies bind independent source-read and destination-write authority. */ export const fileCopyOperation = Object.freeze(policy) + +/** + * Whether a copy names only workspace owners. Every workspace belongs to a Project whether or not + * Projects are on, so only a copy that names a Project owner needs them. + */ +export function copiesWithinWorkspaces(input: { + source: { owner: { entityType: string } } + destination: { owner: { entityType: string } } +}): boolean { + return ( + input.source.owner.entityType === 'workspace' && + input.destination.owner.entityType === 'workspace' + ) +} From 6e6243bb0309b00561a8f640cd09e1c99f7c8b4b Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Oct 2026 12:29:19 -0700 Subject: [PATCH 12/18] improvement(emails): refresh the platform update broadcast (#9291) * improvement(emails): refresh the platform update broadcast * fix(emails): preserve Outlook conditional markup --- .../emails/broadcasts/desktop-cli-search.html | 944 +++++++++++------- .../desktop-cli-search/desktop-overview.jpg | Bin 0 -> 65000 bytes 2 files changed, 571 insertions(+), 373 deletions(-) create mode 100644 apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg diff --git a/apps/sim/emails/broadcasts/desktop-cli-search.html b/apps/sim/emails/broadcasts/desktop-cli-search.html index f8110924202..e42861ac920 100644 --- a/apps/sim/emails/broadcasts/desktop-cli-search.html +++ b/apps/sim/emails/broadcasts/desktop-cli-search.html @@ -1,21 +1,22 @@ - + - Introducing Sim Desktop, CLI, and MCP - + New in Sim: Desktop, live Search, and more + + + - +
- Sim on your Mac, in your terminal, and in your AI tools. Plus, Search for enterprise teams. + Build from your Mac or terminal, find answers across your tools, and + compare changes before deploying.
- +
- - - -
@@ -76,98 +91,101 @@ style="width: 100%; max-width: 600px" >
+ - Sim - + style="display: block; width: 43px; height: 26px" + />
+

- Introducing Sim Desktop, CLI, and MCP + More ways to build and run with Sim

+

- Pick up where you left off. Your Sim workspace now works on your Mac, in your terminal, and inside - Claude, Codex, or Cursor. + Your AI workspace now reaches your Mac, terminal, and the AI + tools you already use. Find answers across your company’s + tools, try new models, and review what changed before your + next deployment.

- +
+ - @@ -175,93 +193,86 @@ - - - -
+ Open your workspaceOpen Sim
+ - Sim Desktop with Chat and a built-in browser side by side. - + />
+

- Sim Desktop + Your workspace, on your Mac

+

- Chat with Sim, browse signed-in sites, and run terminal commands in one Mac app. Your workspace, side - by side. + Bring Chat, a built-in browser, terminal sessions, and + approved local files into one macOS app. Sign in with your + existing Sim account. On supported deployments, you can also + enable native Mac app control.

- +
+ - - - - -
- - Sim CLI commands: bun add -g sim; sim login; sim workflows list --output json. - -
+

- Sim CLI + Sim, from your terminal

+

- Run workflows, query tables, and inspect logs from your terminal. Built for scripts, CI pipelines, and - coding agents. + Run workflows, query tables, move files, and inspect logs. + Structured JSON output fits into scripts, CI pipelines, and + coding agents. Find commands by task with + sim cli search.

- + + + +
+ + + + +
+ bun add -g sim
sim login
sim workflows + list
+
+
+ @@ -427,56 +437,54 @@ - - -
Install Sim CLIGet started with the CLI
+

- Sim MCP + Use Sim from Claude, Codex, or Cursor

+

- Bring your Sim workspace into Claude, Codex, or Cursor. Run workflows, query tables, and manage files - from the AI tools you already use. + Connect through MCP, the standard that lets AI apps use tools. + Run and deploy workflows, query tables, and work with files + from your preferred AI app.

- +
+ - - -
- +

- Search across Slack, Google Drive, GitHub, and Gmail. Example question: What’s the latest on the launch? - + Live answers from your company’s tools +

-

+

- Sim Search for enterprise -

+ Search for Enterprise teams +

+

- Search Slack, Google Drive, GitHub, and more. Get live answers with citations, while respecting each - person’s access. + Ask questions across connected Slack, Google Drive, GitHub, + Gmail, and more, with links back to the sources. Search + queries sources live and now ranks closer matches across apps + while checking each person’s current access.

- +
+ + + + + + + + + + + + + + + + + + + -
+ +

+ Also new in your workspace +

+
+

+ Chat finds the tools you need.
Sim can discover connected integrations, workspace MCP + servers, and skills as you work. +

+
+

+ Compare before you deploy.
Review prompt edits, steps, and connections in a draft + or two saved deployments. Enterprise teams with workspace + forks can also review source changes before syncing. +

+
+

+ Try a different model.
GPT-6.1 Sol, Claude Haiku 5.5, Grok 4.7, and Gemini 4 + Argon are available in the Agent block. Nebius Token Factory + adds open models with your own API key. +

+
+

+ Connect more of your team’s work.
New integrations include Help Scout, Fivetran, Oracle + Database, Checkr, Power BI, and Microsoft Intune, with + expanded Excel and Greenhouse support. +

+

+ See all recent updates +

+
+

- Thanks for building with us.
The Sim team + Try it on something real. Open a new Chat and ask:
“Build an agent that summarizes new support tickets and + posts a daily digest to Slack.”

- +
+ - @@ -699,60 +894,75 @@ - + + + - diff --git a/apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg b/apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..175cd5beecd7aa9426e0ec0af490920c745a4c3f GIT binary patch literal 65000 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%0ADqXdeBO2ZG)5~3)0svX;2@NdW`&~Zy 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 Date: Wed, 21 Oct 2026 12:29:50 -0700 Subject: [PATCH 13/18] feat(chat): recall sent messages from composer (#9289) * feat(chat): recall sent messages from composer * fix(chat): preserve recalled chips and caret navigation * fix(chat): validate saved integration labels before recall --- .../components/search-input-bar.tsx | 5 + .../home/components/composer/composer.tsx | 44 +++-- .../home/organization-home.tsx | 57 +++--- .../mothership-chat/mothership-chat.tsx | 13 +- .../prompt-editor/prompt-editor.tsx | 5 +- .../prompt-editor/use-prompt-editor.test.tsx | 176 +++++++++++++++++- .../prompt-editor/use-prompt-editor.ts | 89 ++++++++- .../user-input/hooks/use-prompt-history.ts | 105 +++++++++++ .../home/components/user-input/user-input.tsx | 23 ++- .../[workspaceId]/home/hooks/send-handoff.ts | 80 +------- .../[workspaceId]/home/hooks/use-chat.ts | 43 +---- .../app/workspace/[workspaceId]/home/types.ts | 1 + apps/sim/lib/mothership/chat/context.ts | 86 +++++++++ .../lib/mothership/chat/display-message.ts | 1 + .../lib/mothership/chat/persisted-message.ts | 14 ++ 15 files changed, 569 insertions(+), 173 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history.ts create mode 100644 apps/sim/lib/mothership/chat/context.ts diff --git a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx index 9ba40936a0f..f6a6511cd27 100644 --- a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx +++ b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx @@ -1,5 +1,6 @@ import { type ClipboardEventHandler, + type KeyboardEventHandler, type ReactNode, type RefObject, useLayoutEffect, @@ -18,6 +19,7 @@ interface SearchInputBarProps { onChange: (value: string) => void onSubmit: () => void onPaste?: ClipboardEventHandler + onKeyDown?: KeyboardEventHandler placeholder?: string 'aria-label'?: string leadingControls?: ReactNode @@ -37,6 +39,7 @@ export function SearchInputBar({ onChange, onSubmit, onPaste, + onKeyDown, placeholder = 'Search your sources', 'aria-label': ariaLabel = 'Search your sources', selectionControl, @@ -108,6 +111,8 @@ export function SearchInputBar({ maxHeight={200} onChange={(event) => onChange(event.target.value)} onKeyDown={(event) => { + onKeyDown?.(event) + if (event.defaultPrevented) return if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault() onSubmit() diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 25e1ef28eed..0017439f27c 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react' import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn' import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons' -import { escapeRegExp } from '@sim/utils/string' import { useQueries } from '@tanstack/react-query' import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE, @@ -25,9 +24,9 @@ import { } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options' import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut' -import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' +import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history' +import type { ChatMessage, ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' -import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import { getSkillsQueryOptions } from '@/hooks/queries/skills' import { useWorkspacesQuery } from '@/hooks/queries/workspace' import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' @@ -49,6 +48,8 @@ interface ComposerProps { restoredContexts?: ChatContext[] onSubmit: (text: string, contexts?: ChatContext[]) => void onSendQueuedHead?: () => void + onEditQueuedTail?: () => boolean + messages?: readonly ChatMessage[] onStop: () => void } @@ -69,6 +70,8 @@ export function Composer({ onChange, onSubmit, onSendQueuedHead, + onEditQueuedTail, + messages, restoredContexts, onStop, }: ComposerProps) { @@ -100,6 +103,12 @@ export function Composer({ initialContexts: restoredContexts, onPasteFiles: files.processFiles, }) + const history = usePromptHistory({ + editor, + attachedFiles: files.attachedFiles, + restoreAttachedFiles: files.restoreAttachedFiles, + getMessages: () => messages ?? [], + }) const { textareaRef } = editor const searchBlocked = editor.getActiveContexts().length > 0 || @@ -143,19 +152,9 @@ export function Composer({ }, [editor.value, editor.contexts, onChange]) useEffect(() => { if (!restoredContexts) return - // A queued skill may belong to a workspace whose picker has never opened here. - // Restore its existing chip from the saved context, without rediscovering it. - let restoredText = editorRef.current.getValue() - for (const context of restoredContexts) { - if (context.kind !== 'skill') continue - restoredText = restoredText.replace( - new RegExp(`(^|\\s)/${escapeRegExp(context.label)}(?=\\s|$)`, 'g'), - `$1${SKILL_CHIP_TRIGGER}${context.label}` - ) - } - editorRef.current.setValue(restoredText, { chipify: false }) - editorRef.current.setContexts(restoredContexts) - }, [restoredContexts]) + history.reset() + editorRef.current.restorePrompt(editorRef.current.getPlainValue(), restoredContexts) + }, [restoredContexts, history.reset]) useChatInputFocus({ textareaRef }) const voice = useVoiceInput({ organizationId: organization.id, @@ -184,6 +183,7 @@ export function Composer({ voice.resetTranscript() const contexts = imagesOnly ? [] : editor.getActiveContexts() onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined) + history.reset() editor.clear() } @@ -207,12 +207,18 @@ export function Composer({ ) + const recallQueuedTail = () => { + if (attachedFilesRef.current.length > 0) return false + return onEditQueuedTail?.() ?? false + } const promptEditor = ( editor.setValue(text)} onSubmit={submit} onPaste={editor.handlePaste} + onKeyDown={(event) => + editor.handleKeyDown(event, { + onNavigateHistory: history.navigate, + onArrowUpOnEmpty: recallQueuedTail, + }) + } placeholder={placeholder} aria-label='Ask Sim' leadingControls={leadingControls} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index a4a7af5c530..c48be524d9f 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -299,6 +299,32 @@ function OrganizationHomeContent({ files.clearAttachedFiles() } + const editQueuedMessage = (id: string) => { + const queued = chat.editQueuedMessage(id) + if (queued) { + const queuedMode = queued.requestMode ?? requestMode + setSelectedMode(queuedMode) + setDraft(queued.content, queued.contexts) + setRestoredContexts(queued.contexts ?? []) + files.restoreAttachedFiles( + (queued.fileAttachments ?? []).map((file) => ({ + id: file.id, + key: file.key, + name: file.filename, + type: file.media_type, + size: file.size, + path: + file.path || + getMothershipAttachmentPreviewUrl(file) || + getMothershipAttachmentUrl(file), + previewUrl: getMothershipAttachmentPreviewUrl(file), + uploading: false, + })) + ) + } + return queued + } + const composer = requestMode !== 'assistant' && !canBuild ? (
@@ -317,6 +343,11 @@ function OrganizationHomeContent({ onModeChange={canSelectMode ? changeMode : undefined} value={draft} restoredContexts={restoredContexts} + messages={chat.messages} + onEditQueuedTail={() => { + const tail = chat.messageQueue.at(-1) + return tail ? Boolean(editQueuedMessage(tail.id)) : false + }} files={files} isInitialView={!hasChat} isSending={chat.isSending || chat.isReconnecting} @@ -357,31 +388,7 @@ function OrganizationHomeContent({ dispatchingHeadId={chat.dispatchingHeadId} onRemoveQueuedMessage={chat.removeFromQueue} onSendQueuedMessage={chat.sendNow} - onEditQueuedMessage={(id) => { - const queued = chat.editQueuedMessage(id) - if (queued) { - const queuedMode = queued.requestMode ?? requestMode - setSelectedMode(queuedMode) - setDraft(queued.content, queued.contexts) - setRestoredContexts(queued.contexts ?? []) - files.restoreAttachedFiles( - (queued.fileAttachments ?? []).map((file) => ({ - id: file.id, - key: file.key, - name: file.filename, - type: file.media_type, - size: file.size, - path: - file.path || - getMothershipAttachmentPreviewUrl(file) || - getMothershipAttachmentUrl(file), - previewUrl: getMothershipAttachmentPreviewUrl(file), - uploading: false, - })) - ) - } - return queued - }} + onEditQueuedMessage={editQueuedMessage} onCancelQueueEdit={chat.cancelQueueEdit} userId={session?.user?.id} chatId={chat.resolvedChatId} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index aaab28c2f98..6e3abdb23e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -396,6 +396,9 @@ export function MothershipChat({ * starving the main thread on every streaming token. */ const messages = useDeferredValue(messagesProp) + const historyMessagesRef = useRef(messagesProp) + historyMessagesRef.current = messagesProp + const getHistoryMessages = useCallback(() => historyMessagesRef.current, []) const [lastRowAnimating, setLastRowAnimating] = useState(false) const containerRef = useRef(null) const scrollElementRef = useRef(null) @@ -779,16 +782,17 @@ export function MothershipChat({ const handleEditQueued = useCallback( (id: string) => { const msg = onEditQueuedMessage(id) - if (!msg) return - userInputRef.current?.loadQueuedMessage(msg) + if (!msg || !userInputRef.current) return false + userInputRef.current.loadQueuedMessage(msg) + return true }, [onEditQueuedMessage, userInputRef] ) const handleEditQueuedTail = useCallback(() => { const tail = messageQueueRef.current[messageQueueRef.current.length - 1] - if (!tail) return - handleEditQueued(tail.id) + if (!tail) return false + return handleEditQueued(tail.id) }, [handleEditQueued]) /** @@ -988,6 +992,7 @@ export function MothershipChat({ onSendQueuedHead={handleSendQueuedHead} onEditQueuedTail={handleEditQueuedTail} draftScopeKey={draftScopeKey} + getMessages={getHistoryMessages} /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index eb06e0b0973..d7d7ffda6e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -71,6 +71,7 @@ export function PromptEditor({ 'aria-label': ariaLabel, onSubmit, onArrowUpOnEmpty, + onNavigateHistory, }: PromptEditorProps) { const { textareaRef, value } = editor const scrollerRef = useRef(null) @@ -198,7 +199,9 @@ export function PromptEditor({ readOnly={readOnly} onChange={readOnly ? undefined : editor.handleInputChange} onKeyDown={ - readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty }) + readOnly + ? undefined + : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty, onNavigateHistory }) } onFocus={readOnly ? undefined : () => setHasFocused(true)} onPaste={readOnly ? undefined : editor.handlePaste} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index b7ae3876f54..f75e3027f67 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' +import { act, type ReactNode, useState } from 'react' import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock' import { createRoot, hydrateRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' @@ -16,6 +16,9 @@ import { type UsePromptEditorProps, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history' +import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { getIntegrationMatcher } from '@/blocks/integration-matcher' import type { ChatContext } from '@/stores/panel' @@ -526,3 +529,174 @@ it('re-chipifies a seeded draft once skills load', () => { act(() => root.unmount()) } }) + +describe('sent prompt history', () => { + it('recalls only user messages, walks both ways, and returns to the empty composer', () => { + const history = renderHistoryEditor([ + { id: 'first', role: 'user', content: 'first\nsecond line' }, + { id: 'answer', role: 'assistant', content: 'answer' }, + { id: 'latest', role: 'user', content: 'latest' }, + { id: 'task', role: 'user', origin: 'task', content: 'background notification' }, + ]) + try { + history.key('ArrowUp') + expect(history.textarea.value).toBe('latest') + history.key('ArrowUp') + expect(history.textarea.value).toBe('first\nsecond line') + history.key('ArrowUp') + expect(history.textarea.value).toBe('first\nsecond line') + history.end() + history.key('ArrowDown') + expect(history.textarea.value).toBe('latest') + history.key('ArrowDown') + expect(history.textarea.value).toBe('') + } finally { + history.unmount() + } + }) + + it('round trips saved skills, MCP, resource context and attachments without a loaded picker', () => { + const contexts: ChatContext[] = [ + { kind: 'skill', skillId: 'skill-a', workspaceId: 'workspace-a', label: 'review' }, + { kind: 'mcp', serverId: 'server-a', label: 'tools' }, + { kind: 'file', fileId: 'file-a', label: 'Plan.md' }, + ] + const attachment = { + id: 'attachment-a', + key: 'uploads/file-a', + filename: 'image.png', + media_type: 'image/png', + size: 30, + } + const content = '(/review), [/tools]! (@Plan.md) and /reviewer and path/review' + const history = renderHistoryEditor([ + { id: 'rich', role: 'user', content, contexts, fileAttachments: [attachment] }, + ]) + try { + history.key('ArrowUp') + expect(history.editor().getPlainValue()).toBe(content) + expect(history.editor().getActiveContexts()).toEqual(contexts) + expect(history.files()).toMatchObject([ + { id: attachment.id, key: attachment.key, uploading: false }, + ]) + expect(history.textarea.value).toBe( + '(\u2003review), [\u2003tools]! (@Plan.md) and /reviewer and path/review' + ) + history.end() + history.key('ArrowDown') + expect(history.editor().getActiveContexts()).toEqual([]) + expect(history.files()).toEqual([]) + } finally { + history.unmount() + } + }) + + it('does not replace drafts, selections, composing text, or modified arrow navigation', () => { + const history = renderHistoryEditor([{ id: 'first', role: 'user', content: 'sent' }]) + try { + for (const modifiers of [ + { shiftKey: true }, + { altKey: true }, + { ctrlKey: true }, + { metaKey: true }, + { isComposing: true }, + ]) { + history.key('ArrowUp', modifiers) + expect(history.textarea.value).toBe('') + } + history.setText('my draft') + history.textarea.setSelectionRange(0, 0) + history.key('ArrowUp') + expect(history.textarea.value).toBe('my draft') + history.setText('') + history.key('ArrowUp') + history.textarea.setSelectionRange(0, 2) + expect(history.key('ArrowUp')).toBe(false) + expect(history.textarea.value).toBe('sent') + history.setText('edited recalled message') + history.end() + history.key('ArrowDown') + expect(history.textarea.value).toBe('edited recalled message') + } finally { + history.unmount() + } + }) + + it('preserves multiline movement and follows message identity when older history arrives', () => { + const history = renderHistoryEditor([{ id: 'latest', role: 'user', content: 'one\ntwo' }]) + try { + history.key('ArrowUp') + history.textarea.setSelectionRange(5, 5) + expect(history.key('ArrowUp')).toBe(false) + expect(history.textarea.value).toBe('one\ntwo') + history.prepend({ id: 'older', role: 'user', content: 'older' }) + history.textarea.setSelectionRange(0, 0) + history.key('ArrowUp') + expect(history.textarea.value).toBe('older') + } finally { + history.unmount() + } + }) +}) + +function renderHistoryEditor(initialMessages: ChatMessage[]) { + let messages = initialMessages + let latest: ReturnType | undefined + let currentFiles: AttachedFile[] = [] + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + function HistoryProbe() { + const editor = usePromptEditor({ workspaceId: 'ws-1' }) + const [attachedFiles, restoreAttachedFiles] = useState([]) + const history = usePromptHistory({ + editor, + attachedFiles, + restoreAttachedFiles, + getMessages: () => messages, + }) + latest = editor + currentFiles = attachedFiles + return ( +
+ Open your workspaceOpen Sim
+ +

+ Thanks for building with us.
The Sim team +

+
 

- Sim, 80 Langton St, San Francisco, CA 94103, USA + Sim · 80 Langton St, San Francisco, CA 94103, USA

Questions? help@sim.ai

Privacy Policy -  ·  +  ·  Terms of Service -  ·  +  ·  Unsubscribe

- © 2026 Sim, All Rights Reserved + © 2026 Sim. All rights reserved.