Skip to content

Commit 900afc3

Browse files
feat(tests): workflow tests as a workspace resource (#8773)
* fix(chat): send entitlements and agent mode from the public chat API `/api/v2/chat` (what `sim chat` uses) stopped sending `entitlements` and `mode` in #8208, so CLI and API chats got no entitlement-gated tools and every CLI service refused them with "CLI services require agent mode". The route now computes entitlements per turn like the workspace chat and sends `mode: 'agent'`. Its test still mocked the old entitlements function and listed `mode` as a forbidden legacy field; both are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * feat(tests): workflow tests as a workspace resource Workflow tests are a workspace resource whose source is a plain vitest file, `tests/<name>.test.js`, owned by the test (workspace_files.context = 'test'). Sim creates a test's metadata with the tests tool and writes its cases with the file tools; every write is collected in the sandbox and refused if the file does not load. - Runner: test files run in the isolated-vm sandbox against draft or deployed workflows. `runWorkflow` executes real runs; `mockBlock`, `mockTool` (Agent tool calls) and `spyOnBlock` reach blocks in the tested workflow and in every child workflow it runs, matched by name as each workflow starts. `.mockSampleOutput()` builds outputs shaped like the real block or tool. `toMatchRubric` asks a model judge for pass or fail. - Runs record live per-case progress, the source hash, and the deployment of every workflow they ran, so results show as out of date once the test or a workflow changes. - UI: Tests page and test page (Edit / Split / Preview over the file, the preview a dashboard of the selected run), a test resource type in chat, and a Tests sidebar entry behind the `workflow-tests` flag. - Owned files never open as file tabs in chat: only workspace files and chat uploads do. - Migration 0400 adds workflow_test and workflow_test_run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * feat(tests): pick a run from a dropdown and open what each run ran against The test page shows one run at a time, chosen from a run picker with status dots and Draft / Out of date chips. Case statuses use the Badge status chip. Each ran-against entry records one execution, so a draft row opens the workflow snapshot from that run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): type errors and tests broken by workflow tests - Narrow the test principal to the kinds workflow_tests.run admits before handing it to executeWorkflow. - Select progress with the latest-run rows, guard file upsert ids in the tab filter, and set the sandbox Event polyfills through Reflect. - Cover the tests tool in the management tool contract, expect content writes to reach test files, and stub test availability in the payload test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): address review on redaction, staleness, and the harness - Redact each run's resolved secrets from what returns to the sandbox (output, errors, mocked tool inputs); mocked tools get only declared params. - Custom blocks no longer receive the consumer's test hooks. - Draft runs go stale when the draft changes; children a run calls are recorded in ran-against. - Test cases commit in the same transaction as the source file write. - Harness: runWorkflow is rejected in suite hooks, a timed-out case stops the file, and expect.assertions/hasAssertions are supported. - Insert run rows in one statement and start each run's clock with its file; check bans before each workflow run; restrict owned-file access to Copilot delegation; validate names in the tool contract. - Delete soft-deletes the test file and removes the chat tab; a finished run shows its own cases; polling at 3s on a separate read bucket; list error state; store reset; tests stay in the org Add Resource picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): reserve run slots, await pending assertions, keep dynamic tool args - Each test workflow run reserves and releases an execution slot. - A case waits for assertions it did not await and fails if one fails. - Mocked MCP and custom tools keep the arguments their schema declares. - A closed session refuses starts still awaiting their lookups. - Stable refresh callback; scroll fade on the results pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): keep test sources out of file tabs, refresh after runs, reopen tests - File-edit tool results mark a non-tab file `fileTab: false`, and the browser skips promoting it. - Idle test pages poll every 15s so runs started elsewhere appear; a Mothership run returns its tests as resource changes. - open_resource accepts test resources through an authorized read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): refresh test tabs after a run, keep saved edits successful - A finished Mothership run refreshes its tests instead of upserting tabs, so a test deleted mid-run does not come back. - A failed file-tab lookup after a saved edit opens no tab instead of reporting the edit as failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): starter source imports every test helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * build(tests): add @vitest/expect and @vitest/spy for the sandbox bundle The vitest-expect sandbox bundle builds from these packages; rebuilt with the Reflect-based event polyfills. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * build(tests): tell knip the sandbox bundle uses @vitest/expect and @vitest/spy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * feat(tests): name MCP and custom tool mocks by server and title MCP tool ids embed the server's database id, which changes when a server is re-added or a workspace is forked, so a stale mock silently stopped matching and the real server was called. Tests now name workspace tools the way the workspace does: mockTool({ mcp: 'Server', tool: 'name' }) resolved per run (failing on an unknown or ambiguous server), and mockTool({ customTool: 'Title' }) matched case- and space-insensitively. Raw mcp- and custom_ ids are rejected; built-in catalog ids are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): reserve the run name, hold Run for unsaved edits, cancel judges on close - A test named "run" collided with the static run endpoint, so its detail page got a 405; the name is now reserved. - Run is disabled while the open editor holds edits the server has not saved (including a refused save), so a run never uses the previous source. - toMatchRubric model calls are aborted when the sandbox run ends, so a stopped test no longer keeps calling or billing the judge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R * fix(tests): fail a run whose selected case names no test in the file A renamed or misspelled `only` path skipped every case, and the run was then saved as passing. The harness now rejects unknown names, so the run is recorded as an error with the names it could not find. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 66e535d commit 900afc3

144 files changed

Lines changed: 37125 additions & 230 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/v2/chat/route.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ vi.mock('@/lib/mothership/chat/messages-store', () => mothershipChatMessagesMock
8484
vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock)
8585

8686
vi.mock('@/lib/mothership/entitlements', () => ({
87-
computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]),
87+
computeEntitlements: vi.fn().mockResolvedValue([]),
8888
}))
8989

9090
vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock)
@@ -417,8 +417,9 @@ describe('POST /api/v2/chat', () => {
417417
workspaceId: 'workspace-1',
418418
chatId: SERVER_ISSUED_CHAT_ID,
419419
integrationCatalog: { mcpServerIds: [] },
420+
mode: 'agent',
420421
})
421-
for (const legacy of ['messages', 'mode', 'isHosted', 'workspaceContext', 'userPermission']) {
422+
for (const legacy of ['messages', 'isHosted', 'workspaceContext', 'userPermission']) {
422423
expect(payload).not.toHaveProperty(legacy)
423424
}
424425
expect(options).toMatchObject({

‎apps/sim/app/api/v2/chat/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
buildPersistedUserMessage,
3737
} from '@/lib/mothership/chat/persisted-message'
3838
import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants'
39+
import { computeEntitlements } from '@/lib/mothership/entitlements'
3940
import {
4041
type CopilotEnvironmentContext,
4142
createCopilotEnvironmentContext,
@@ -343,6 +344,8 @@ export const POST = withRouteHandler(
343344
chatId,
344345
messageId,
345346
integrationCatalog: { mcpServerIds: [] },
347+
mode: 'agent',
348+
entitlements: await computeEntitlements({ principal, workspaceId }),
346349
...(effort ? { effort } : {}),
347350
}
348351

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getWorkflowTestContract } from '@/lib/api/contracts/workflow-tests'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
9+
import { getWorkflowTestDetail } from '@/lib/workflow-tests/application/tests'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getWorkflowTestContract,
13+
auth: internalSessionAuth,
14+
operation: workflowTestOperations.read,
15+
rateLimit: internalRateLimits.user({
16+
bucketName: 'workflow-tests-read',
17+
// A running test polls these every 3s from each open page and chat tab.
18+
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params, query }) => ({
22+
workspaceId: params.id,
23+
name: params.name,
24+
version: query.version,
25+
}),
26+
useCase: getWorkflowTestDetail,
27+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getWorkflowTestRunContract } from '@/lib/api/contracts/workflow-tests'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
9+
import { getWorkflowTestRunDetail } from '@/lib/workflow-tests/application/tests'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getWorkflowTestRunContract,
13+
auth: internalSessionAuth,
14+
operation: workflowTestOperations.read,
15+
rateLimit: internalRateLimits.user({
16+
bucketName: 'workflow-tests-read',
17+
// A running test polls these every 3s from each open page and chat tab.
18+
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params }) => ({
22+
workspaceId: params.id,
23+
name: params.name,
24+
runId: params.runId,
25+
}),
26+
useCase: getWorkflowTestRunDetail,
27+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { listWorkflowTestsContract } from '@/lib/api/contracts/workflow-tests'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
9+
import { listWorkflowTests } from '@/lib/workflow-tests/application/tests'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: listWorkflowTestsContract,
13+
auth: internalSessionAuth,
14+
operation: workflowTestOperations.read,
15+
rateLimit: internalRateLimits.user({
16+
bucketName: 'workflow-tests-read',
17+
// A running test polls these every 3s from each open page and chat tab.
18+
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params, query }) => ({ workspaceId: params.id, version: query.version }),
22+
useCase: listWorkflowTests,
23+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { runWorkflowTestsContract } from '@/lib/api/contracts/workflow-tests'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
9+
import { startWorkflowTestRuns } from '@/lib/workflow-tests/application/run-tests'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: runWorkflowTestsContract,
13+
auth: internalSessionAuth,
14+
operation: workflowTestOperations.run,
15+
rateLimit: internalRateLimits.user({ bucketName: 'workflow-tests' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
18+
useCase: startWorkflowTestRuns,
19+
})

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ async function render(
232232
<FeatureFlagsProvider
233233
flags={{
234234
dashboards: false,
235+
'workflow-tests': false,
235236
'mothership-model-selector': mocks.advanced,
236237
'mothership-plan-mode': mocks.plan,
237238
}}
@@ -309,6 +310,7 @@ it.each([
309310
<FeatureFlagsProvider
310311
flags={{
311312
dashboards: false,
313+
'workflow-tests': false,
312314
'mothership-model-selector': false,
313315
'mothership-plan-mode': planEnabled,
314316
}}
@@ -403,6 +405,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
403405
<FeatureFlagsProvider
404406
flags={{
405407
dashboards: false,
408+
'workflow-tests': false,
406409
'mothership-model-selector': mocks.advanced,
407410
'mothership-plan-mode': mocks.plan,
408411
}}

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
88
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
99
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
1010
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
11+
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1112
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1213
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1314
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
@@ -55,24 +56,27 @@ export default async function OrganizationLayout({
5556
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
5657
redirect(WORKSPACE_SETTINGS_PATH)
5758

58-
const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] = await Promise.all([
59-
prefetchOrganizationSidebar(
60-
queryClient,
61-
organizationId,
62-
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
63-
getActiveOrganizationId(session)
64-
),
65-
isMothershipModelSelectorEnabled(),
66-
isPlanModeEnabled(),
67-
isDashboardsEnabled(organizationId),
68-
])
59+
const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled, workflowTestsEnabled] =
60+
await Promise.all([
61+
prefetchOrganizationSidebar(
62+
queryClient,
63+
organizationId,
64+
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
65+
getActiveOrganizationId(session)
66+
),
67+
isMothershipModelSelectorEnabled(),
68+
isPlanModeEnabled(),
69+
isDashboardsEnabled(organizationId),
70+
isWorkflowTestsEnabled(organizationId),
71+
])
6972
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
7073

7174
return (
7275
<HydrationBoundary state={dehydrate(queryClient)}>
7376
<FeatureFlagsProvider
7477
flags={{
7578
dashboards: dashboardsEnabled,
79+
'workflow-tests': workflowTestsEnabled,
7680
'mothership-model-selector': modelSelectorEnabled,
7781
'mothership-plan-mode': planModeEnabled,
7882
}}

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { Music } from '@sim/emcn/icons'
55
import dynamic from 'next/dynamic'
66
import type { FileDownloadSource } from '@/lib/uploads/client/download'
@@ -120,6 +120,12 @@ interface FileViewerProps {
120120
status: 'idle' | 'saving' | 'saved' | 'error',
121121
retry?: () => Promise<void>
122122
) => void
123+
/** Receives why a save was refused; text files only. */
124+
onSaveError?: (error: unknown) => void
125+
/** Replaces the file's own preview pane, for an owner with a richer view of the file. */
126+
preview?: ReactNode
127+
/** Which side of a split the source takes; `end` puts it after the preview. */
128+
sourceSide?: 'start' | 'end'
123129
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
124130
downloadSourceRef?: React.MutableRefObject<FileDownloadSource | null>
125131
discardRef?: React.MutableRefObject<(() => void) | null>
@@ -175,6 +181,9 @@ function FileViewerContent({
175181
autoFocus,
176182
onDirtyChange,
177183
onSaveStatusChange,
184+
onSaveError,
185+
preview,
186+
sourceSide,
178187
saveRef,
179188
downloadSourceRef,
180189
discardRef,
@@ -253,6 +262,9 @@ function FileViewerContent({
253262
autoFocus={autoFocus}
254263
onDirtyChange={onDirtyChange}
255264
onSaveStatusChange={onSaveStatusChange}
265+
onSaveError={onSaveError}
266+
preview={preview}
267+
sourceSide={sourceSide}
256268
saveRef={saveRef}
257269
discardRef={discardRef}
258270
streamingContent={streamingContent}

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
memo,
55
type ClipboardEvent as ReactClipboardEvent,
66
type KeyboardEvent as ReactKeyboardEvent,
7+
type ReactNode,
78
useCallback,
89
useEffect,
910
useId,
@@ -405,6 +406,9 @@ interface TextEditorProps {
405406
status: 'idle' | 'saving' | 'saved' | 'error',
406407
retry?: () => Promise<void>
407408
) => void
409+
onSaveError?: (error: unknown) => void
410+
preview?: ReactNode
411+
sourceSide?: 'start' | 'end'
408412
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
409413
downloadSourceRef?: React.MutableRefObject<FileDownloadSource | null>
410414
discardRef?: React.MutableRefObject<(() => void) | null>
@@ -422,6 +426,9 @@ export const TextEditor = memo(function TextEditor({
422426
autoFocus,
423427
onDirtyChange,
424428
onSaveStatusChange,
429+
onSaveError,
430+
preview,
431+
sourceSide = 'start',
425432
saveRef,
426433
downloadSourceRef,
427434
discardRef,
@@ -503,6 +510,7 @@ export const TextEditor = memo(function TextEditor({
503510
isAgentEditing,
504511
onDirtyChange,
505512
onSaveStatusChange,
513+
onSaveError,
506514
saveRef,
507515
discardRef,
508516
})
@@ -608,7 +616,8 @@ export const TextEditor = memo(function TextEditor({
608616
if (!container) return
609617
const rect = container.getBoundingClientRect()
610618
const isRtl = getComputedStyle(container).direction === 'rtl'
611-
const sourceWidth = isRtl ? rect.right - e.clientX : e.clientX - rect.left
619+
const fromRight = isRtl !== (sourceSide === 'end')
620+
const sourceWidth = fromRight ? rect.right - e.clientX : e.clientX - rect.left
612621
const pct = (sourceWidth / rect.width) * 100
613622
setSplitPct(Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, pct)))
614623
}
@@ -626,7 +635,7 @@ export const TextEditor = memo(function TextEditor({
626635
document.body.style.cursor = ''
627636
document.body.style.userSelect = ''
628637
}
629-
}, [isResizing])
638+
}, [isResizing, sourceSide])
630639

631640
const handleSplitKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
632641
const key = readSeparatorKey(event)
@@ -639,7 +648,8 @@ export const TextEditor = memo(function TextEditor({
639648
}
640649
const container = containerRef.current
641650
const isRtl = container !== null && getComputedStyle(container).direction === 'rtl'
642-
const delta = (key === 'left' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
651+
const fromRight = isRtl !== (sourceSide === 'end')
652+
const delta = (key === 'left' ? -1 : 1) * (fromRight ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
643653
setSplitPct((current) => Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, current + delta)))
644654
}
645655

@@ -789,7 +799,10 @@ export const TextEditor = memo(function TextEditor({
789799
<div
790800
ref={containerRef}
791801
data-find-tooltip-fix
792-
className='relative flex flex-1 overflow-hidden'
802+
className={cn(
803+
'relative flex flex-1 overflow-hidden',
804+
sourceSide === 'end' && 'flex-row-reverse'
805+
)}
793806
>
794807
<style>{FIND_TOOLTIP_FIX_CSS}</style>
795808
{showEditor && (
@@ -847,16 +860,18 @@ export const TextEditor = memo(function TextEditor({
847860
isResizing && 'pointer-events-none'
848861
)}
849862
>
850-
<PreviewPanel
851-
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
852-
content={content}
853-
mimeType={file.type}
854-
filename={file.name}
855-
workspaceId={workspaceId}
856-
fileId={file.id}
857-
fileKey={file.key}
858-
isStreaming={isStreaming}
859-
/>
863+
{preview ?? (
864+
<PreviewPanel
865+
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
866+
content={content}
867+
mimeType={file.type}
868+
filename={file.name}
869+
workspaceId={workspaceId}
870+
fileId={file.id}
871+
fileKey={file.key}
872+
isStreaming={isStreaming}
873+
/>
874+
)}
860875
</div>
861876
</>
862877
)}

0 commit comments

Comments
 (0)