From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/5] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 4fcbf2da0eec75476c8e5fd2a5c12a1995f6e9a6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 21 Sep 2026 13:06:31 -0700 Subject: [PATCH 2/5] chore(ci): give the Trigger.dev upload job room for a slow image push (#8115) (cherry picked from commit 43c9f6c97a1049365405348ceea5fc46c6dffa4a) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c110d101e7..ab57ee711e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,7 +231,7 @@ jobs: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 30 outputs: version: ${{ steps.deploy.outputs.deploymentVersion }} environment: ${{ steps.target.outputs.environment }} From cda7e56bcbde68342121db34b283dbc32689d0ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:25:22 -0700 Subject: [PATCH 3/5] improvement(extension): document browser data handling and limited use --- .../app/(landing)/privacy/privacy-content.tsx | 48 ++++++++++++++++++- apps/sim/tools/assistant-execution.test.ts | 3 ++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 61bc9287a17..1abcb53b72d 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -44,7 +44,7 @@ function richText(content: string): ReactNode { export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)', - lastUpdated: 'October 13, 2026', + lastUpdated: 'October 31, 2026', intro: [ { kind: 'paragraph', @@ -799,6 +799,52 @@ export const PRIVACY_CONFIG: LegalPageConfig = { }, ], }, + { + id: 'browser-extension', + heading: '10B. Sim Browser Extension', + blocks: [ + { + kind: 'paragraph', + content: richText( + 'When You ask Sim to attach a page or selection, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.' + ), + }, + { + kind: 'paragraph', + content: richText( + 'Capture starts only in response to Your action. The extension does not collect page content continuously or silently in the background. Optional continuing site access is requested for an individual site and can be removed through Site access or Chrome settings. Incognito capture is disabled.' + ), + }, + { + kind: 'paragraph', + content: richText( + 'Attaching a page transfers the snapshot to the hosted composer before You send a message. Captured text is held in memory and is not persisted in Chrome extension storage. Short-lived extension session records contain capture intent metadata and a one-way digest of the source address, not captured text or the raw address. You can review or remove an attachment before sending it.' + ), + }, + { + kind: 'paragraph', + content: richText( + 'When You send a message, submitted content is processed and retained as part of Your Sim conversation under this Privacy Policy and applicable Account or organization controls. Sim infrastructure and the AI providers or integrations used for Your requested conversation may process that content as described in sections 6 and 10A. Removing a draft attachment or uninstalling the extension does not delete previously submitted conversations. Access and deletion controls are described in section 14.' + ), + }, + { + kind: 'paragraph', + content: richText( + 'The hosted chat uses Your normal Sim Account and authenticated session, together with the operational diagnostics and consent controls described in this Privacy Policy. The extension document and its page attachments are excluded from session replay. Captured page content is not used for advertising.' + ), + }, + { + kind: 'subheading', + text: 'Chrome Web Store Limited Use', + }, + { + kind: 'paragraph', + content: richText( + "Sim's use and transfer of data obtained through the browser extension complies with the [Chrome Web Store User Data Policy](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq), including its [Limited Use requirements](https://developer.chrome.com/docs/webstore/program-policies/limited-use). This data is used only to provide or improve the extension's disclosed user-facing features. It is not sold, used or transferred for personalized advertising, or used to determine creditworthiness or for lending purposes. Transfers are limited to providing or improving those features, security needs, compliance with applicable laws, or a merger, acquisition, or asset sale as permitted by that policy. Human access is limited to specific user consent, necessary security or legal purposes, or aggregated and anonymized internal operations permitted by that policy." + ), + }, + ], + }, { id: 'links-to-other-sites', heading: '11. Links to Other Sites', diff --git a/apps/sim/tools/assistant-execution.test.ts b/apps/sim/tools/assistant-execution.test.ts index 33b0e9e490e..7d9bc97ac4f 100644 --- a/apps/sim/tools/assistant-execution.test.ts +++ b/apps/sim/tools/assistant-execution.test.ts @@ -8,6 +8,9 @@ import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolve import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { InternalToolConfig } from '@/tools/types' +// Compile the lazy authorization graph before the first test's execution deadline. +import '@/lib/assistant-actions/execution-scope' + const { getTool, resolveToken, operation, resolvePersonalToken } = vi.hoisted(() => ({ getTool: vi.fn(), resolveToken: vi.fn(), From 2934701b47727b8850a2ba1b5493000bd13ead34 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 2 Nov 2026 10:05:48 -0800 Subject: [PATCH 4/5] fix(extension): attach Sim pages and streamline composer controls (#9560) * fix(extension): attach Sim pages and streamline composer controls * fix(extension): normalize Sim paths before capture * fix(extension): exclude revealed secrets and keep large composers usable --- apps/extension/scripts/test-e2e.ts | 107 ++++++++- apps/extension/src/capture.ts | 13 +- apps/sim/app/extension/[workspaceId]/chat.tsx | 6 +- apps/sim/app/extension/hooks/use-composer.tsx | 20 +- .../components/organization-conversation.tsx | 5 +- .../home/components/composer/composer.tsx | 3 + .../home/components/composer-scroll-area.tsx | 31 +++ .../mothership-chat/mothership-chat.tsx | 12 +- .../attached-files-list.tsx | 2 +- .../home/components/user-input/user-input.tsx | 3 + .../sim/scripts/test-browser-extension-e2e.ts | 218 ++++++++++++++++-- .../secret-reveal/secret-reveal.tsx | 1 + 12 files changed, 389 insertions(+), 32 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/composer-scroll-area.tsx diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 3d949981a69..1006ab1d6b7 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -5,11 +5,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { createElement } from 'react' import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test' import { pageSnapshotSchema } from '@sim/browser-extension-protocol' +import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { renderToStaticMarkup } from 'react-dom/server' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const logger = createLogger('BrowserExtensionE2E') @@ -18,6 +21,9 @@ if (!reportPath) throw new Error('Set EXTENSION_REPORT_PATH to the output JSON r const output = resolve(root, reportPath) const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] +const secretReveal = renderToStaticMarkup( + createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' }) +) const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, @@ -55,6 +61,7 @@ const fixture = Bun.serve({
EDITABLE_SENTINEL
CSS_HIDDEN_SENTINEL
+

Credential instructions

${secretReveal}visible-code-control
`, { headers: { 'content-type': 'text/html' } } @@ -129,12 +136,17 @@ try { await signIn.close() const panel: Page = await context.newPage() // Observe dispatch before the bridge so background metadata queries stay outside each action. - await panel.addInitScript(() => { + const observeDispatch = () => { window.addEventListener('message', () => { document.documentElement.dataset.queryCountAtDispatch = document.documentElement.dataset.browserQueryCount ?? '0' }) - }) + } + const panelBundle = resolve(extensionPath, 'sidepanel.js') + await writeFile( + panelBundle, + `(${observeDispatch.toString()})();\n${await readFile(panelBundle, 'utf8')}` + ) await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`) await panel.getByTitle('Sim chat').waitFor() const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached')) @@ -231,9 +243,11 @@ try { 'A typed sim/ link opens the Sim link resolver on the configured origin', async () => { const tab = await page.context().newPage() - for (const path of ['handbook', 'jira/123?view=board']) { - await tab.goto(`http://sim/${path}`) - assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) + for (const scheme of ['http', 'https']) { + for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) { + await tab.goto(`${scheme}://sim/${path}`) + assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) + } } await tab.close() } @@ -310,6 +324,7 @@ try { await check('Page extraction excludes secrets and strips URL query and fragment', async () => { const [snapshot] = await snapshots() assert(snapshot.text.includes('visible-reference-482'), 'Visible page content missing') + assert(snapshot.text.includes('visible-code-control'), 'Ordinary code was excluded') for (const sentinel of [ 'PASSWORD_SENTINEL', 'TEXTAREA_SENTINEL', @@ -318,6 +333,7 @@ try { 'CSS_HIDDEN_SENTINEL', 'ARIA_SENTINEL', 'SCRIPT_SENTINEL', + 'SECRET_REVEAL_SENTINEL', ]) { assert(!snapshot.text.includes(sentinel), `Captured ${sentinel}`) } @@ -668,6 +684,87 @@ try { await parentCdp.detach() } ) + await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => { + for (const url of [ + `${origin}/workspace/example`, + `${origin}/extension-notes`, + `${origin}/workspace/%E2%9C%93`, + `${pageOrigin}/ticket/100%done`, + ]) { + const before = (await snapshots()).length + await page.goto(`${url}?token=URL_SECRET#FRAGMENT_SECRET`) + await page.bringToFront() + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + const snapshot = (await snapshots()).at(-1) + assert(snapshot?.url === url, 'Page URL retained private parameters') + assert(snapshot.text.includes('visible-reference-482'), 'Visible page content is missing') + assert(!snapshot.text.includes('SENTINEL'), 'Sensitive page content was captured') + } + }) + await check('Embedded chat and malformed Sim paths fail closed', async () => { + for (const path of [ + '/extension', + '/extension/workspace', + '/extension/o/organization', + '/%65xtension', + '/%65xtension/workspace', + '/extension%2fworkspace', + '/extension%ZZ/workspace', + ]) { + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + const previous = (await snapshots()).length + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(previous + 1) + await expect(panel.getByRole('status')).toHaveText('') + const before = (await snapshots()).length + await page.goto(`${origin}${path}`) + await page.bringToFront() + await capture() + await panel.getByRole('status').filter({ hasText: 'This page cannot be attached.' }).waitFor() + assert((await snapshots()).length === before, 'An excluded Sim path was captured') + } + }) + await check( + 'Revealed credentials stay excluded from exact and surrounding selections', + async () => { + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + const before = (await snapshots()).length + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + await expect(panel.getByRole('status')).toHaveText('') + await page + .locator('#secret-reveal code') + .first() + .evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + }) + await capture('Selection') + await expect(panel.getByRole('status')).toContainText('could not be read') + assert((await snapshots()).length === before + 1, 'A revealed credential was attached') + await page.locator('#secret-reveal').evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + }) + await capture('Selection') + await expect.poll(async () => (await snapshots()).length).toBe(before + 2) + const snapshot = (await snapshots()).at(-1) + assert(snapshot, 'Missing surrounding selection snapshot') + assert(snapshot.text.includes('Credential instructions'), 'Public selection text is missing') + assert(snapshot.text.includes('visible-code-control'), 'Ordinary selected code was excluded') + assert( + !snapshot.text.includes('SECRET_REVEAL_SENTINEL'), + 'A surrounding selection leaked the credential' + ) + } + ) await check( 'Site access stays modal without violating the packaged security policy', async () => { diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index 9a8ff2b651c..dbd5d253f50 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -21,14 +21,23 @@ export async function captureActivePage( if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { throw new Error('The page changed before reading. Attach it again.') } + let pathname = url.pathname + if (url.origin === SIM_ORIGIN) { + try { + pathname = decodeURIComponent(pathname) + } catch { + throw new Error('This page cannot be attached.') + } + } if ( !['http:', 'https:'].includes(url.protocol) || - url.origin === SIM_ORIGIN || + (url.origin === SIM_ORIGIN && + (pathname === '/extension' || pathname.startsWith('/extension/'))) || url.username || url.password || tab.incognito ) { - throw new Error('Choose a regular web page outside Sim to attach.') + throw new Error('This page cannot be attached.') } const results = await chrome.scripting.executeScript({ target: expected diff --git a/apps/sim/app/extension/[workspaceId]/chat.tsx b/apps/sim/app/extension/[workspaceId]/chat.tsx index 8b38a977634..f1f5324a08b 100644 --- a/apps/sim/app/extension/[workspaceId]/chat.tsx +++ b/apps/sim/app/extension/[workspaceId]/chat.tsx @@ -169,12 +169,10 @@ export function ExtensionChat({ workspaceId, workspaceName }: ExtensionChatProps draftScopeKey={`extension:${userId}:${workspaceId}:${selectedChatId ?? 'new'}`} composerAttachments={composer.attachments} composerResourceActions={composer.resourceActions} + composerToolbarActions={composer.toolbarActions} composerModelSelectorOptions={composer.modelSelectorOptions} layout='copilot-view' - className={cn( - 'h-auto min-h-0', - selectedChatId || chat.messages.length > 0 ? 'flex-1' : 'shrink-0' - )} + className={cn('h-auto min-h-0', (selectedChatId || chat.messages.length > 0) && 'flex-1')} /> + connected ? ( + + + requestAction('capture-page')} + aria-label='Attach current page' + /> + + Attach current page + + ) : undefined, + [connected, requestAction] + ) return { attachments, resourceActions, + toolbarActions, modelSelectorOptions: MODEL_SELECTOR_OPTIONS, prepareSubmission, onSubmitted: clearSnapshots, diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx index f9b7faa735c..f99b6155c19 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx @@ -35,6 +35,7 @@ import { organizationSearchUrlKeys, } from '@/app/o/[organizationId]/search/search-params' import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel' +import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area' import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context' import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat' @@ -94,6 +95,7 @@ interface OrganizationConversationProps { header: ReactNode attachments?: ReactNode resourceActions?: ReactNode + toolbarActions?: ReactNode modelSelectorOptions?: ModelSelectorOptions onChatIdResolved: (chatId: string) => void onNavigateToChat: (chatId: string) => void @@ -453,6 +455,7 @@ function OrganizationConversationContent({ compact={Boolean(panelOptions)} attachments={panelOptions?.attachments} resourceActions={panelOptions?.resourceActions} + toolbarActions={panelOptions?.toolbarActions} modelSelectorOptions={panelOptions?.modelSelectorOptions} onPlanChange={ !hasChat && destinationMode === 'agent' && planEnabled @@ -535,7 +538,7 @@ function OrganizationConversationContent({

How can I help?

-
{composer}
+ {composer} ) : ( )} {!imagesOnly && contextPicker('skills', Slash, 'Skills')} + {toolbarActions} {onPlanChange && ( +} + +/** Keeps the complete composer reachable when its contents exceed the available chat height. */ +export function ComposerScrollArea({ + children, + className, + onAnimationEnd, +}: ComposerScrollAreaProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef) + + return ( +
+ {children} +
+ ) +} 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 81025bd01fb..21a1a1689fe 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 @@ -34,6 +34,7 @@ import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar' import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area' import { assistantMessageHasRenderableContent, getOrchestratorMessageText, @@ -87,6 +88,7 @@ interface MothershipChatProps { composer?: ReactNode composerAttachments?: ReactNode composerResourceActions?: ReactNode + composerToolbarActions?: ReactNode composerModelSelectorOptions?: ModelSelectorOptions messages: ChatMessage[] isSending: boolean @@ -173,7 +175,7 @@ const LAYOUT_STYLES = { userBubble: 'max-w-[85%] md:max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', assistantRow: 'group/msg', - footer: 'shrink-0 px-3 pb-[max(12px,env(safe-area-inset-bottom))] md:px-[24px] md:pb-[16px]', + footer: 'px-3 pb-[max(12px,env(safe-area-inset-bottom))] md:px-[24px] md:pb-[16px]', footerInner: 'mx-auto max-w-chat', }, 'copilot-view': { @@ -185,7 +187,7 @@ const LAYOUT_STYLES = { attachmentWidth: 'max-w-[85%]', userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', assistantRow: 'group/msg', - footer: 'shrink-0 px-3 pb-3', + footer: 'px-3 pb-3', footerInner: '', }, } as const @@ -392,6 +394,7 @@ export function MothershipChat({ composer, composerAttachments, composerResourceActions, + composerToolbarActions, composerModelSelectorOptions, messages: messagesProp, isSending, @@ -1027,7 +1030,7 @@ export function MothershipChat({ )} -
@@ -1050,6 +1053,7 @@ export function MothershipChat({ onSubmit={onSubmit} attachments={composerAttachments} resourceActions={composerResourceActions} + toolbarActions={composerToolbarActions} modelSelectorOptions={composerModelSelectorOptions} isSending={isStreamActive} onStopGeneration={onStopGeneration} @@ -1061,7 +1065,7 @@ export function MothershipChat({ /> ))}
- + ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx index e0f6a82e450..aa9a756bb20 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx @@ -170,7 +170,7 @@ export const AttachedFilesList = React.memo(function AttachedFilesList({
void @@ -98,6 +99,7 @@ const UserInputImpl = forwardRef(function UserI { attachments, resourceActions, + toolbarActions, modelSelectorOptions, requestMode = 'agent', onPlanChange, @@ -680,6 +682,7 @@ const UserInputImpl = forwardRef(function UserI Skills + {toolbarActions} {onPlanChange && ( { + await shell.setViewportSize({ width, height }) + const session = await browserContext.newCDPSession(shell) + try { + const { windowId } = await session.send('Browser.getWindowForTarget') + // OOPIF hit testing uses the native content bounds, including below the browser toolbar. + await session.send('Browser.setContentsSize', { windowId, width, height }) + } finally { + await session.detach() + } + } await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`) const frame = () => { const app = shell.frames().find((candidate) => candidate.url().startsWith(base.origin)) @@ -410,20 +421,24 @@ try { }) }, base.origin) await observeCaptureReceipts() - const capture = async (mode: 'page' | 'selection' = 'page') => { + const capture = async (mode: 'page' | 'selection' | 'shortcut' = 'page') => { await source.bringToFront() const before = Number(await shell.locator('body').getAttribute('data-capture-receipts')) - await frame().getByRole('button', { name: 'Add resources', exact: true }).press('Enter') - await frame() - .getByRole('menuitem', { - name: mode === 'page' ? 'Current page' : 'Selected text', - exact: true, - }) - .press('Enter') + if (mode === 'shortcut') { + await frame().getByRole('button', { name: 'Attach current page', exact: true }).press('Enter') + } else { + await frame().getByRole('button', { name: 'Add resources', exact: true }).press('Enter') + await frame() + .getByRole('menuitem', { + name: mode === 'page' ? 'Current page' : 'Selected text', + exact: true, + }) + .press('Enter') + } await expect(shell.locator('body')).toHaveAttribute('data-capture-receipts', String(before + 1)) } const attachments = () => frame().getByRole('group', { name: 'Page attached to next message' }) - const attachFile = async () => { + const attachFile = async (name = 'extension-notes.txt') => { const completion = shell.waitForResponse( (response) => response.request().method() === 'POST' && @@ -433,7 +448,7 @@ try { await frame() .locator('input[type="file"]') .setInputFiles({ - name: 'extension-notes.txt', + name, mimeType: 'text/plain', buffer: Buffer.from('This synthetic file belongs only to extension QA.'), }) @@ -443,9 +458,7 @@ try { const asset = toRecord(toRecord(toRecord(body).data).result) assert.equal(typeof asset.key, 'string') trackUploadedFile(String(asset.key)) - await expect( - frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }) - ).toBeEnabled() + await expect(frame().getByRole('button', { name: `Remove ${name}`, exact: true })).toBeEnabled() } await check( 'Capture, preview, removal, and the three-page cap use the real composer', @@ -492,7 +505,7 @@ try { await navigate(`${base.origin}/extension/${alphaId}`) await expect(frame().getByRole('textbox', { name: 'Message', exact: true })).toBeVisible() for (const width of [320, 390]) { - await shell.setViewportSize({ width, height: 844 }) + await setPanelViewport(width, 844) await expect .poll(() => shell.evaluate( @@ -733,7 +746,51 @@ try { async () => { await capture() await attachFile() + await setPanelViewport(320, 844) + await capture() + await capture() + const geometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(geometry.contentHeight > 102, 'The fixture did not exercise multiple attachment rows') + assert( + geometry.contentHeight <= geometry.height + 1, + 'Desktop attachments require nested scrolling' + ) await screenshot('mixed-attachments') + const device = await browserContext.newCDPSession(frame()) + try { + await device.send('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 1, + }) + await expect + .poll(() => frame().evaluate(() => matchMedia('(pointer: coarse)').matches)) + .toBe(true) + const touchGeometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(touchGeometry.height <= 102, 'Touch attachments lost their height limit') + assert( + touchGeometry.contentHeight > touchGeometry.height, + 'Touch overflow fixture did not wrap' + ) + await screenshot('touch-attachments') + } finally { + await device.send('Emulation.setTouchEmulationEnabled', { enabled: false }) + await device.detach() + } + await attachments().last().getByRole('button', { name: 'Remove page context' }).click() + await attachments().last().getByRole('button', { name: 'Remove page context' }).click() + await setPanelViewport(390, 844) await attachments().first().getByRole('button', { name: 'Remove page context' }).click() await expect( frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }) @@ -1243,6 +1300,136 @@ try { await screenshot('organization-first-open-capture') } ) + await check('The current-page shortcut attaches in both shared composers', async () => { + for (const path of [`/extension/${alphaId}`, `/extension/o/${organizationId}`]) { + await navigate(`${base.origin}${path}`) + await expect(attachments()).toHaveCount(0) + await capture('shortcut') + await expect(attachments()).toHaveCount(1) + await attachments() + .first() + .getByRole('button', { name: /^Review page context:/ }) + .click() + const preview = frame().getByRole('dialog', { name: 'Page context', exact: true }) + await expect(preview.locator('pre')).toContainText(captureText) + await preview.getByRole('button', { name: 'Close', exact: true }).click() + await frame().getByRole('button', { name: 'Attach current page', exact: true }).hover() + await expect(frame().getByRole('tooltip', { name: 'Attach current page' })).toBeVisible() + await screenshot(path.includes('/o/') ? 'shortcut-organization-agent' : 'shortcut-workspace') + await attachments().first().getByRole('button', { name: 'Remove page context' }).click() + } + }) + await check( + 'Large attachment stacks keep the composer reachable without nested desktop scrolling', + async () => { + const names = Array.from( + { length: 20 }, + (_, index) => `extension-qa-long-attachment-name-${index + 1}.txt` + ) + const endpoint = `${base.origin}/api/mothership/chat` + for (const [name, path, inputLabel, sendLabel] of [ + ['workspace-new', `/extension/${alphaId}`, 'Message', 'Send message'], + ['workspace-history', `/extension/${alphaId}?chat=${historyId}`, 'Message', 'Send message'], + ['organization-new', `/extension/o/${organizationId}`, 'Ask Sim', 'Send'], + ] as const) { + await navigate(`${base.origin}${path}`) + await setPanelViewport(320, 844) + await capture('shortcut') + for (const filename of names) await attachFile(filename) + const geometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(geometry.contentHeight > 844, 'The fixture did not exceed the panel height') + assert( + geometry.contentHeight <= geometry.height + 1, + 'Desktop attachments gained nested scrolling' + ) + await shell.bringToFront() + await shell.mouse.move(160, 760) + await expect + .poll(() => + attachments() + .first() + .evaluate((chip) => chip.getBoundingClientRect().top) + ) + .toBeGreaterThanOrEqual(0) + await screenshot(`large-attachments-${name}-top`) + await shell.mouse.wheel(0, 2000) + const send = frame().getByRole('button', { name: sendLabel, exact: true }) + await expect + .poll( + () => + send.evaluate((button) => { + const rect = button.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.bottom <= innerHeight && + button.contains( + document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + ) + ) + }), + { message: `${name}: scrolling must expose Send inside the panel` } + ) + .toBe(true) + const editor = frame().getByRole('textbox', { name: inputLabel, exact: true }) + assert( + await editor.evaluate((element) => { + const rect = element.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.bottom <= innerHeight && + element.contains( + document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + ) + ) + }), + `${name}: the editor is clipped` + ) + assert( + await frame() + .getByRole('button', { name: 'New chat', exact: true }) + .evaluate((button) => { + const rect = button.getBoundingClientRect() + return rect.top >= 0 && rect.bottom <= innerHeight + }), + `${name}: scrolling hid the extension header` + ) + await screenshot(`large-attachments-${name}-bottom`) + await browserContext.route(endpoint, (route) => + route.fulfill({ status: 403, json: { error: 'QA attachment capacity admission check' } }) + ) + try { + const request = browserContext.waitForEvent('request', { + predicate: (entry) => entry.url() === endpoint && entry.method() === 'POST', + }) + await editor.fill('Use all attached context.') + await send.click() + const payload = toRecord((await request).postDataJSON()) + assert(Array.isArray(payload.fileAttachments), 'Missing file attachments') + assert.deepEqual( + payload.fileAttachments.map((file) => toRecord(file).filename).sort(), + [...names].sort() + ) + assert( + Array.isArray(payload.contexts) && payload.contexts.length === 1, + 'The attached page was lost' + ) + await expect( + frame().getByText('QA attachment capacity admission check', { exact: false }) + ).toBeVisible() + } finally { + await browserContext.unroute(endpoint) + } + } + await setPanelViewport(390, 844) + await navigate(`${base.origin}/extension/o/${organizationId}`) + } + ) if (liveChat) { await check( 'Organization live admission preserves queued pages, files, and the admitted conversation mode', @@ -1283,6 +1470,9 @@ try { await screenshot('organization-mode-file-recovery') await frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }).click() await expect(frame().getByText(/Remove unsupported files/)).not.toBeVisible() + await attachments().first().getByRole('button', { name: 'Remove page context' }).click() + await capture('shortcut') + await expect(attachments()).toHaveCount(1) await screenshot('organization-assistant-page') if (!liveChat) await shell.route('**/api/mothership/chat', (route) => diff --git a/packages/emcn/src/components/secret-reveal/secret-reveal.tsx b/packages/emcn/src/components/secret-reveal/secret-reveal.tsx index 1a87172b74d..3be9a4d6145 100644 --- a/packages/emcn/src/components/secret-reveal/secret-reveal.tsx +++ b/packages/emcn/src/components/secret-reveal/secret-reveal.tsx @@ -44,6 +44,7 @@ export function SecretReveal({ value, className, redacted = false }: SecretRevea return (
Date: Mon, 2 Nov 2026 10:37:05 -0800 Subject: [PATCH 5/5] fix(extension): disclose link handling and keep gesture checks passive --- apps/extension/scripts/test-e2e.ts | 13 +++++++++++-- apps/sim/app/(landing)/privacy/privacy-content.tsx | 8 +++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 1006ab1d6b7..cb63a401c40 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -659,8 +659,17 @@ try { expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, userGesture: false, }) - await expect(panel.locator('body')).toHaveAttribute('data-observed-actions', '1') - await expect(panel.locator('body')).toHaveAttribute('data-action-queries', '0') + await expect + .poll(async () => { + const { result } = await parentCdp.send('Runtime.evaluate', { + expression: + '[document.body.dataset.observedActions, document.body.dataset.actionQueries]', + returnByValue: true, + userGesture: false, + }) + return result.value + }) + .toEqual(['1', '0']) assert((await snapshots()).length === before, 'Captured without a user gesture') await app.evaluate(({ action, targetOrigin }) => parent.postMessage(action, targetOrigin), { action: { ...action, sessionId: generateId() }, diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 1abcb53b72d..b649fa96e4d 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -44,7 +44,7 @@ function richText(content: string): ReactNode { export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)', - lastUpdated: 'October 31, 2026', + lastUpdated: 'November 2, 2026', intro: [ { kind: 'paragraph', @@ -833,6 +833,12 @@ export const PRIVACY_CONFIG: LegalPageConfig = { 'The hosted chat uses Your normal Sim Account and authenticated session, together with the operational diagnostics and consent controls described in this Privacy Policy. The extension document and its page attachments are excluded from session replay. Captured page content is not used for advertising.' ), }, + { + kind: 'paragraph', + content: richText( + 'When You use the sim address-bar keyword, the extension sends the typed link name, before any path values or query, to Sim with Your authenticated session to suggest Your organization’s links. Suggestions are cached in memory and are not written to extension storage. Opening a sim/ shortcut sends its path and query to Sim to resolve the destination, then opens that destination. Sim records each link’s visit count and last-visited time. These requests also use the operational diagnostics described in this Privacy Policy. The extension does not read Your browser history or unrelated address-bar input.' + ), + }, { kind: 'subheading', text: 'Chrome Web Store Limited Use',