From 6f3df271fd7be5e3e29ee81ba056dcdf77dda7ec Mon Sep 17 00:00:00 2001
From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Date: Mon, 13 Oct 2025 19:56:02 -0700
Subject: [PATCH] feat(copilot+canvas rendering): add context window tracking
to copilot and selectively render canvas components (#1622)
* Copilot context window
* Fix pill
* Speed up canvas
* Fix react hook bug
* Context pill div
---
apps/sim/app/api/copilot/chat/route.ts | 3 +
.../[workflowId]/components/diff-controls.tsx | 69 +-
.../context-usage-pill/context-usage-pill.tsx | 60 +
.../components/user-input/user-input.tsx | 178 +-
.../components/action-bar/action-bar.tsx | 261 +-
.../sub-block/hooks/use-sub-block-value.ts | 11 +-
.../components/sub-block/sub-block.tsx | 1048 ++++----
.../workflow-block/workflow-block.tsx | 2251 +++++++++--------
.../hooks/use-current-workflow.ts | 37 +-
.../[workspaceId]/w/[workflowId]/workflow.tsx | 79 +-
apps/sim/stores/copilot/store.ts | 25 +-
apps/sim/stores/copilot/types.ts | 10 +
12 files changed, 2071 insertions(+), 1961 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/context-usage-pill/context-usage-pill.tsx
diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts
index 815270c361..558f61a94c 100644
--- a/apps/sim/app/api/copilot/chat/route.ts
+++ b/apps/sim/app/api/copilot/chat/route.ts
@@ -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':
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx
index db334379c3..2e996dd7ae 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx
@@ -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() {
)
-}
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/context-usage-pill/context-usage-pill.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/context-usage-pill/context-usage-pill.tsx
new file mode 100644
index 0000000000..068aaf3a6f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/context-usage-pill/context-usage-pill.tsx
@@ -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 (
+
+
{formattedPercentage}%
+ {isHighUsage && onCreateNewChat && (
+
+ )}
+
+ )
+ }
+)
+
+ContextUsagePill.displayName = 'ContextUsagePill'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx
index ac7868ba8f..d2a98dc748 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx
@@ -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(
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
- >({})
- // 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(
}
}, [enabledModels, setEnabledModels])
- // Track the last chat ID we've seen to detect chat changes
- const [lastChatId, setLastChatId] = useState(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(
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(
}
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(
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(
// 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()
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(
(
onDragOver={handleDragOver}
onDrop={handleDrop}
>
+ {/* Context Usage Pill - Top Right */}
+ {contextUsage && contextUsage.percentage > 0 && (
+
+
+
+ )}
{/* Attached Files Display with Thumbnails */}
{attachedFiles.length > 0 && (
@@ -2172,7 +2054,7 @@ const UserInput = forwardRef
(
{/* Highlight overlay */}
{(() => {
@@ -2220,7 +2102,7 @@ const UserInput = forwardRef(
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(
{/* Right side: Attach Button + Send Button */}
-
+
{/* Attach Button */}