Compare commits

..

9 Commits

Author SHA1 Message Date
Emir Karabeg
077a902325 feat(copilot): editable input component 2026-01-10 17:59:49 -08:00
Emir Karabeg
fd2c4b6a7c improvement: diff controls and notifications positioning 2026-01-10 17:35:17 -08:00
Siddharth Ganesan
47209aee32 Scroll stickiness 2026-01-10 15:49:15 -08:00
Siddharth Ganesan
0350321d1b Scroll stickiness 2026-01-10 15:36:24 -08:00
Siddharth Ganesan
e3b849ad74 Fix Lint 2026-01-10 15:29:52 -08:00
Siddharth Ganesan
07433ccbb1 Fix loading 2026-01-10 15:17:05 -08:00
Waleed
ead2413b95 fix(context-menu): make divider on context menu aware of available options (#2766) 2026-01-10 14:06:51 -08:00
Vikhyath Mondreti
9a16e7c20f improvement(response): only allow singleton (#2764)
* improvement(response): only allow singleton

* respect singleton triggers and blocks in copilot

* don't show dup button for response

* fix error message
2026-01-10 12:16:32 -08:00
Siddharth Ganesan
283a521614 feat(copilot): subagents (#2731)
* fix(helm): add custom egress rules to realtime network policy (#2481)

The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.

* Add subagents

* Edit, plan, debug subagents

* Tweaks

* Message queue

* Many subagents

* Fix bugs

* Trigger request

* Overlays

* Diff in chat

* Remove context usage code

* Diff view in chat

* Options

* Lint

* Fix rendering of edit subblocks

* Add deploy mcp tools

* Add evaluator subagent

* Editor component

* Options select

* Fixes to options

* Fix spacing between options

* Subagent rendering

* Fix previews

* Plan

* Streaming

* Fix thinking scroll

* Renaming

* Fix thinking text

* Persist and load chats properly

* Diff view

* Fix lint

* Previous options should not be selectable

* Enable images

* improvement(copilot): ui/ux

* improvement(copilot): diff controls

* Fix ops bug

* Fix ops

* Stuff

* Fix config

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Martin Yankov <23098926+Lutherwaves@users.noreply.github.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-10 11:44:04 -08:00
19 changed files with 421 additions and 177 deletions

View File

@@ -802,49 +802,29 @@ export async function POST(req: NextRequest) {
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
})
// Save messages to database after streaming completes (including aborted messages)
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
// Server only updates conversationId here to avoid overwriting client's richer save.
if (currentChat) {
const updatedMessages = [...conversationHistory, userMessage]
// Save assistant message if there's any content or tool calls (even partial from abort)
if (assistantContent.trim() || toolCalls.length > 0) {
const assistantMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString(),
...(toolCalls.length > 0 && { toolCalls }),
}
updatedMessages.push(assistantMessage)
logger.info(
`[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
)
} else {
logger.info(
`[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
)
}
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
// Update chat in database immediately (without title)
await db
.update(copilotChats)
.set({
messages: updatedMessages,
updatedAt: new Date(),
...(responseId ? { conversationId: responseId } : {}),
})
.where(eq(copilotChats.id, actualChatId!))
if (responseId) {
await db
.update(copilotChats)
.set({
updatedAt: new Date(),
conversationId: responseId,
})
.where(eq(copilotChats.id, actualChatId!))
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
messageCount: updatedMessages.length,
savedUserMessage: true,
savedAssistantMessage: assistantContent.trim().length > 0,
updatedConversationId: responseId || null,
})
logger.info(
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
{
updatedConversationId: responseId,
}
)
}
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)

View File

@@ -77,6 +77,18 @@ export async function POST(req: NextRequest) {
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
// Debug: Log what we're about to save
const lastMsgParsed = messages[messages.length - 1]
if (lastMsgParsed?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
messageCount: messages.length,
lastMsgId: lastMsgParsed.id,
lastMsgContentLength: lastMsgParsed.content?.length || 0,
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
})
}
// Verify that the chat belongs to the user
const [chat] = await db
.select()

View File

@@ -462,9 +462,6 @@ export default function PlaygroundPage() {
<Avatar size='lg'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='with image'>
<Avatar size='md'>
@@ -505,9 +502,6 @@ export default function PlaygroundPage() {
<Avatar size='lg' status='online'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl' status='online'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
</Section>

View File

@@ -303,8 +303,8 @@ export const DiffControls = memo(function DiffControls() {
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 8px)',
right: 'calc(var(--panel-width) + 8px)',
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
}}
>
<div

View File

@@ -470,17 +470,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Show streaming indicator if streaming but no text content yet after tool calls */}
{isStreaming &&
!message.content &&
message.contentBlocks?.every((block) => block.type === 'tool_call') && (
<StreamingIndicator />
)}
{/* Streaming indicator when no content yet */}
{!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
<StreamingIndicator />
)}
{/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp, LayoutList } from 'lucide-react'
import { Button, Code } from '@/components/emcn'
import Editor from 'react-simple-code-editor'
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
@@ -413,6 +414,8 @@ const ACTION_VERBS = [
'Listed',
'Editing',
'Edited',
'Executing',
'Executed',
'Running',
'Ran',
'Designing',
@@ -751,36 +754,70 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
if (inputEntries.length === 0) return null
/**
* Format a value for display - handles objects, arrays, and primitives
*/
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-'
if (typeof value === 'string') return value || '-'
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
/**
* Check if a value is a complex type (object or array)
*/
const isComplex = (value: unknown): boolean => {
return typeof value === 'object' && value !== null
}
return (
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
<thead className='bg-transparent'>
<tr className='border-[var(--border-1)] border-b bg-transparent'>
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Input
</th>
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
<tbody className='bg-transparent'>
{inputEntries.map(([key, value]) => (
<tr key={key} className='border-[var(--border-1)] border-t bg-transparent'>
<td className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[6px]'>
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
{key}
<div className='mt-1.5 w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
{/* Header */}
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Input</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{inputEntries.length}
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
{inputEntries.map(([key, value], index) => {
const formattedValue = formatValue(value)
const needsCodeViewer = isComplex(value)
return (
<div
key={key}
className={clsx(
'flex flex-col gap-1 px-[10px] py-[6px]',
index > 0 && 'border-[var(--border-1)] border-t'
)}
>
{/* Input key */}
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
{/* Value display */}
{needsCodeViewer ? (
<Code.Viewer
code={formattedValue}
language='json'
showGutter={false}
className='max-h-[80px] min-h-0'
/>
) : (
<span className='font-mono text-[11px] text-[var(--text-muted)] leading-[1.3]'>
{formattedValue}
</span>
</td>
<td className='w-[64%] bg-transparent px-[10px] py-[6px]'>
<span className='font-mono text-[var(--text-muted)] text-xs'>
{String(value)}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -2290,74 +2327,136 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
// Don't show the table if there are no inputs
// Don't show the section if there are no inputs
if (inputEntries.length === 0) {
return null
}
return (
<div className='w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
<thead className='bg-transparent'>
<tr className='border-[var(--border-1)] border-b bg-transparent'>
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Input
</th>
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
<tbody className='bg-transparent'>
{inputEntries.map(([key, value]) => (
<tr
key={key}
className='group relative border-[var(--border-1)] border-t bg-transparent'
>
<td className='relative w-[36%] border-[var(--border-1)] border-r bg-transparent p-0'>
<div className='px-[10px] py-[8px]'>
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
{key}
</span>
</div>
</td>
<td className='relative w-[64%] bg-transparent p-0'>
<div className='min-w-0 px-[10px] py-[8px]'>
<input
type='text'
value={String(value)}
onChange={(e) => {
const newInputs = { ...safeInputs, [key]: e.target.value }
/**
* Format a value for display - handles objects, arrays, and primitives
*/
const formatValueForDisplay = (value: unknown): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
// For objects and arrays, use JSON.stringify with formatting
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
// Determine how to update based on original structure
if (isNestedInWorkflowInput) {
// Update workflow_input
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
// Input was a JSON string, serialize back
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (editedParams.input && typeof editedParams.input === 'object') {
// Input is an object
setEditedParams({ ...editedParams, input: newInputs })
} else if (
editedParams.inputs &&
typeof editedParams.inputs === 'object'
) {
// Inputs is an object
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
// Flat structure - update at base level
setEditedParams({ ...editedParams, [key]: e.target.value })
}
}}
className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]'
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
/**
* Parse a string value back to its original type if possible
*/
const parseInputValue = (value: string, originalValue: unknown): unknown => {
// If original was a primitive, keep as string
if (typeof originalValue !== 'object' || originalValue === null) {
return value
}
// Try to parse as JSON for objects/arrays
try {
return JSON.parse(value)
} catch {
return value
}
}
/**
* Check if a value is a complex type (object or array)
*/
const isComplexValue = (value: unknown): boolean => {
return typeof value === 'object' && value !== null
}
return (
<div className='w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
{/* Header */}
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Edit Input</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{inputEntries.length}
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
{inputEntries.map(([key, value], index) => {
const isComplex = isComplexValue(value)
const displayValue = formatValueForDisplay(value)
return (
<div
key={key}
className={clsx(
'flex flex-col gap-1.5 px-[10px] py-[8px]',
index > 0 && 'border-[var(--border-1)] border-t'
)}
>
{/* Input key */}
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
{/* Value editor */}
{isComplex ? (
<Code.Container className='max-h-[168px] min-h-[60px]'>
<Code.Content>
<Editor
value={displayValue}
onValueChange={(newCode) => {
const parsedValue = parseInputValue(newCode, value)
const newInputs = { ...safeInputs, [key]: parsedValue }
if (isNestedInWorkflowInput) {
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (
editedParams.input &&
typeof editedParams.input === 'object'
) {
setEditedParams({ ...editedParams, input: newInputs })
} else if (
editedParams.inputs &&
typeof editedParams.inputs === 'object'
) {
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
setEditedParams({ ...editedParams, [key]: parsedValue })
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
{...getCodeEditorProps()}
className={clsx(getCodeEditorProps().className, 'min-h-[40px]')}
style={{ minHeight: '40px' }}
/>
</Code.Content>
</Code.Container>
) : (
<input
type='text'
value={displayValue}
onChange={(e) => {
const parsedValue = parseInputValue(e.target.value, value)
const newInputs = { ...safeInputs, [key]: parsedValue }
if (isNestedInWorkflowInput) {
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (editedParams.input && typeof editedParams.input === 'object') {
setEditedParams({ ...editedParams, input: newInputs })
} else if (editedParams.inputs && typeof editedParams.inputs === 'object') {
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
setEditedParams({ ...editedParams, [key]: parsedValue })
}
}}
className='w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium font-mono text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none'
/>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -2443,8 +2542,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={false}
className='font-[470] font-season text-[var(--text-muted)] text-sm'
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
{code && (

View File

@@ -124,8 +124,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
isSendingMessage,
})
// Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
// Handle scroll management (80px stickiness for copilot)
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
stickinessThreshold: 80,
})
// Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(

View File

@@ -87,8 +87,8 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
// Check for start_trigger (unified start block) - prevent duplication but allow deletion
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
/**
@@ -140,7 +140,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && (
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -12,6 +12,12 @@ interface UseScrollManagementOptions {
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
*/
behavior?: 'auto' | 'smooth'
/**
* Distance from bottom (in pixels) within which auto-scroll stays active.
* Lower values = less sticky (user can scroll away easier).
* Default is 100px.
*/
stickinessThreshold?: number
}
/**
@@ -34,6 +40,7 @@ export function useScrollManagement(
const programmaticScrollInProgressRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
@@ -74,7 +81,7 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= 100
const nearBottom = distanceFromBottom <= stickinessThreshold
setIsNearBottom(nearBottom)
if (isSendingMessage) {
@@ -95,7 +102,7 @@ export function useScrollManagement(
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
// Attach scroll listener
useEffect(() => {
@@ -174,14 +181,20 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= 120
const nearBottom = distanceFromBottom <= stickinessThreshold
if (nearBottom) {
scrollToBottom()
}
}, 100)
return () => window.clearInterval(intervalId)
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
}, [
isSendingMessage,
userHasScrolledDuringStream,
getScrollContainer,
scrollToBottom,
stickinessThreshold,
])
return {
scrollAreaRef,

View File

@@ -23,7 +23,8 @@ interface TriggerValidationResult {
}
/**
* Validates that pasting/duplicating trigger blocks won't violate constraints.
* Validates that pasting/duplicating blocks won't violate constraints.
* Checks both trigger constraints and single-instance block constraints.
* Returns validation result with error message if invalid.
*/
export function validateTriggerPaste(
@@ -43,6 +44,12 @@ export function validateTriggerPaste(
return { isValid: false, message }
}
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type)
if (singleInstanceIssue) {
const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
return { isValid: false, message }
}
}
return { isValid: true }
}

View File

@@ -1129,17 +1129,18 @@ const WorkflowContent = React.memo(() => {
)
/**
* Checks if adding a trigger block would violate constraints and shows notification if so.
* Checks if adding a block would violate constraints (triggers or single-instance blocks)
* and shows notification if so.
* @returns true if validation failed (caller should return early), false if ok to proceed
*/
const checkTriggerConstraints = useCallback(
(blockType: string): boolean => {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (issue) {
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (triggerIssue) {
const message =
issue.issue === 'legacy'
triggerIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
: `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
@@ -1147,6 +1148,17 @@ const WorkflowContent = React.memo(() => {
})
return true
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType)
if (singleInstanceIssue) {
addNotification({
level: 'error',
message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`,
workflowId: activeWorkflowId || undefined,
})
return true
}
return false
},
[blocks, addNotification, activeWorkflowId]

View File

@@ -147,6 +147,12 @@ export function ContextMenu({
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
// Section visibility for divider logic
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
<Popover
open={isOpen}
@@ -176,7 +182,7 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
@@ -214,7 +220,7 @@ export function ContextMenu({
)}
{/* Copy and export actions */}
{(showDuplicate || showExport) && <PopoverDivider />}
{hasEditSection && hasCopySection && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
disabled={disableDuplicate}
@@ -239,7 +245,7 @@ export function ContextMenu({
)}
{/* Destructive action */}
<PopoverDivider />
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
<PopoverItem
disabled={disableDelete}
onClick={() => {

View File

@@ -17,6 +17,7 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
category: 'blocks',
bgColor: '#2F55FF',
icon: ResponseIcon,
singleInstance: true,
subBlocks: [
{
id: 'dataMode',

View File

@@ -320,6 +320,7 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
subBlocks: SubBlockConfig[]
triggerAllowed?: boolean
authMode?: AuthMode
singleInstance?: boolean
tools: {
access: string[]
config?: {

View File

@@ -16,7 +16,6 @@ const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full'
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
},
},
defaultVariants: {
@@ -42,7 +41,6 @@ const avatarStatusVariants = cva(
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xl: 'h-4 w-4',
},
},
defaultVariants: {

View File

@@ -11,6 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
@@ -62,6 +63,8 @@ type SkippedItemType =
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
| 'duplicate_trigger'
| 'duplicate_single_instance_block'
/**
* Represents an item that was skipped during operation application
@@ -1775,6 +1778,34 @@ function applyOperationsToWorkflowState(
break
}
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
if (triggerIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_trigger',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`,
details: { requestedType: params.type, issue: triggerIssue.issue },
})
break
}
// Check single-instance block constraints (e.g., Response block)
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(
modifiedState.blocks,
params.type
)
if (singleInstanceIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_single_instance_block',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`,
details: { requestedType: params.type },
})
break
}
// Create new block with proper structure
const newBlock = createBlockFromParams(
block_id,

View File

@@ -592,4 +592,34 @@ export class TriggerUtils {
const parentWithType = parent as T & { type?: string }
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
}
static isSingleInstanceBlockType(blockType: string): boolean {
const blockConfig = getBlock(blockType)
return blockConfig?.singleInstance === true
}
static wouldViolateSingleInstanceBlock<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): boolean {
if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
return false
}
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
return blockArray.some((block) => block.type === blockType)
}
static getSingleInstanceBlockIssue<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): { issue: 'duplicate'; blockName: string } | null {
if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
return null
}
const blockConfig = getBlock(blockType)
const blockName = blockConfig?.name || blockType
return { issue: 'duplicate', blockName }
}
}

View File

@@ -271,11 +271,31 @@ function resolveToolDisplay(
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
}
} catch {}
// Humanized fallback as last resort
// Humanized fallback as last resort - include state verb for proper verb-noun styling
try {
if (toolName) {
const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
return { text, icon: undefined as any }
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
// Add state verb prefix for verb-noun rendering in tool-call component
let stateVerb: string
switch (state) {
case ClientToolCallState.pending:
case ClientToolCallState.executing:
stateVerb = 'Executing'
break
case ClientToolCallState.success:
stateVerb = 'Executed'
break
case ClientToolCallState.error:
stateVerb = 'Failed'
break
case ClientToolCallState.rejected:
case ClientToolCallState.aborted:
stateVerb = 'Skipped'
break
default:
stateVerb = 'Executing'
}
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
}
} catch {}
return undefined
@@ -572,8 +592,30 @@ function stripTodoTags(text: string): string {
*/
function deepClone<T>(obj: T): T {
try {
return JSON.parse(JSON.stringify(obj))
} catch {
const json = JSON.stringify(obj)
if (!json || json === 'undefined') {
logger.warn('[deepClone] JSON.stringify returned empty for object', {
type: typeof obj,
isArray: Array.isArray(obj),
length: Array.isArray(obj) ? obj.length : undefined,
})
return obj
}
const parsed = JSON.parse(json)
// Verify the clone worked
if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
logger.warn('[deepClone] Array clone mismatch', {
originalLength: obj.length,
clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
})
}
return parsed
} catch (err) {
logger.error('[deepClone] Failed to clone object', {
error: String(err),
type: typeof obj,
isArray: Array.isArray(obj),
})
return obj
}
}
@@ -587,11 +629,18 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
const result = messages
.map((msg) => {
// Deep clone the entire message to ensure all nested data is serializable
// Ensure timestamp is always a string (Zod schema requires it)
let timestamp: string = msg.timestamp
if (typeof timestamp !== 'string') {
const ts = timestamp as any
timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
}
const serialized: any = {
id: msg.id,
role: msg.role,
content: msg.content || '',
timestamp: msg.timestamp,
timestamp,
}
// Deep clone contentBlocks (the main rendering data)
@@ -3151,7 +3200,7 @@ export const useCopilotStore = create<CopilotStore>()(
model: selectedModel,
}
await fetch('/api/copilot/chat/update-messages', {
const saveResponse = await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3162,6 +3211,18 @@ export const useCopilotStore = create<CopilotStore>()(
}),
})
if (!saveResponse.ok) {
const errorText = await saveResponse.text().catch(() => '')
logger.error('[Stream Done] Failed to save messages to DB', {
status: saveResponse.status,
error: errorText,
})
} else {
logger.info('[Stream Done] Successfully saved messages to DB', {
messageCount: dbMessages.length,
})
}
// Update local chat object with plan artifact and config
set({
currentChat: {
@@ -3170,7 +3231,9 @@ export const useCopilotStore = create<CopilotStore>()(
config,
},
})
} catch {}
} catch (err) {
logger.error('[Stream Done] Exception saving messages', { error: String(err) })
}
}
// Post copilot_stats record (input/output tokens can be null for now)

View File

@@ -41,6 +41,10 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
return 'Start'
}
if (normalizedBaseName === 'response') {
return 'Response'
}
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName