Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/copilot/confirm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const logger = createLogger('CopilotConfirmAPI')
// Schema for confirmation request
const ConfirmationSchema = z.object({
toolCallId: z.string().min(1, 'Tool call ID is required'),
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background'] as const, {
status: z.enum(['success', 'error', 'accepted', 'rejected', 'background', 'cancelled'] as const, {
errorMap: () => ({ message: 'Invalid notification status' }),
}),
message: z.string().optional(),
Expand Down
41 changes: 38 additions & 3 deletions apps/sim/app/api/mothership/chat/stop/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,39 @@ import { getSession } from '@/lib/auth'

const logger = createLogger('MothershipChatStopAPI')

const StoredToolCallSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
state: z.string().optional(),
params: z.record(z.unknown()).optional(),
result: z
.object({
success: z.boolean(),
output: z.unknown().optional(),
error: z.string().optional(),
})
.optional(),
display: z
.object({
text: z.string().optional(),
})
.optional(),
calledBy: z.string().optional(),
})
.nullable()

const ContentBlockSchema = z.object({
type: z.string(),
content: z.string().optional(),
toolCall: StoredToolCallSchema.optional(),
})

const StopSchema = z.object({
chatId: z.string(),
streamId: z.string(),
content: z.string(),
contentBlocks: z.array(ContentBlockSchema).optional(),
})

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

const { chatId, streamId, content } = StopSchema.parse(await req.json())
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())

const setClause: Record<string, unknown> = {
conversationId: null,
updatedAt: new Date(),
}

if (content.trim()) {
const assistantMessage = {
const hasContent = content.trim().length > 0
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0

if (hasContent || hasBlocks) {
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
role: 'assistant' as const,
content,
timestamp: new Date().toISOString(),
}
if (hasBlocks) {
assistantMessage.contentBlocks = contentBlocks
}
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export function AgentGroup({
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const hasTools = tools.length > 0
const allDone = hasTools && tools.every((t) => t.status === 'success' || t.status === 'error')
const allDone =
hasTools &&
tools.every((t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled')

const [expanded, setExpanded] = useState(!allDone)
const [mounted, setMounted] = useState(!allDone)
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { AgentGroup } from './agent-group'
export { CircleStop } from './tool-call-item'
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ function CircleCheck({ className }: { className?: string }) {
)
}

export function CircleStop({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
<rect x='6' y='6' width='4' height='4' rx='0.5' fill='currentColor' />
</svg>
)
}

interface ToolCallItemProps {
toolName: string
displayTitle: string
Expand All @@ -38,13 +54,21 @@ export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemPro
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
{status === 'executing' ? (
<Loader className='h-[16px] w-[16px] text-[var(--text-icon)]' animate />
) : status === 'cancelled' ? (
<CircleStop className='h-[16px] w-[16px] text-[var(--text-secondary)]' />
) : Icon ? (
<Icon className='h-[16px] w-[16px] text-[var(--text-icon)]' />
) : (
<CircleCheck className='h-[16px] w-[16px] text-[var(--text-icon)]' />
)}
</div>
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
<span
className={
status === 'cancelled'
? 'font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-secondary)]'
: 'font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'
}
>
{displayTitle}
</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { AgentGroup } from './agent-group'
export { AgentGroup, CircleStop } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type { ContentBlock, OptionItem, SubagentName, ToolCallStatus } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import { AgentGroup, ChatContent, Options } from './components'
import { AgentGroup, ChatContent, CircleStop, Options } from './components'

interface TextSegment {
type: 'text'
Expand All @@ -29,7 +29,11 @@ interface OptionsSegment {
items: OptionItem[]
}

type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment
interface StoppedSegment {
type: 'stopped'
}

type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment

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

Expand Down Expand Up @@ -165,6 +169,15 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
group = null
}
segments.push({ type: 'options', items: block.options })
continue
}

if (block.type === 'stopped') {
if (group) {
segments.push(group)
group = null
}
segments.push({ type: 'stopped' })
}
}

Expand Down Expand Up @@ -212,7 +225,9 @@ export function MessageContent({
case 'agent_group': {
const allToolsDone =
segment.tools.length > 0 &&
segment.tools.every((t) => t.status === 'success' || t.status === 'error')
segment.tools.every(
(t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled'
)
const hasFollowingText = segments.slice(i + 1).some((s) => s.type === 'text')
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
Expand All @@ -234,6 +249,15 @@ export function MessageContent({
<Options items={segment.items} onSelect={onOptionSelect} />
</div>
)
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-secondary)]' />
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-secondary)]'>
Stopped by user
</span>
</div>
)
}
})}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { useRouter } from 'next/navigation'
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import { BookOpen } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import {
markRunToolManuallyStopped,
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import {
FileViewer,
type PreviewMode,
Expand All @@ -18,7 +22,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useCurrentWorkflowExecution } from '@/stores/execution'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

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

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

const handleRun = useCallback(async () => {
setActiveWorkflow(workflowId)

if (isExecuting) {
markRunToolManuallyStopped(workflowId)
await handleCancelExecution()
await reportManualRunToolStop(workflowId)
return
}

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

await handleRunWorkflow()
}, [handleCancelExecution, handleRunWorkflow, isExecuting, navigateToSettings, usageExceeded])
}, [
handleCancelExecution,
handleRunWorkflow,
isExecuting,
navigateToSettings,
setActiveWorkflow,
usageExceeded,
workflowId,
])

const handleOpenWorkflow = useCallback(() => {
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
Expand Down
Loading