Skip to content

Commit 6b3ca1f

Browse files
TheodoreSpeaksTheodore Li
andauthored
fix(stop) Add stop of motehership ran workflows, persist stop messages (#3538)
* Connect play stop workflow in embedded view to workflow * Fix stop not actually stoping workflow * Fix ui not showing stopped by user * Lint fix * Plumb cancellation through system * Stopping mothership chat stops workflow * Remove extra fluff * Persist blocks on cancellation * Add root level stopped by user --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent 5d57faf commit 6b3ca1f

File tree

17 files changed

+457
-49
lines changed

17 files changed

+457
-49
lines changed

apps/sim/app/api/copilot/confirm/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const logger = createLogger('CopilotConfirmAPI')
1717
// Schema for confirmation request
1818
const ConfirmationSchema = z.object({
1919
toolCallId: z.string().min(1, 'Tool call ID is required'),
20-
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background'] as const, {
20+
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background', 'cancelled'] as const, {
2121
errorMap: () => ({ message: 'Invalid notification status' }),
2222
}),
2323
message: z.string().optional(),

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,39 @@ import { getSession } from '@/lib/auth'
88

99
const logger = createLogger('MothershipChatStopAPI')
1010

11+
const StoredToolCallSchema = z
12+
.object({
13+
id: z.string().optional(),
14+
name: z.string().optional(),
15+
state: z.string().optional(),
16+
params: z.record(z.unknown()).optional(),
17+
result: z
18+
.object({
19+
success: z.boolean(),
20+
output: z.unknown().optional(),
21+
error: z.string().optional(),
22+
})
23+
.optional(),
24+
display: z
25+
.object({
26+
text: z.string().optional(),
27+
})
28+
.optional(),
29+
calledBy: z.string().optional(),
30+
})
31+
.nullable()
32+
33+
const ContentBlockSchema = z.object({
34+
type: z.string(),
35+
content: z.string().optional(),
36+
toolCall: StoredToolCallSchema.optional(),
37+
})
38+
1139
const StopSchema = z.object({
1240
chatId: z.string(),
1341
streamId: z.string(),
1442
content: z.string(),
43+
contentBlocks: z.array(ContentBlockSchema).optional(),
1544
})
1645

1746
/**
@@ -26,20 +55,26 @@ export async function POST(req: NextRequest) {
2655
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2756
}
2857

29-
const { chatId, streamId, content } = StopSchema.parse(await req.json())
58+
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
3059

3160
const setClause: Record<string, unknown> = {
3261
conversationId: null,
3362
updatedAt: new Date(),
3463
}
3564

36-
if (content.trim()) {
37-
const assistantMessage = {
65+
const hasContent = content.trim().length > 0
66+
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0
67+
68+
if (hasContent || hasBlocks) {
69+
const assistantMessage: Record<string, unknown> = {
3870
id: crypto.randomUUID(),
3971
role: 'assistant' as const,
4072
content,
4173
timestamp: new Date().toISOString(),
4274
}
75+
if (hasBlocks) {
76+
assistantMessage.contentBlocks = contentBlocks
77+
}
4378
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
4479
}
4580

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export function AgentGroup({
3131
}: AgentGroupProps) {
3232
const AgentIcon = getAgentIcon(agentName)
3333
const hasTools = tools.length > 0
34-
const allDone = hasTools && tools.every((t) => t.status === 'success' || t.status === 'error')
34+
const allDone =
35+
hasTools &&
36+
tools.every((t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled')
3537

3638
const [expanded, setExpanded] = useState(!allDone)
3739
const [mounted, setMounted] = useState(!allDone)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { AgentGroup } from './agent-group'
2+
export { CircleStop } from './tool-call-item'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ function CircleCheck({ className }: { className?: string }) {
2424
)
2525
}
2626

27+
export function CircleStop({ className }: { className?: string }) {
28+
return (
29+
<svg
30+
width='16'
31+
height='16'
32+
viewBox='0 0 16 16'
33+
fill='none'
34+
xmlns='http://www.w3.org/2000/svg'
35+
className={className}
36+
>
37+
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
38+
<rect x='6' y='6' width='4' height='4' rx='0.5' fill='currentColor' />
39+
</svg>
40+
)
41+
}
42+
2743
interface ToolCallItemProps {
2844
toolName: string
2945
displayTitle: string
@@ -38,13 +54,21 @@ export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemPro
3854
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
3955
{status === 'executing' ? (
4056
<Loader className='h-[16px] w-[16px] text-[var(--text-icon)]' animate />
57+
) : status === 'cancelled' ? (
58+
<CircleStop className='h-[16px] w-[16px] text-[var(--text-secondary)]' />
4159
) : Icon ? (
4260
<Icon className='h-[16px] w-[16px] text-[var(--text-icon)]' />
4361
) : (
4462
<CircleCheck className='h-[16px] w-[16px] text-[var(--text-icon)]' />
4563
)}
4664
</div>
47-
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
65+
<span
66+
className={
67+
status === 'cancelled'
68+
? 'font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-secondary)]'
69+
: 'font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'
70+
}
71+
>
4872
{displayTitle}
4973
</span>
5074
</div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { AgentGroup } from './agent-group'
1+
export { AgentGroup, CircleStop } from './agent-group'
22
export { ChatContent } from './chat-content'
33
export { Options } from './options'
44
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { ContentBlock, OptionItem, SubagentName, ToolCallStatus } from '../../types'
44
import { SUBAGENT_LABELS } from '../../types'
5-
import { AgentGroup, ChatContent, Options } from './components'
5+
import { AgentGroup, ChatContent, CircleStop, Options } from './components'
66

77
interface TextSegment {
88
type: 'text'
@@ -29,7 +29,11 @@ interface OptionsSegment {
2929
items: OptionItem[]
3030
}
3131

32-
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment
32+
interface StoppedSegment {
33+
type: 'stopped'
34+
}
35+
36+
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
3337

3438
const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
3539

@@ -165,6 +169,15 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
165169
group = null
166170
}
167171
segments.push({ type: 'options', items: block.options })
172+
continue
173+
}
174+
175+
if (block.type === 'stopped') {
176+
if (group) {
177+
segments.push(group)
178+
group = null
179+
}
180+
segments.push({ type: 'stopped' })
168181
}
169182
}
170183

@@ -212,7 +225,9 @@ export function MessageContent({
212225
case 'agent_group': {
213226
const allToolsDone =
214227
segment.tools.length > 0 &&
215-
segment.tools.every((t) => t.status === 'success' || t.status === 'error')
228+
segment.tools.every(
229+
(t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled'
230+
)
216231
const hasFollowingText = segments.slice(i + 1).some((s) => s.type === 'text')
217232
return (
218233
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
@@ -234,6 +249,15 @@ export function MessageContent({
234249
<Options items={segment.items} onSelect={onOptionSelect} />
235250
</div>
236251
)
252+
case 'stopped':
253+
return (
254+
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
255+
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-secondary)]' />
256+
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-secondary)]'>
257+
Stopped by user
258+
</span>
259+
</div>
260+
)
237261
}
238262
})}
239263
</div>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { useRouter } from 'next/navigation'
66
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
77
import { BookOpen } from '@/components/emcn/icons'
88
import { WorkflowIcon } from '@/components/icons'
9+
import {
10+
markRunToolManuallyStopped,
11+
reportManualRunToolStop,
12+
} from '@/lib/copilot/client-sse/run-tool-execution'
913
import {
1014
FileViewer,
1115
type PreviewMode,
@@ -18,7 +22,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
1822
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
1923
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
2024
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
21-
import { useCurrentWorkflowExecution } from '@/stores/execution'
25+
import { useExecutionStore } from '@/stores/execution/store'
2226
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2327

2428
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
@@ -90,7 +94,9 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
9094
const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext()
9195
const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow)
9296
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
93-
const { isExecuting } = useCurrentWorkflowExecution()
97+
const isExecuting = useExecutionStore(
98+
(state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false
99+
)
94100
const { usageExceeded } = useUsageLimits()
95101

96102
useEffect(() => {
@@ -101,8 +107,12 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
101107
!isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
102108

103109
const handleRun = useCallback(async () => {
110+
setActiveWorkflow(workflowId)
111+
104112
if (isExecuting) {
113+
markRunToolManuallyStopped(workflowId)
105114
await handleCancelExecution()
115+
await reportManualRunToolStop(workflowId)
106116
return
107117
}
108118

@@ -112,7 +122,15 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
112122
}
113123

114124
await handleRunWorkflow()
115-
}, [handleCancelExecution, handleRunWorkflow, isExecuting, navigateToSettings, usageExceeded])
125+
}, [
126+
handleCancelExecution,
127+
handleRunWorkflow,
128+
isExecuting,
129+
navigateToSettings,
130+
setActiveWorkflow,
131+
usageExceeded,
132+
workflowId,
133+
])
116134

117135
const handleOpenWorkflow = useCallback(() => {
118136
router.push(`/workspace/${workspaceId}/w/${workflowId}`)

0 commit comments

Comments
 (0)