Skip to content

Commit 72e3c69

Browse files
committed
Fixes
1 parent 5f7b98d commit 72e3c69

File tree

31 files changed

+1562
-271
lines changed

31 files changed

+1562
-271
lines changed

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,50 @@ describe('Chat API Route', () => {
274274
)
275275
})
276276

277+
it('passes chat customizations and outputConfigs through in the API request shape', async () => {
278+
mockGetSession.mockResolvedValue({
279+
user: { id: 'user-id', email: 'user@example.com' },
280+
})
281+
282+
const validData = {
283+
workflowId: 'workflow-123',
284+
identifier: 'test-chat',
285+
title: 'Test Chat',
286+
customizations: {
287+
primaryColor: '#000000',
288+
welcomeMessage: 'Hello',
289+
imageUrl: 'https://example.com/icon.png',
290+
},
291+
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
292+
}
293+
294+
mockLimit.mockResolvedValueOnce([])
295+
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
296+
hasAccess: true,
297+
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
298+
})
299+
300+
const req = new NextRequest('http://localhost:3000/api/chat', {
301+
method: 'POST',
302+
body: JSON.stringify(validData),
303+
})
304+
const response = await POST(req)
305+
306+
expect(response.status).toBe(200)
307+
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
308+
expect.objectContaining({
309+
workflowId: 'workflow-123',
310+
identifier: 'test-chat',
311+
customizations: {
312+
primaryColor: '#000000',
313+
welcomeMessage: 'Hello',
314+
imageUrl: 'https://example.com/icon.png',
315+
},
316+
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
317+
})
318+
)
319+
})
320+
277321
it('should allow chat deployment when user has workspace admin permission', async () => {
278322
mockGetSession.mockResolvedValue({
279323
user: { id: 'user-id', email: 'user@example.com' },

apps/sim/app/api/copilot/chat/abort/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { NextResponse } from 'next/server'
33
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
44
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
55
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
6-
import { abortActiveStream } from '@/lib/copilot/request/session/abort'
6+
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/request/session'
77
import { env } from '@/lib/core/config/env'
88

99
const logger = createLogger('CopilotChatAbortAPI')
1010
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
11+
const STREAM_ABORT_SETTLE_TIMEOUT_MS = 8000
1112

1213
export async function POST(request: Request) {
1314
const { userId: authenticatedUserId, isAuthenticated } =
@@ -74,5 +75,16 @@ export async function POST(request: Request) {
7475
}
7576

7677
const aborted = await abortActiveStream(streamId)
78+
if (chatId) {
79+
const settled = await waitForPendingChatStream(chatId, STREAM_ABORT_SETTLE_TIMEOUT_MS, streamId)
80+
if (!settled) {
81+
return NextResponse.json(
82+
{ error: 'Previous response is still shutting down', aborted, settled: false },
83+
{ status: 409 }
84+
)
85+
}
86+
return NextResponse.json({ aborted, settled: true })
87+
}
88+
7789
return NextResponse.json({ aborted })
7890
}

apps/sim/app/api/mothership/chat/stop/route.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
9-
import { releasePendingChatStream } from '@/lib/copilot/request/session'
109
import { taskPubSub } from '@/lib/copilot/tasks'
1110

1211
const logger = createLogger('MothershipChatStopAPI')
@@ -60,6 +59,8 @@ const StopSchema = z.object({
6059
* POST /api/mothership/chat/stop
6160
* Persists partial assistant content when the user stops a stream mid-response.
6261
* Clears conversationId so the server-side onComplete won't duplicate the message.
62+
* The chat stream lock is intentionally left alone here; it is released only once
63+
* the aborted server stream actually unwinds.
6364
*/
6465
export async function POST(req: NextRequest) {
6566
try {
@@ -80,7 +81,6 @@ export async function POST(req: NextRequest) {
8081
.limit(1)
8182

8283
if (!row) {
83-
await releasePendingChatStream(chatId, streamId)
8484
return NextResponse.json({ success: true })
8585
}
8686

@@ -119,8 +119,6 @@ export async function POST(req: NextRequest) {
119119
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
120120
.returning({ workspaceId: copilotChats.workspaceId })
121121

122-
await releasePendingChatStream(chatId, streamId)
123-
124122
if (updated?.workspaceId) {
125123
taskPubSub?.publishStatusChanged({
126124
workspaceId: updated.workspaceId,

apps/sim/app/api/workflows/[id]/chat/status/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('Workflow Chat Status Route', () => {
122122
customizations: { theme: 'dark' },
123123
authType: 'public',
124124
allowedEmails: [],
125-
outputConfigs: {},
125+
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
126126
password: 'secret',
127127
isActive: true,
128128
},
@@ -136,5 +136,6 @@ describe('Workflow Chat Status Route', () => {
136136
expect(data.isDeployed).toBe(true)
137137
expect(data.deployment.id).toBe('chat-1')
138138
expect(data.deployment.hasPassword).toBe(true)
139+
expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }])
139140
})
140141
})

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const ExecuteWorkflowSchema = z.object({
7878
parallels: z.record(z.any()).optional(),
7979
})
8080
.optional(),
81+
triggerBlockId: z.string().optional(),
8182
stopAfterBlockId: z.string().optional(),
8283
runFromBlock: z
8384
.object({
@@ -381,6 +382,7 @@ async function handleExecutePost(
381382
includeFileBase64,
382383
base64MaxBytes,
383384
workflowStateOverride,
385+
triggerBlockId,
384386
stopAfterBlockId,
385387
runFromBlock: rawRunFromBlock,
386388
} = validation.data
@@ -484,6 +486,7 @@ async function handleExecutePost(
484486
includeFileBase64,
485487
base64MaxBytes,
486488
workflowStateOverride,
489+
triggerBlockId: _triggerBlockId,
487490
stopAfterBlockId: _stopAfterBlockId,
488491
runFromBlock: _runFromBlock,
489492
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
@@ -514,6 +517,7 @@ async function handleExecutePost(
514517
(body.useDraftState !== undefined ||
515518
body.workflowStateOverride !== undefined ||
516519
body.runFromBlock !== undefined ||
520+
body.triggerBlockId !== undefined ||
517521
body.stopAfterBlockId !== undefined ||
518522
body.selectedOutputs?.length ||
519523
body.includeFileBase64 !== undefined ||
@@ -714,6 +718,7 @@ async function handleExecutePost(
714718
sessionUserId: isClientSession ? userId : undefined,
715719
workflowUserId: workflow.userId,
716720
triggerType,
721+
triggerBlockId,
717722
useDraftState: shouldUseDraftState,
718723
startTime: new Date().toISOString(),
719724
isClientSession,
@@ -1122,6 +1127,7 @@ async function handleExecutePost(
11221127
sessionUserId: isClientSession ? userId : undefined,
11231128
workflowUserId: workflow.userId,
11241129
triggerType,
1130+
triggerBlockId,
11251131
useDraftState: shouldUseDraftState,
11261132
startTime: new Date().toISOString(),
11271133
isClientSession,

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

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export function FileViewer({
268268
}
269269

270270
if (category === 'image-previewable') {
271-
return <ImagePreview file={file} />
271+
return <ImagePreview file={file} workspaceId={workspaceId} />
272272
}
273273

274274
if (category === 'docx-previewable') {
@@ -997,8 +997,20 @@ const ZOOM_BUTTON_FACTOR = 1.2
997997

998998
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
999999

1000-
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
1001-
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${file.size}`
1000+
const ImagePreview = memo(function ImagePreview({
1001+
file,
1002+
workspaceId,
1003+
}: {
1004+
file: WorkspaceFileRecord
1005+
workspaceId: string
1006+
}) {
1007+
const {
1008+
data: fileData,
1009+
isLoading,
1010+
error: fetchError,
1011+
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
1012+
const [blobUrl, setBlobUrl] = useState<string | null>(null)
1013+
const blobUrlRef = useRef<string | null>(null)
10021014
const [zoom, setZoom] = useState(1)
10031015
const [offset, setOffset] = useState({ x: 0, y: 0 })
10041016
const isDragging = useRef(false)
@@ -1009,6 +1021,15 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
10091021

10101022
const containerRef = useRef<HTMLDivElement>(null)
10111023

1024+
const replaceBlobUrl = useCallback((nextUrl: string | null) => {
1025+
const previousUrl = blobUrlRef.current
1026+
blobUrlRef.current = nextUrl
1027+
setBlobUrl(nextUrl)
1028+
if (previousUrl && previousUrl !== nextUrl) {
1029+
URL.revokeObjectURL(previousUrl)
1030+
}
1031+
}, [])
1032+
10121033
const zoomIn = useCallback(() => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR)), [])
10131034
const zoomOut = useCallback(() => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR)), [])
10141035

@@ -1027,6 +1048,24 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
10271048
return () => el.removeEventListener('wheel', onWheel)
10281049
}, [])
10291050

1051+
useEffect(() => {
1052+
replaceBlobUrl(null)
1053+
}, [file.id, file.key, replaceBlobUrl])
1054+
1055+
useEffect(() => {
1056+
return () => {
1057+
if (blobUrlRef.current) {
1058+
URL.revokeObjectURL(blobUrlRef.current)
1059+
blobUrlRef.current = null
1060+
}
1061+
}
1062+
}, [])
1063+
1064+
useEffect(() => {
1065+
if (!fileData) return
1066+
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'image/png' })))
1067+
}, [file.type, fileData, replaceBlobUrl])
1068+
10301069
const handleMouseDown = useCallback((e: React.MouseEvent) => {
10311070
if (e.button !== 0) return
10321071
isDragging.current = true
@@ -1052,7 +1091,21 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
10521091
useEffect(() => {
10531092
setZoom(1)
10541093
setOffset({ x: 0, y: 0 })
1055-
}, [file.key])
1094+
}, [blobUrl])
1095+
1096+
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
1097+
1098+
if (error) {
1099+
return <PreviewError label='Image' error={error} />
1100+
}
1101+
1102+
if (isLoading && !blobUrl) {
1103+
return (
1104+
<div className='flex h-full items-center justify-center'>
1105+
<Skeleton className='h-[200px] w-[80%]' />
1106+
</div>
1107+
)
1108+
}
10561109

10571110
return (
10581111
<div
@@ -1071,7 +1124,7 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
10711124
}}
10721125
>
10731126
<img
1074-
src={serveUrl}
1127+
src={blobUrl ?? undefined}
10751128
alt={file.name}
10761129
className='max-h-full max-w-full select-none rounded-md object-contain'
10771130
draggable={false}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import {
4-
File as FileTool,
54
Read as ReadTool,
65
ToolSearchToolRegex,
76
WorkspaceFile,
@@ -13,6 +12,8 @@ import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
1312
import type { AgentGroupItem } from './components'
1413
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
1514

15+
const FILE_SUBAGENT_ID = 'file'
16+
1617
interface TextSegment {
1718
type: 'text'
1819
content: string
@@ -48,7 +49,7 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
4849
* group is absorbed so it doesn't render as a separate Mothership entry.
4950
*/
5051
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
51-
[FileTool.id]: WorkspaceFile.id,
52+
[FILE_SUBAGENT_ID]: WorkspaceFile.id,
5253
}
5354

5455
function isToolResultRead(params?: Record<string, unknown>): boolean {

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export function MothershipChat({
177177
blocks={msg.contentBlocks || []}
178178
fallbackContent={msg.content}
179179
isStreaming={isThisStreaming}
180-
onOptionSelect={isLastMessage ? onSubmit : undefined}
180+
onOptionSelect={isLastMessage && !isStreamActive ? onSubmit : undefined}
181181
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
182182
/>
183183
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export function Home({ chatId }: HomeProps = {}) {
228228
workspace_id: workspaceId,
229229
view: 'mothership',
230230
})
231-
stopGeneration()
231+
void stopGeneration().catch(() => {})
232232
}, [stopGeneration, workspaceId])
233233

234234
const handleSubmit = useCallback(

0 commit comments

Comments
 (0)