mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
v0.4.14: canvas speedup and copilot context window
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user