v0.4.14: canvas speedup and copilot context window

This commit is contained in:
Siddharth Ganesan
2025-10-13 20:23:49 -07:00
committed by GitHub
12 changed files with 2071 additions and 1961 deletions

View File

@@ -463,6 +463,8 @@ export async function POST(req: NextRequest) {
logger.debug(`[${tracker.requestId}] Sent initial chatId event to client`)
}
// Note: context_usage events are forwarded from sim-agent (which has accurate token counts)
// Start title generation in parallel if needed
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
generateChatTitle(message)
@@ -594,6 +596,7 @@ export async function POST(req: NextRequest) {
lastSafeDoneResponseId = responseIdFromDone
}
}
// Note: context_usage events are forwarded from sim-agent
break
case 'error':

View File

@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
@@ -9,31 +10,46 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
export function DiffControls() {
const {
isShowingDiff,
isDiffReady,
diffWorkflow,
toggleDiffView,
acceptChanges,
rejectChanges,
diffMetadata,
} = useWorkflowDiffStore()
export const DiffControls = memo(function DiffControls() {
// Optimized: Single diff store subscription
const { isShowingDiff, isDiffReady, diffWorkflow, toggleDiffView, acceptChanges, rejectChanges } =
useWorkflowDiffStore(
useCallback(
(state) => ({
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
diffWorkflow: state.diffWorkflow,
toggleDiffView: state.toggleDiffView,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
)
)
const { updatePreviewToolCallState, clearPreviewYaml, currentChat, messages } = useCopilotStore()
const { activeWorkflowId } = useWorkflowRegistry()
// Optimized: Single copilot store subscription for needed values
const { updatePreviewToolCallState, clearPreviewYaml, currentChat, messages } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
clearPreviewYaml: state.clearPreviewYaml,
currentChat: state.currentChat,
messages: state.messages,
}),
[]
)
)
// Don't show anything if no diff is available or diff is not ready
if (!diffWorkflow || !isDiffReady) {
return null
}
const { activeWorkflowId } = useWorkflowRegistry(
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const handleToggleDiff = () => {
const handleToggleDiff = useCallback(() => {
logger.info('Toggling diff view', { currentState: isShowingDiff })
toggleDiffView()
}
}, [isShowingDiff, toggleDiffView])
const createCheckpoint = async () => {
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
workflowId: activeWorkflowId,
@@ -184,9 +200,9 @@ export function DiffControls() {
logger.error('Failed to create checkpoint:', error)
return false
}
}
}, [activeWorkflowId, currentChat, messages])
const handleAccept = async () => {
const handleAccept = useCallback(async () => {
logger.info('Accepting proposed changes with backup protection')
try {
@@ -239,9 +255,9 @@ export function DiffControls() {
console.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
}
}, [createCheckpoint, clearPreviewYaml, updatePreviewToolCallState, acceptChanges])
const handleReject = () => {
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')
// Clear preview YAML immediately
@@ -279,6 +295,11 @@ export function DiffControls() {
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
})
}, [clearPreviewYaml, updatePreviewToolCallState, rejectChanges])
// Don't show anything if no diff is available or diff is not ready
if (!diffWorkflow || !isDiffReady) {
return null
}
return (
@@ -319,4 +340,4 @@ export function DiffControls() {
</div>
</div>
)
}
})

View File

@@ -0,0 +1,60 @@
'use client'
import { memo } from 'react'
import { Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ContextUsagePillProps {
percentage: number
className?: string
onCreateNewChat?: () => void
}
export const ContextUsagePill = memo(
({ percentage, className, onCreateNewChat }: ContextUsagePillProps) => {
// Don't render if invalid (but DO render if 0 or very small)
if (percentage === null || percentage === undefined || Number.isNaN(percentage)) return null
const isHighUsage = percentage >= 75
// Determine color based on percentage (similar to Cursor IDE)
const getColorClass = () => {
if (percentage >= 90) return 'bg-red-500/10 text-red-600 dark:text-red-400'
if (percentage >= 75) return 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
if (percentage >= 50) return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'
return 'bg-gray-500/10 text-gray-600 dark:text-gray-400'
}
// Format: show 1 decimal for <1%, 0 decimals for >=1%
const formattedPercentage = percentage < 1 ? percentage.toFixed(1) : percentage.toFixed(0)
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium text-[11px] tabular-nums transition-colors',
getColorClass(),
isHighUsage && 'border border-red-500/50',
className
)}
title={`Context used in this chat: ${percentage.toFixed(2)}%`}
>
<span>{formattedPercentage}%</span>
{isHighUsage && onCreateNewChat && (
<button
onClick={(e) => {
e.stopPropagation()
onCreateNewChat()
}}
className='inline-flex items-center justify-center transition-opacity hover:opacity-70'
title='Recommended: Start a new chat for better quality'
type='button'
>
<Plus className='h-3 w-3' />
</button>
)}
</div>
)
}
)
ContextUsagePill.displayName = 'ContextUsagePill'

View File

@@ -55,6 +55,7 @@ import { cn } from '@/lib/utils'
import { useCopilotStore } from '@/stores/copilot/store'
import type { ChatContext } from '@/stores/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ContextUsagePill } from '../context-usage-pill/context-usage-pill'
const logger = createLogger('CopilotUserInput')
@@ -182,18 +183,16 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const { data: session } = useSession()
const { currentChat, workflowId, enabledModels, setEnabledModels } = useCopilotStore()
const {
currentChat,
workflowId,
enabledModels,
setEnabledModels,
contextUsage,
createNewChat,
} = useCopilotStore()
const params = useParams()
const workspaceId = params.workspaceId as string
// Track per-chat preference for auto-adding workflow context
const [workflowAutoAddDisabledMap, setWorkflowAutoAddDisabledMap] = useState<
Record<string, boolean>
>({})
// Also track for new chats (no ID yet)
const [newChatWorkflowDisabled, setNewChatWorkflowDisabled] = useState(false)
const workflowAutoAddDisabled = currentChat?.id
? workflowAutoAddDisabledMap[currentChat.id] || false
: newChatWorkflowDisabled
// Determine placeholder based on mode
const effectivePlaceholder =
@@ -251,98 +250,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [enabledModels, setEnabledModels])
// Track the last chat ID we've seen to detect chat changes
const [lastChatId, setLastChatId] = useState<string | undefined>(undefined)
// Track if we just sent a message to avoid re-adding context after submit
const [justSentMessage, setJustSentMessage] = useState(false)
// Reset states when switching to a truly new chat
useEffect(() => {
const currentChatId = currentChat?.id
// Detect when we're switching to a different chat
if (lastChatId !== currentChatId) {
// If switching to a new chat (undefined ID) from a different state
// reset the disabled flag so each new chat starts fresh
if (!currentChatId && lastChatId !== undefined) {
setNewChatWorkflowDisabled(false)
}
// If a new chat just got an ID assigned, transfer the disabled state
if (currentChatId && !lastChatId && newChatWorkflowDisabled) {
setWorkflowAutoAddDisabledMap((prev) => ({
...prev,
[currentChatId]: true,
}))
// Keep newChatWorkflowDisabled as false for the next new chat
setNewChatWorkflowDisabled(false)
}
// Reset the "just sent" flag when switching chats
setJustSentMessage(false)
setLastChatId(currentChatId)
}
}, [currentChat?.id, lastChatId, newChatWorkflowDisabled])
// Auto-add workflow context when message is empty and not disabled
useEffect(() => {
// Don't auto-add if disabled or no workflow
if (!workflowId || workflowAutoAddDisabled) return
// Don't auto-add right after sending a message
if (justSentMessage) return
// Only add when message is empty (new message being composed)
if (message && message.trim().length > 0) return
// Check if current_workflow context already exists
const hasCurrentWorkflowContext = selectedContexts.some(
(ctx) => ctx.kind === 'current_workflow' && (ctx as any).workflowId === workflowId
)
if (hasCurrentWorkflowContext) {
return
}
const addWorkflowContext = async () => {
// Double-check disabled state right before adding
if (workflowAutoAddDisabled) return
// Get workflow name
let workflowName = 'Current Workflow'
// Try loaded workflows first
const existingWorkflow = workflows.find((w) => w.id === workflowId)
if (existingWorkflow) {
workflowName = existingWorkflow.name
} else if (workflows.length === 0) {
// If workflows not loaded yet, try to fetch this specific one
try {
const resp = await fetch(`/api/workflows/${workflowId}`)
if (resp.ok) {
const data = await resp.json()
workflowName = data?.data?.name || 'Current Workflow'
}
} catch {}
}
// Add current_workflow context using functional update to prevent duplicates
setSelectedContexts((prev) => {
const alreadyHasCurrentWorkflow = prev.some(
(ctx) => ctx.kind === 'current_workflow' && (ctx as any).workflowId === workflowId
)
if (alreadyHasCurrentWorkflow) return prev
return [
...prev,
{ kind: 'current_workflow', workflowId, label: workflowName } as ChatContext,
]
})
}
addWorkflowContext()
}, [workflowId, workflowAutoAddDisabled, workflows.length, message, justSentMessage]) // Re-run when message changes
// Auto-resize textarea and toggle vertical scroll when exceeding max height
useEffect(() => {
const textarea = textareaRef.current
@@ -710,16 +617,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
size: f.size,
}))
// Build contexts to send: hide current_workflow in UI but always include it in payload
const uiContexts = selectedContexts.filter((c) => (c as any).kind !== 'current_workflow')
const finalContexts: any[] = [...uiContexts]
if (workflowId) {
// Include current_workflow for the agent; label not shown in UI
finalContexts.push({ kind: 'current_workflow', workflowId, label: 'Current Workflow' })
}
onSubmit(trimmedMessage, fileAttachments, finalContexts as any)
// Send only the explicitly selected contexts
onSubmit(trimmedMessage, fileAttachments, selectedContexts as any)
// Clean up preview URLs before clearing
attachedFiles.forEach((f) => {
@@ -736,17 +635,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
setAttachedFiles([])
// Clear @mention contexts after submission, but preserve current_workflow if not disabled
setSelectedContexts((prev) => {
// Keep current_workflow context if it's not disabled
const currentWorkflowCtx = prev.find(
(ctx) => ctx.kind === 'current_workflow' && !workflowAutoAddDisabled
)
return currentWorkflowCtx ? [currentWorkflowCtx] : []
})
// Mark that we just sent a message to prevent auto-add
setJustSentMessage(true)
// Clear @mention contexts after submission
setSelectedContexts([])
setOpenSubmenuFor(null)
setShowMentionMenu(false)
@@ -1440,11 +1330,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setInternalMessage(newValue)
}
// Reset the "just sent" flag when user starts typing
if (justSentMessage && newValue.length > 0) {
setJustSentMessage(false)
}
const caret = e.target.selectionStart ?? newValue.length
const active = getActiveMentionQueryAtPosition(caret, newValue)
if (active) {
@@ -1714,34 +1599,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Keep selected contexts in sync with inline @label tokens so deleting inline tokens updates pills
useEffect(() => {
if (!message) {
// When message is empty, preserve current_workflow if not disabled
// Clear other contexts
setSelectedContexts((prev) => {
const currentWorkflowCtx = prev.find(
(ctx) => ctx.kind === 'current_workflow' && !workflowAutoAddDisabled
)
return currentWorkflowCtx ? [currentWorkflowCtx] : []
})
// When message is empty, clear all contexts
setSelectedContexts([])
return
}
const presentLabels = new Set<string>()
const ranges = computeMentionRanges()
for (const r of ranges) presentLabels.add(r.label)
setSelectedContexts((prev) => {
// Keep contexts that are mentioned in text OR are current_workflow (unless disabled)
// Keep only contexts that are mentioned in text
const filteredContexts = prev.filter((c) => {
// Always preserve current_workflow context if it's not disabled
// It should only be removable via the X button
if (c.kind === 'current_workflow' && !workflowAutoAddDisabled) {
return true
}
// For other contexts, check if they're mentioned in text
return !!c.label && presentLabels.has(c.label!)
})
return filteredContexts
})
}, [message, workflowAutoAddDisabled])
}, [message])
// Manage aggregate mode and preloading when needed
useEffect(() => {
@@ -2050,7 +1923,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<div className={cn('relative flex-none pb-4', className)}>
<div
className={cn(
'rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs transition-all duration-200 dark:border-[#414141] dark:bg-[var(--surface-elevated)]',
'relative rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs transition-all duration-200 dark:border-[#414141] dark:bg-[var(--surface-elevated)]',
isDragging &&
'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
)}
@@ -2059,6 +1932,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Context Usage Pill - Top Right */}
{contextUsage && contextUsage.percentage > 0 && (
<div className='absolute top-2 right-2 z-10'>
<ContextUsagePill
percentage={contextUsage.percentage}
onCreateNewChat={createNewChat}
/>
</div>
)}
{/* Attached Files Display with Thumbnails */}
{attachedFiles.length > 0 && (
<div className='mb-2 flex flex-wrap gap-1.5'>
@@ -2172,7 +2054,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
{/* Highlight overlay */}
<div
ref={overlayRef}
className='pointer-events-none absolute inset-0 z-[1] max-h-[120px] overflow-y-auto overflow-x-hidden px-[2px] py-1 [&::-webkit-scrollbar]:hidden'
className='pointer-events-none absolute inset-0 z-[1] max-h-[120px] overflow-y-auto overflow-x-hidden pl-[2px] pr-14 py-1 [&::-webkit-scrollbar]:hidden'
>
<pre className='whitespace-pre-wrap break-words font-sans text-foreground text-sm leading-[1.25rem]'>
{(() => {
@@ -2220,7 +2102,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
disabled={disabled}
rows={1}
className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent pl-[2px] pr-14 py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto', wordBreak: 'break-word' }}
/>
@@ -3364,7 +3246,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</div>
{/* Right side: Attach Button + Send Button */}
<div className='flex items-center gap-1'>
<div className='flex items-center gap-1.5'>
{/* Attach Button */}
<Button
variant='ghost'

View File

@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, LogOut, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -12,42 +13,53 @@ interface ActionBarProps {
disabled?: boolean
}
export function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
const {
collaborativeRemoveBlock,
collaborativeToggleBlockEnabled,
collaborativeDuplicateBlock,
collaborativeToggleBlockHandles,
} = useCollaborativeWorkflow()
const isEnabled = useWorkflowStore((state) => state.blocks[blockId]?.enabled ?? true)
const horizontalHandles = useWorkflowStore(
(state) => state.blocks[blockId]?.horizontalHandles ?? false
)
const parentId = useWorkflowStore((state) => state.blocks[blockId]?.data?.parentId)
const parentType = useWorkflowStore((state) =>
parentId ? state.blocks[parentId]?.type : undefined
)
const userPermissions = useUserPermissionsContext()
export const ActionBar = memo(
function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
const {
collaborativeRemoveBlock,
collaborativeToggleBlockEnabled,
collaborativeDuplicateBlock,
collaborativeToggleBlockHandles,
} = useCollaborativeWorkflow()
const isStarterBlock = blockType === 'starter'
// Optimized: Single store subscription for all block data
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
(state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
return {
isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType: parentId ? state.blocks[parentId]?.type : undefined,
}
},
[blockId]
)
)
const getTooltipMessage = (defaultMessage: string) => {
if (disabled) {
return userPermissions.isOfflineMode ? 'Connection lost - please refresh' : 'Read-only mode'
const userPermissions = useUserPermissionsContext()
const isStarterBlock = blockType === 'starter'
const getTooltipMessage = (defaultMessage: string) => {
if (disabled) {
return userPermissions.isOfflineMode ? 'Connection lost - please refresh' : 'Read-only mode'
}
return defaultMessage
}
return defaultMessage
}
return (
<div
className={cn(
'-right-20 absolute top-0',
'flex flex-col items-center gap-2 p-2',
'rounded-md border border-gray-200 bg-background shadow-sm dark:border-gray-800',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100'
)}
>
{/* <Tooltip>
return (
<div
className={cn(
'-right-20 absolute top-0',
'flex flex-col items-center gap-2 p-2',
'rounded-md border border-gray-200 bg-background shadow-sm dark:border-gray-800',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100'
)}
>
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
@@ -64,28 +76,6 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
<TooltipContent side="right">Run Block</TooltipContent>
</Tooltip> */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled) {
collaborativeToggleBlockEnabled(blockId)
}
}}
className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')}
disabled={disabled}
>
{isEnabled ? <Circle className='h-4 w-4' /> : <CircleOff className='h-4 w-4' />}
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</TooltipContent>
</Tooltip>
{!isStarterBlock && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -93,72 +83,68 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
size='sm'
onClick={() => {
if (!disabled) {
collaborativeDuplicateBlock(blockId)
collaborativeToggleBlockEnabled(blockId)
}
}}
className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')}
disabled={disabled}
>
<Copy className='h-4 w-4' />
{isEnabled ? <Circle className='h-4 w-4' /> : <CircleOff className='h-4 w-4' />}
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Duplicate Block')}</TooltipContent>
<TooltipContent side='right'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</TooltipContent>
</Tooltip>
)}
{/* Remove from subflow - only show when inside loop/parallel */}
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
{!isStarterBlock && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled) {
collaborativeDuplicateBlock(blockId)
}
}}
className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')}
disabled={disabled}
>
<Copy className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Duplicate Block')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
}
}}
className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')}
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-4 w-4' />
) : (
<ArrowUpDown className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</TooltipContent>
</Tooltip>
{/* Remove from subflow - only show when inside loop/parallel */}
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
{!isStarterBlock && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -166,21 +152,56 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
size='sm'
onClick={() => {
if (!disabled) {
collaborativeRemoveBlock(blockId)
collaborativeToggleBlockHandles(blockId)
}
}}
className={cn(
'text-gray-500 hover:text-red-600',
disabled && 'cursor-not-allowed opacity-50'
)}
className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')}
disabled={disabled}
>
<Trash2 className='h-4 w-4' />
{horizontalHandles ? (
<ArrowLeftRight className='h-4 w-4' />
) : (
<ArrowUpDown className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Delete Block')}</TooltipContent>
<TooltipContent side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</TooltipContent>
</Tooltip>
)}
</div>
)
}
{!isStarterBlock && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled) {
collaborativeRemoveBlock(blockId)
}
}}
className={cn(
'text-gray-500 hover:text-red-600',
disabled && 'cursor-not-allowed opacity-50'
)}
disabled={disabled}
>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Delete Block')}</TooltipContent>
</Tooltip>
)}
</div>
)
},
(prevProps, nextProps) => {
// Only re-render if props actually changed
return (
prevProps.blockId === nextProps.blockId &&
prevProps.blockType === nextProps.blockType &&
prevProps.disabled === nextProps.disabled
)
}
)

View File

@@ -60,7 +60,7 @@ export function useSubBlockValue<T = any>(
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store, keyed by active workflow id
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
// Optimized: use shallow equality comparison to prevent re-renders when other fields change
const storeValue = useSubBlockStore(
useCallback(
(state) => {
@@ -69,7 +69,8 @@ export function useSubBlockValue<T = any>(
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]
)
),
(a, b) => isEqual(a, b) // Use deep equality to prevent re-renders for same values
)
// Check if we're in diff mode and get diff value if available
@@ -84,8 +85,10 @@ export function useSubBlockValue<T = any>(
subBlockId === 'apiKey' || (subBlockId?.toLowerCase().includes('apikey') ?? false)
// Always call this hook unconditionally - don't wrap it in a condition
const modelSubBlockValue = useSubBlockStore((state) =>
blockId ? state.getValue(blockId, 'model') : null
// Optimized: only re-render if model value actually changes
const modelSubBlockValue = useSubBlockStore(
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
(a, b) => a === b
)
// Determine if this is a provider-based block type

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { Edge } from 'reactflow'
import { shallow } from 'zustand/shallow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,13 +41,27 @@ export interface CurrentWorkflow {
* Automatically handles diff vs normal mode without exposing the complexity to consumers.
*/
export function useCurrentWorkflow(): CurrentWorkflow {
// Get normal workflow state
const normalWorkflow = useWorkflowStore((state) => state.getWorkflowState())
// Get normal workflow state - optimized with shallow comparison
// This prevents re-renders when only subblock values change (not block structure)
const normalWorkflow = useWorkflowStore((state) => {
const workflow = state.getWorkflowState()
return {
blocks: workflow.blocks,
edges: workflow.edges,
loops: workflow.loops,
parallels: workflow.parallels,
lastSaved: workflow.lastSaved,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
deploymentStatuses: workflow.deploymentStatuses,
needsRedeployment: workflow.needsRedeployment,
}
}, shallow)
// Get diff state - now including isDiffReady
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
// Create the abstracted interface
// Create the abstracted interface - optimized to prevent unnecessary re-renders
const currentWorkflow = useMemo((): CurrentWorkflow => {
// Determine which workflow to use - only use diff if it's ready
const hasDiffBlocks =
@@ -56,8 +71,8 @@ export function useCurrentWorkflow(): CurrentWorkflow {
return {
// Current workflow state
blocks: activeWorkflow.blocks,
edges: activeWorkflow.edges,
blocks: activeWorkflow.blocks || {},
edges: activeWorkflow.edges || [],
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
lastSaved: activeWorkflow.lastSaved,
@@ -71,14 +86,14 @@ export function useCurrentWorkflow(): CurrentWorkflow {
isNormalMode: !shouldUseDiff,
// Full workflow state (for cases that need the complete object)
workflowState: activeWorkflow,
workflowState: activeWorkflow as WorkflowState,
// Helper methods
getBlockById: (blockId: string) => activeWorkflow.blocks[blockId],
getBlockCount: () => Object.keys(activeWorkflow.blocks).length,
getEdgeCount: () => activeWorkflow.edges.length,
hasBlocks: () => Object.keys(activeWorkflow.blocks).length > 0,
hasEdges: () => activeWorkflow.edges.length > 0,
getBlockById: (blockId: string) => activeWorkflow.blocks?.[blockId],
getBlockCount: () => Object.keys(activeWorkflow.blocks || {}).length,
getEdgeCount: () => (activeWorkflow.edges || []).length,
hasBlocks: () => Object.keys(activeWorkflow.blocks || {}).length > 0,
hasEdges: () => (activeWorkflow.edges || []).length > 0,
}
}, [normalWorkflow, isShowingDiff, isDiffReady, diffWorkflow])

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import ReactFlow, {
Background,
@@ -52,7 +52,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Workflow')
// Define custom node and edge types
// Define custom node and edge types - memoized outside component to prevent re-creation
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
subflowNode: SubflowNodeComponent,
@@ -62,6 +62,15 @@ const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge, // Keep for backward compatibility
}
// Memoized ReactFlow props to prevent unnecessary re-renders
const defaultEdgeOptions = { type: 'custom' }
const connectionLineStyle = {
stroke: '#94a3b8',
strokeWidth: 2,
strokeDasharray: '5,5',
}
const snapGrid: [number, number] = [20, 20]
interface SelectedEdgeInfo {
id: string
parentLoopId?: string
@@ -1188,14 +1197,46 @@ const WorkflowContent = React.memo(() => {
validateAndNavigate()
}, [params.workflowId, workflows, isLoading, workspaceId, router])
// Cache block configs to prevent unnecessary re-fetches
const blockConfigCache = useRef<Map<string, any>>(new Map())
const getBlockConfig = useCallback((type: string) => {
if (!blockConfigCache.current.has(type)) {
blockConfigCache.current.set(type, getBlock(type))
}
return blockConfigCache.current.get(type)
}, [])
// Track previous blocks hash to prevent unnecessary recalculations
const prevBlocksHashRef = useRef<string>('')
const prevBlocksRef = useRef(blocks)
// Create a stable hash of block properties that affect node rendering
// This prevents nodes from recreating when only subblock values change
const blocksHash = useMemo(() => {
// Only recalculate hash if blocks reference actually changed
if (prevBlocksRef.current === blocks) {
return prevBlocksHashRef.current
}
prevBlocksRef.current = blocks
const hash = Object.values(blocks)
.map(
(b) =>
`${b.id}:${b.type}:${b.name}:${b.position.x.toFixed(0)}:${b.position.y.toFixed(0)}:${b.isWide}:${b.height}:${b.data?.parentId || ''}`
)
.join('|')
prevBlocksHashRef.current = hash
return hash
}, [blocks])
// Transform blocks and loops into ReactFlow nodes
const nodes = useMemo(() => {
const nodeArray: any[] = []
// Add block nodes
Object.entries(blocks).forEach(([blockId, block]) => {
if (!block.type || !block.name) {
logger.warn(`Skipping invalid block: ${blockId}`, { block })
if (!block || !block.type || !block.name) {
return
}
@@ -1220,7 +1261,7 @@ const WorkflowContent = React.memo(() => {
return
}
const blockConfig = getBlock(block.type)
const blockConfig = getBlockConfig(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, {
block,
@@ -1233,6 +1274,7 @@ const WorkflowContent = React.memo(() => {
const isActive = activeBlockIds.has(block.id)
const isPending = isDebugModeEnabled && pendingBlocks.includes(block.id)
// Create stable node object - React Flow will handle shallow comparison
nodeArray.push({
id: block.id,
type: 'workflowBlock',
@@ -1242,7 +1284,7 @@ const WorkflowContent = React.memo(() => {
extent: block.data?.extent || undefined,
data: {
type: block.type,
config: blockConfig,
config: blockConfig, // Cached config reference
name: block.name,
isActive,
isPending,
@@ -1250,11 +1292,21 @@ const WorkflowContent = React.memo(() => {
// Include dynamic dimensions for container resizing calculations
width: block.isWide ? 450 : 350, // Standard width based on isWide state
height: Math.max(block.height || 100, 100), // Use actual height with minimum
// Explicitly set measured to prevent ReactFlow from recalculating
measured: { width: block.isWide ? 450 : 350, height: Math.max(block.height || 100, 100) },
})
})
return nodeArray
}, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled, nestedSubflowErrors])
}, [
blocksHash,
blocks,
activeBlockIds,
pendingBlocks,
isDebugModeEnabled,
nestedSubflowErrors,
getBlockConfig,
])
// Update nodes - use store version to avoid collaborative feedback loops
const onNodesChange = useCallback(
@@ -1919,13 +1971,9 @@ const WorkflowContent = React.memo(() => {
minZoom={0.1}
maxZoom={1.3}
panOnScroll
defaultEdgeOptions={{ type: 'custom' }}
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
connectionLineStyle={{
stroke: '#94a3b8',
strokeWidth: 2,
strokeDasharray: '5,5',
}}
connectionLineStyle={connectionLineStyle}
connectionLineType={ConnectionLineType.SmoothStep}
onNodeClick={(e, _node) => {
e.stopPropagation()
@@ -1945,8 +1993,11 @@ const WorkflowContent = React.memo(() => {
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={false}
snapGrid={[20, 20]}
snapGrid={snapGrid}
elevateEdgesOnSelect={true}
// Performance optimizations
onlyRenderVisibleElements={true}
deleteKeyCode={null}
elevateNodesOnSelect={true}
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}

View File

@@ -1166,6 +1166,25 @@ const sseHandlers: Record<string, SSEHandler> = {
context.currentTextBlock = null
updateStreamingMessage(set, context)
},
context_usage: (data, _context, _get, set) => {
try {
const usageData = data?.data
if (usageData) {
set({
contextUsage: {
usage: usageData.usage || 0,
percentage: usageData.percentage || 0,
model: usageData.model || '',
contextWindow: usageData.context_window || usageData.contextWindow || 0,
when: usageData.when || 'start',
estimatedTokens: usageData.estimated_tokens || usageData.estimatedTokens,
},
})
}
} catch (err) {
logger.warn('Failed to handle context_usage event:', err)
}
},
default: () => {},
}
@@ -1304,6 +1323,7 @@ const initialState = {
showPlanTodos: false,
toolCallsById: {} as Record<string, CopilotToolCall>,
suppressAutoSelect: false,
contextUsage: null,
}
export const useCopilotStore = create<CopilotStore>()(
@@ -1314,7 +1334,7 @@ export const useCopilotStore = create<CopilotStore>()(
setMode: (mode) => set({ mode }),
// Clear messages
clearMessages: () => set({ messages: [] }),
clearMessages: () => set({ messages: [], contextUsage: null }),
// Workflow selection
setWorkflowId: async (workflowId: string | null) => {
@@ -1374,6 +1394,7 @@ export const useCopilotStore = create<CopilotStore>()(
planTodos: [],
showPlanTodos: false,
suppressAutoSelect: false,
contextUsage: null,
})
// Background-save the previous chat's latest messages before switching (optimistic)
@@ -1442,6 +1463,7 @@ export const useCopilotStore = create<CopilotStore>()(
planTodos: [],
showPlanTodos: false,
suppressAutoSelect: true,
contextUsage: null,
})
},
@@ -2041,6 +2063,7 @@ export const useCopilotStore = create<CopilotStore>()(
for await (const data of parseSSEStream(reader, decoder)) {
const { abortController } = get()
if (abortController?.signal.aborted) break
const handler = sseHandlers[data.type] || sseHandlers.default
await handler(data, context, get, set)
if (context.streamComplete) break

View File

@@ -124,6 +124,16 @@ export interface CopilotState {
currentUserMessageId?: string | null
// Per-message metadata captured at send-time for reliable stats
// Context usage tracking for percentage pill
contextUsage: {
usage: number
percentage: number
model: string
contextWindow: number
when: 'start' | 'end'
estimatedTokens?: number
} | null
}
export interface CopilotActions {