Compare commits

..

63 Commits

Author SHA1 Message Date
Siddharth Ganesan
75e6e4f230 Fix config 2026-01-10 11:32:34 -08:00
Siddharth Ganesan
963f1d561a Merge branch 'staging' into feat/copilot-subagents 2026-01-10 11:29:06 -08:00
Siddharth Ganesan
5e9d8e1e9a Stuff 2026-01-10 11:16:33 -08:00
Siddharth Ganesan
152e942074 Merge origin/staging into feat/copilot-subagents 2026-01-10 10:46:36 -08:00
Siddharth Ganesan
3359d7b0d8 Fix ops 2026-01-10 10:13:39 -08:00
Siddharth Ganesan
b7d0f2053b Fix ops bug 2026-01-10 10:12:54 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Emir Karabeg
1f6f58cf7f improvement(copilot): diff controls 2026-01-09 20:17:49 -08:00
Emir Karabeg
41d767e170 improvement(copilot): ui/ux 2026-01-09 18:56:57 -08:00
Siddharth Ganesan
c5dc78ff08 Enable images 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
ae6e29512a Previous options should not be selectable 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
6639871c92 Fix lint 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
0ba5ec65f7 Diff view 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
8597786962 Persist and load chats properly 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
1aada6ba57 Fix thinking text 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
a9edbd71f1 Renaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
bd35dda8fa Fix thinking scroll 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
b2b06c3dd1 Streaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
29eefd8416 Plan 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
51b2297e35 Fix previews 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
aa8da99ce2 Subagent rendering 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
2b2ed6df1a Fix spacing between options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
7305ecf4fa Fixes to options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
d2ef972bbb Options select 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
90c875b895 Editor component 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4280461cb8 Add evaluator subagent 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
3be426af8e Add deploy mcp tools 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
8fcb0349d2 Fix rendering of edit subblocks 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
96dc2b7afd Lint 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
e95b6135ac Options 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
2fe0afaef4 Diff view in chat 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
d86e43945f Remove context usage code 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4fd5656c01 Diff in chat 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
54f6047dd3 Overlays 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
86f8e77293 Trigger request 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
05034edc83 Fix bugs 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
7a925ad45c Many subagents 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
442d8a1f45 Message queue 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
56366088d8 Tweaks 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
f6cd0cbc55 Edit, plan, debug subagents 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
df80309c3b Add subagents 2026-01-09 18:56:29 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

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

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
36 changed files with 608 additions and 1465 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border)]',
'transition-block-bg transition-ring',
'z-[20]'
)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
className='relative select-none rounded-[8px] border border-[var(--border)]'
style={{
width,
height,

View File

@@ -163,7 +163,7 @@ function AddMembersModal({
className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]'
>
<Checkbox checked={isSelected} />
<Avatar size='sm'>
<Avatar size='xs'>
{member.user?.image && (
<AvatarImage src={member.user.image} alt={name} />
)}
@@ -663,7 +663,7 @@ export function AccessControl() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='md'>
<Avatar size='sm'>
{member.userImage && <AvatarImage src={member.userImage} alt={name} />}
<AvatarFallback
style={{

View File

@@ -434,10 +434,12 @@ export function CredentialSets() {
filteredOwnedSets.length === 0 &&
!hasNoContent
// Early returns AFTER all hooks
if (membershipsLoading || invitationsLoading) {
return <CredentialSetsSkeleton />
}
// Detail view for a polling group
if (viewingSet) {
const activeMembers = members.filter((m) => m.status === 'active')
const totalCount = activeMembers.length + pendingInvitations.length
@@ -527,7 +529,7 @@ export function CredentialSets() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='md'>
<Avatar size='sm'>
{member.userImage && (
<AvatarImage src={member.userImage} alt={name} />
)}
@@ -581,7 +583,7 @@ export function CredentialSets() {
return (
<div key={invitation.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='md'>
<Avatar size='sm'>
<AvatarFallback
style={{ background: getUserColor(email) }}
className='border-0 text-white'

View File

@@ -1,41 +1,12 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
/**
* Validates a hex color string.
* Accepts 3 or 6 character hex codes with or without #.
*/
function isValidHex(hex: string): boolean {
const cleaned = hex.replace('#', '')
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleaned)
}
/**
* Normalizes a hex color to lowercase 6-character format with #.
*/
function normalizeHex(hex: string): string {
let cleaned = hex.replace('#', '').toLowerCase()
if (cleaned.length === 3) {
cleaned = cleaned
.split('')
.map((c) => c + c)
.join('')
}
return `#${cleaned}`
}
interface ContextMenuProps {
/**
@@ -82,14 +53,6 @@ interface ContextMenuProps {
* Callback when delete is clicked
*/
onDelete: () => void
/**
* Callback when color is changed
*/
onColorChange?: (color: string) => void
/**
* Current workflow color (for showing selected state)
*/
currentColor?: string
/**
* Whether to show the open in new tab option (default: false)
* Set to true for items that can be opened in a new tab
@@ -120,21 +83,11 @@ interface ContextMenuProps {
* Set to true for items that can be exported (like workspaces)
*/
showExport?: boolean
/**
* Whether to show the change color option (default: false)
* Set to true for workflows to allow color customization
*/
showColorChange?: boolean
/**
* Whether the export option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableExport?: boolean
/**
* Whether the change color option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableColorChange?: boolean
/**
* Whether the rename option is disabled (default: false)
* Set to true when user lacks permissions
@@ -181,76 +134,19 @@ export function ContextMenu({
onDuplicate,
onExport,
onDelete,
onColorChange,
currentColor,
showOpenInNewTab = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
showDuplicate = true,
showExport = false,
showColorChange = false,
disableExport = false,
disableColorChange = false,
disableRename = false,
disableDuplicate = false,
disableDelete = false,
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
// Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
useEffect(() => {
setHexInput(currentColor || '#ffffff')
}, [currentColor])
const canSubmitHex = useMemo(() => {
if (!isValidHex(hexInput)) return false
const normalized = normalizeHex(hexInput)
if (currentColor && normalized.toLowerCase() === currentColor.toLowerCase()) return false
return true
}, [hexInput, currentColor])
const handleHexSubmit = useCallback(() => {
if (!canSubmitHex || !onColorChange) return
const normalized = normalizeHex(hexInput)
onColorChange(normalized)
setHexInput(normalized)
}, [hexInput, canSubmitHex, onColorChange])
const handleHexKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleHexSubmit()
}
},
[handleHexSubmit]
)
const handleHexChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value.trim()
if (value && !value.startsWith('#')) {
value = `#${value}`
}
value = value.slice(0, 1) + value.slice(1).replace(/[^0-9a-fA-F]/g, '')
setHexInput(value.slice(0, 7))
}, [])
const handleHexFocus = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
e.target.select()
}, [])
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showColorChange && onColorChange)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
<Popover
open={isOpen}
@@ -268,21 +164,10 @@ export function ContextMenu({
height: '1px',
}}
/>
<PopoverContent
ref={menuRef}
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* Back button - shown only when in a folder */}
<PopoverBackButton />
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
rootOnly
onClick={() => {
onOpenInNewTab()
onClose()
@@ -291,12 +176,11 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
rootOnly
disabled={disableRename}
onClick={() => {
onRename()
@@ -308,7 +192,6 @@ export function ContextMenu({
)}
{showCreate && onCreate && (
<PopoverItem
rootOnly
disabled={disableCreate}
onClick={() => {
onCreate()
@@ -320,7 +203,6 @@ export function ContextMenu({
)}
{showCreateFolder && onCreateFolder && (
<PopoverItem
rootOnly
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
@@ -330,72 +212,11 @@ export function ContextMenu({
Create folder
</PopoverItem>
)}
{showColorChange && onColorChange && (
<PopoverFolder
id='color-picker'
title='Change color'
expandOnHover
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
>
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
{/* Preset colors */}
<div className='grid grid-cols-6 gap-[4px]'>
{WORKFLOW_COLORS.map(({ color, name }) => (
<button
key={color}
type='button'
title={name}
onClick={(e) => {
e.stopPropagation()
onColorChange(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
{/* Hex input */}
<div className='flex items-center gap-[4px]'>
<div
className='h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
style={{
backgroundColor: isValidHex(hexInput) ? normalizeHex(hexInput) : '#ffffff',
}}
/>
<input
type='text'
value={hexInput}
onChange={handleHexChange}
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
/>
<button
type='button'
disabled={!canSubmitHex}
onClick={(e) => {
e.stopPropagation()
handleHexSubmit()
}}
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
>
<Check className='h-[12px] w-[12px]' />
</button>
</div>
</div>
</PopoverFolder>
)}
{/* Copy and export actions */}
{hasEditSection && hasCopySection && <PopoverDivider rootOnly />}
{(showDuplicate || showExport) && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
rootOnly
disabled={disableDuplicate}
onClick={() => {
onDuplicate()
@@ -407,7 +228,6 @@ export function ContextMenu({
)}
{showExport && onExport && (
<PopoverItem
rootOnly
disabled={disableExport}
onClick={() => {
onExport()
@@ -419,9 +239,8 @@ export function ContextMenu({
)}
{/* Destructive action */}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
<PopoverDivider />
<PopoverItem
rootOnly
disabled={disableDelete}
onClick={() => {
onDelete()

View File

@@ -3,9 +3,8 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -24,7 +23,10 @@ import {
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -171,7 +173,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
} = useContextMenu()
// Rename hook
@@ -241,40 +242,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
[isEditing, handleRenameKeyDown, handleExpandKeyDown]
)
/**
* Handle more button pointerdown - prevents click-outside dismissal when toggling
*/
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
/**
* Handle more button click - toggles context menu at button position
*/
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
}
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenu({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isContextMenuOpen, closeMenu, handleContextMenu]
)
return (
<>
<div
@@ -336,22 +303,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
spellCheck='false'
/>
) : (
<>
<span
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
<button
type='button'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
</>
<span
className='truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
)}
</div>

View File

@@ -1,24 +1,15 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
/**
* Avatar display configuration for responsive layout.
*/
const AVATAR_CONFIG = {
MIN_COUNT: 3,
MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20,
} as const
interface AvatarsProps {
workflowId: string
maxVisible?: number
/**
* Callback fired when the presence visibility changes.
* Used by parent components to adjust layout (e.g., text truncation spacing).
@@ -39,29 +30,45 @@ interface UserAvatarProps {
}
/**
* Individual user avatar using emcn Avatar component.
* Individual user avatar with error handling for image loading.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl) && !imageError
// Reset error state when avatar URL changes
useEffect(() => {
setImageError(false)
}, [user.avatarUrl])
const avatarElement = (
<Avatar size='xs' style={{ zIndex: index + 1 } as CSSProperties}>
{user.avatarUrl && (
<AvatarImage
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized
onError={() => setImageError(true)}
/>
) : (
initials
)}
<AvatarFallback
style={{ background: color }}
className='border-0 font-semibold text-[7px] text-white'
>
{initials}
</AvatarFallback>
</Avatar>
</div>
)
if (user.userName) {
@@ -85,26 +92,14 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Calculate max visible avatars based on sidebar width.
* Scales between MIN_COUNT and MAX_COUNT as sidebar expands.
*/
const maxVisible = useMemo(() => {
const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN
const additionalAvatars = Math.floor(widthDelta / AVATAR_CONFIG.WIDTH_PER_AVATAR)
const calculated = AVATAR_CONFIG.MIN_COUNT + additionalAvatars
return Math.max(AVATAR_CONFIG.MIN_COUNT, Math.min(AVATAR_CONFIG.MAX_COUNT, calculated))
}, [sidebarWidth])
/**
* Only show presence for the currently active workflow.
* Filter out the current user from the list.
* Only show presence for the currently active workflow
* Filter out the current user from the list
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
@@ -127,6 +122,7 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
return { visibleUsers: visible, overflowCount: overflow }
}, [workflowUsers, maxVisible])
// Notify parent when avatars are present or not
useEffect(() => {
const hasAnyAvatars = visibleUsers.length > 0
if (typeof onPresenceChange === 'function') {
@@ -139,25 +135,26 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
}
return (
<div className='-space-x-1 flex items-center'>
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Avatar size='xs' style={{ zIndex: 0 } as CSSProperties}>
<AvatarFallback className='border-0 bg-[#404040] font-semibold text-[7px] text-white'>
+{overflowCount}
</AvatarFallback>
</Avatar>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full bg-[#404040] font-semibold text-[7px] text-white'
style={{ zIndex: 10 - visibleUsers.length } as CSSProperties}
>
+{overflowCount}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</Tooltip.Content>
</Tooltip.Root>
)}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
))}
</div>
)
}

View File

@@ -2,7 +2,6 @@
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -109,16 +108,6 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
}, [workspaceId, workflow.id])
/**
* Changes the workflow color
*/
const handleColorChange = useCallback(
(color: string) => {
updateWorkflow(workflow.id, { color })
},
[workflow.id, updateWorkflow]
)
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
@@ -153,38 +142,8 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
menuRef,
handleContextMenu: handleContextMenuBase,
closeMenu,
preventDismiss,
} = useContextMenu()
/**
* Captures selection state for context menu operations
*/
const captureSelectionState = useCallback(() => {
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
const finalSelection = useFolderStore.getState().selectedWorkflows
const finalIsSelected = finalSelection.has(workflow.id)
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
capturedSelectionRef.current = {
workflowIds,
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
}, [workflow.id, workflows, canDeleteWorkflows])
/**
* Handle right-click - ensure proper selection behavior and capture selection state
* If right-clicking on an unselected workflow, select only that workflow
@@ -192,46 +151,39 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
*/
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
captureSelectionState()
handleContextMenuBase(e)
},
[captureSelectionState, handleContextMenuBase]
)
// Check current selection state at time of right-click
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
/**
* Handle more button pointerdown - prevents click-outside dismissal when toggling
*/
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
/**
* Handle more button click - toggles context menu at button position
*/
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
// If this workflow is not in the current selection, select only this workflow
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
captureSelectionState()
// Open context menu aligned with the button
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
// Capture the selection state at right-click time
const finalSelection = useFolderStore.getState().selectedWorkflows
const finalIsSelected = finalSelection.has(workflow.id)
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
// Store in ref so it persists even if selection changes
capturedSelectionRef.current = {
workflowIds,
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
// Check if the captured selection can be deleted
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
// If already selected with multiple selections, keep all selections
handleContextMenuBase(e)
},
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
)
// Rename hook
@@ -357,17 +309,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
)}
</div>
{!isEditing && (
<>
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
<button
type='button'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
</>
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
)}
</Link>
@@ -382,17 +324,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
onDuplicate={handleDuplicateWorkflow}
onExport={handleExportWorkflow}
onDelete={handleOpenDeleteModal}
onColorChange={handleColorChange}
currentColor={workflow.color}
showOpenInNewTab={selectedWorkflows.size <= 1}
showRename={selectedWorkflows.size <= 1}
showDuplicate={true}
showExport={true}
showColorChange={selectedWorkflows.size <= 1}
disableRename={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit}
disableExport={!userPermissions.canEdit}
disableColorChange={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
/>

View File

@@ -657,7 +657,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={removeEmailItem}
onInputChange={() => setErrorMessage(null)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'

View File

@@ -27,8 +27,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const menuRef = useRef<HTMLDivElement>(null)
// Used to prevent click-outside dismissal when trigger is clicked
const dismissPreventedRef = useRef(false)
/**
* Handle right-click event
@@ -57,14 +55,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
setIsOpen(false)
}, [])
/**
* Prevent the next click-outside from dismissing the menu.
* Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
*/
const preventDismiss = useCallback(() => {
dismissPreventedRef.current = true
}, [])
/**
* Handle clicks outside the menu to close it
*/
@@ -72,11 +62,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
// Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
if (dismissPreventedRef.current) {
dismissPreventedRef.current = false
return
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
@@ -99,6 +84,5 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
}
}

View File

@@ -1,11 +1,13 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
const logger = createLogger('useDuplicateWorkflow')

View File

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

View File

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

View File

@@ -12,10 +12,11 @@ import { cn } from '@/lib/core/utils/cn'
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
xs: 'h-3.5 w-3.5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
},
},
defaultVariants: {
@@ -37,10 +38,11 @@ const avatarStatusVariants = cva(
away: 'bg-[#f59e0b]',
},
size: {
xs: 'h-1.5 w-1.5 border',
sm: 'h-2 w-2',
md: 'h-2.5 w-2.5',
lg: 'h-3 w-3',
xs: 'h-2 w-2',
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xl: 'h-4 w-4',
},
},
defaultVariants: {

View File

@@ -52,7 +52,6 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
@@ -167,9 +166,6 @@ interface PopoverContextValue {
colorScheme: PopoverColorScheme
searchQuery: string
setSearchQuery: (query: string) => void
/** ID of the last hovered item (for hover submenus) */
lastHoveredItem: string | null
setLastHoveredItem: (id: string | null) => void
}
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
@@ -212,24 +208,12 @@ const Popover: React.FC<PopoverProps> = ({
variant = 'default',
size = 'md',
colorScheme = 'default',
open,
...props
}) => {
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
const [folderTitle, setFolderTitle] = React.useState<string | null>(null)
const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const [lastHoveredItem, setLastHoveredItem] = React.useState<string | null>(null)
React.useEffect(() => {
if (open === false) {
setCurrentFolder(null)
setFolderTitle(null)
setOnFolderSelect(null)
setSearchQuery('')
setLastHoveredItem(null)
}
}, [open])
const openFolder = React.useCallback(
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
@@ -262,8 +246,6 @@ const Popover: React.FC<PopoverProps> = ({
colorScheme,
searchQuery,
setSearchQuery,
lastHoveredItem,
setLastHoveredItem,
}),
[
openFolder,
@@ -275,15 +257,12 @@ const Popover: React.FC<PopoverProps> = ({
size,
colorScheme,
searchQuery,
lastHoveredItem,
]
)
return (
<PopoverContext.Provider value={contextValue}>
<PopoverPrimitive.Root open={open} {...props}>
{children}
</PopoverPrimitive.Root>
<PopoverPrimitive.Root {...props}>{children}</PopoverPrimitive.Root>
</PopoverContext.Provider>
)
}
@@ -517,17 +496,7 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
(
{
className,
active,
rootOnly,
disabled,
showCheck = false,
children,
onClick,
onMouseEnter,
...props
},
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
ref
) => {
const context = React.useContext(PopoverContext)
@@ -545,12 +514,6 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
onClick?.(e)
}
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
// Clear last hovered item to close any open hover submenus
context?.setLastHoveredItem(null)
onMouseEnter?.(e)
}
return (
<div
className={cn(
@@ -566,7 +529,6 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{children}
@@ -627,150 +589,44 @@ export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivEle
children?: React.ReactNode
/** Whether currently active/selected */
active?: boolean
/**
* Expand folder on hover to show submenu alongside parent
* When true, hovering shows a floating submenu; clicking still uses inline navigation
* @default false
*/
expandOnHover?: boolean
}
/**
* Expandable folder that shows nested content.
* Supports two modes:
* - Click mode (default): Replaces parent content, shows back button
* - Hover mode (expandOnHover): Shows floating submenu alongside parent
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
(
{
className,
id,
title,
icon,
onOpen,
onSelect,
children,
active,
expandOnHover = false,
...props
},
ref
) => {
const {
openFolder,
currentFolder,
isInFolder,
variant,
size,
colorScheme,
lastHoveredItem,
setLastHoveredItem,
} = usePopoverContext()
const [submenuPosition, setSubmenuPosition] = React.useState<{ top: number; left: number }>({
top: 0,
left: 0,
})
const triggerRef = React.useRef<HTMLDivElement>(null)
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
usePopoverContext()
// Submenu is open when this folder is the last hovered item (for expandOnHover mode)
const isHoverOpen = expandOnHover && lastHoveredItem === id
// Merge refs
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
triggerRef.current = node
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
},
[ref]
)
// If we're in a folder and this isn't the current one, hide
if (isInFolder && currentFolder !== id) return null
// If this folder is open via click (inline mode), render children directly
if (currentFolder === id) return <>{children}</>
const handleClickOpen = () => {
openFolder(id, title, onOpen, onSelect)
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (expandOnHover) {
// In hover mode, clicking opens inline and clears hover state
setLastHoveredItem(null)
}
handleClickOpen()
}
const handleMouseEnter = () => {
if (!expandOnHover) return
// Calculate position for submenu
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
const parentPopover = triggerRef.current.closest('[data-radix-popper-content-wrapper]')
const parentRect = parentPopover?.getBoundingClientRect()
// Position to the right of the parent popover with a small gap
setSubmenuPosition({
top: rect.top,
left: parentRect ? parentRect.right + 4 : rect.right + 4,
})
}
setLastHoveredItem(id)
onOpen?.()
openFolder(id, title, onOpen, onSelect)
}
return (
<>
<div
ref={mergedRef}
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active || isHoverOpen),
className
)}
role='menuitem'
aria-haspopup='true'
aria-expanded={isHoverOpen}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={STYLES.size[size].icon} />
</div>
{/* Hover submenu - rendered as a portal to escape overflow clipping */}
{isHoverOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
className={cn(
'fixed z-[10000201] min-w-[120px]',
STYLES.content,
STYLES.colorScheme[colorScheme].content,
'shadow-lg'
)}
style={{
top: submenuPosition.top,
left: submenuPosition.left,
}}
>
{children}
</div>,
document.body
)}
</>
<div
ref={ref}
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
className
)}
role='menuitem'
aria-haspopup='true'
aria-expanded={false}
onClick={handleClick}
{...props}
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={STYLES.size[size].icon} />
</div>
)
}
)
@@ -809,10 +665,7 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
className
)}
role='button'
onClick={(e) => {
e.stopPropagation()
closeFolder()
}}
onClick={closeFolder}
{...props}
>
<ChevronLeft className={STYLES.size[size].icon} />

View File

@@ -166,8 +166,6 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
onAdd: (value: string) => boolean
/** Callback when a tag is removed (receives value, index, and isValid) */
onRemove: (value: string, index: number, isValid: boolean) => void
/** Callback when the input value changes (useful for clearing errors) */
onInputChange?: (value: string) => void
/** Placeholder text for the input */
placeholder?: string
/** Placeholder text when there are existing tags */
@@ -209,7 +207,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
items,
onAdd,
onRemove,
onInputChange,
placeholder = 'Enter values',
placeholderWithTags = 'Add another',
disabled = false,
@@ -347,12 +344,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
})
if (addedCount === 0 && pastedValues.length === 1) {
const newValue = inputValue + pastedValues[0]
setInputValue(newValue)
onInputChange?.(newValue)
setInputValue(inputValue + pastedValues[0])
}
},
[onAdd, inputValue, onInputChange]
[onAdd, inputValue]
)
const handleBlur = React.useCallback(() => {
@@ -427,10 +422,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
name={name}
type='text'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
onInputChange?.(e.target.value)
}}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}

View File

@@ -1,7 +1,6 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import {
createOptimisticMutationHandlers,
@@ -9,7 +8,10 @@ import {
} from '@/hooks/queries/utils/optimistic-mutation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

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

View File

@@ -1,75 +0,0 @@
/**
* Workflow color constants and utilities.
* Centralized location for all workflow color-related functionality.
*
* Colors are aligned with the brand color scheme:
* - Purple: brand-400 (#8e4cfb)
* - Blue: brand-secondary (#33b4ff)
* - Green: brand-tertiary (#22c55e)
* - Red: text-error (#ef4444)
* - Orange: warning (#f97316)
* - Pink: (#ec4899)
*/
/**
* Full list of available workflow colors with names.
* Used for color picker and random color assignment.
* Each base color has 6 vibrant shades optimized for both light and dark themes.
*/
export const WORKFLOW_COLORS = [
// Shade 1 - all base colors (brightest)
{ color: '#c084fc', name: 'Purple 1' },
{ color: '#5ed8ff', name: 'Blue 1' },
{ color: '#4aea7f', name: 'Green 1' },
{ color: '#ff6b6b', name: 'Red 1' },
{ color: '#ff9642', name: 'Orange 1' },
{ color: '#f472b6', name: 'Pink 1' },
// Shade 2 - all base colors
{ color: '#a855f7', name: 'Purple 2' },
{ color: '#38c8ff', name: 'Blue 2' },
{ color: '#2ed96a', name: 'Green 2' },
{ color: '#ff5555', name: 'Red 2' },
{ color: '#ff8328', name: 'Orange 2' },
{ color: '#ec4899', name: 'Pink 2' },
// Shade 3 - all base colors
{ color: '#9333ea', name: 'Purple 3' },
{ color: '#33b4ff', name: 'Blue 3' },
{ color: '#22c55e', name: 'Green 3' },
{ color: '#ef4444', name: 'Red 3' },
{ color: '#f97316', name: 'Orange 3' },
{ color: '#e11d89', name: 'Pink 3' },
// Shade 4 - all base colors
{ color: '#8e4cfb', name: 'Purple 4' },
{ color: '#1e9de8', name: 'Blue 4' },
{ color: '#18b04c', name: 'Green 4' },
{ color: '#dc3535', name: 'Red 4' },
{ color: '#e56004', name: 'Orange 4' },
{ color: '#d61c7a', name: 'Pink 4' },
// Shade 5 - all base colors
{ color: '#7c3aed', name: 'Purple 5' },
{ color: '#1486d1', name: 'Blue 5' },
{ color: '#0e9b3a', name: 'Green 5' },
{ color: '#c92626', name: 'Red 5' },
{ color: '#d14d00', name: 'Orange 5' },
{ color: '#be185d', name: 'Pink 5' },
// Shade 6 - all base colors (darkest)
{ color: '#6322c9', name: 'Purple 6' },
{ color: '#0a6fb8', name: 'Blue 6' },
{ color: '#048628', name: 'Green 6' },
{ color: '#b61717', name: 'Red 6' },
{ color: '#bd3a00', name: 'Orange 6' },
{ color: '#9d174d', name: 'Pink 6' },
] as const
/**
* Generates a random color for a new workflow
* @returns A hex color string from the available workflow colors
*/
export function getNextWorkflowColor(): string {
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)].color
}

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type {
@@ -12,6 +11,7 @@ import type {
WorkflowMetadata,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

View File

@@ -1,410 +1,321 @@
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
// Available workflow colors
export const WORKFLOW_COLORS = [
// Blues - vibrant blue tones
'#3972F6', // Blue (original)
'#2E5BF5', // Deeper Blue
'#1E4BF4', // Royal Blue
'#0D3BF3', // Deep Royal Blue
// Pinks/Magentas - vibrant pink and magenta tones
'#F639DD', // Pink/Magenta (original)
'#F529CF', // Deep Magenta
'#F749E7', // Light Magenta
'#F419C1', // Hot Pink
// Oranges/Yellows - vibrant orange and yellow tones
'#F6B539', // Orange/Yellow (original)
'#F5A529', // Deep Orange
'#F49519', // Burnt Orange
'#F38509', // Deep Burnt Orange
// Purples - vibrant purple tones
'#8139F6', // Purple (original)
'#7129F5', // Deep Purple
'#6119F4', // Royal Purple
'#5109F3', // Deep Royal Purple
// Greens - vibrant green tones
'#39B54A', // Green (original)
'#29A53A', // Deep Green
'#19952A', // Forest Green
'#09851A', // Deep Forest Green
// Teals/Cyans - vibrant teal and cyan tones
'#39B5AB', // Teal (original)
'#29A59B', // Deep Teal
'#19958B', // Dark Teal
'#09857B', // Deep Dark Teal
// Reds/Red-Oranges - vibrant red and red-orange tones
'#F66839', // Red/Orange (original)
'#F55829', // Deep Red-Orange
'#F44819', // Burnt Red
'#F33809', // Deep Burnt Red
// Additional vibrant colors for variety
// Corals - warm coral tones
'#F6397A', // Coral
'#F5296A', // Deep Coral
'#F7498A', // Light Coral
// Crimsons - deep red tones
'#DC143C', // Crimson
'#CC042C', // Deep Crimson
'#EC243C', // Light Crimson
'#BC003C', // Dark Crimson
'#FC343C', // Bright Crimson
// Mint - fresh green tones
'#00FF7F', // Mint Green
'#00EF6F', // Deep Mint
'#00DF5F', // Dark Mint
// Slate - blue-gray tones
'#6A5ACD', // Slate Blue
'#5A4ABD', // Deep Slate
'#4A3AAD', // Dark Slate
// Amber - warm orange-yellow tones
'#FFBF00', // Amber
'#EFAF00', // Deep Amber
'#DF9F00', // Dark Amber
]
// Generates a random color for a new workflow
export function getNextWorkflowColor(): string {
// Simply return a random color from the available colors
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)]
}
// Adjectives and nouns for creative workflow names
const ADJECTIVES = [
// Light & Luminosity
'Radiant',
'Luminous',
'Blazing',
'Glowing',
'Bright',
'Gleaming',
'Shining',
'Lustrous',
'Flaring',
'Vivid',
'Dazzling',
'Beaming',
'Brilliant',
'Lit',
'Ablaze',
// Celestial Descriptors
'Stellar',
'Crystal',
'Golden',
'Silver',
'Mystic',
'Cosmic',
'Astral',
'Galactic',
'Nebular',
'Orbital',
'Electric',
'Frozen',
'Burning',
'Shining',
'Dancing',
'Flying',
'Roaring',
'Whispering',
'Glowing',
'Sparkling',
'Thunder',
'Lightning',
'Storm',
'Ocean',
'Mountain',
'Forest',
'Desert',
'Arctic',
'Tropical',
'Midnight',
'Dawn',
'Sunset',
'Rainbow',
'Diamond',
'Ruby',
'Emerald',
'Sapphire',
'Pearl',
'Jade',
'Amber',
'Coral',
'Ivory',
'Obsidian',
'Marble',
'Velvet',
'Silk',
'Satin',
'Linen',
'Cotton',
'Wool',
'Cashmere',
'Denim',
'Neon',
'Pastel',
'Vibrant',
'Muted',
'Bold',
'Subtle',
'Bright',
'Dark',
'Ancient',
'Modern',
'Eternal',
'Swift',
'Radiant',
'Quantum',
'Stellar',
'Lunar',
'Solar',
'Starlit',
'Heavenly',
'Celestial',
'Sidereal',
'Planetary',
'Starry',
'Spacial',
// Scale & Magnitude
'Infinite',
'Vast',
'Boundless',
'Immense',
'Colossal',
'Titanic',
'Massive',
'Grand',
'Supreme',
'Ultimate',
'Epic',
'Enormous',
'Gigantic',
'Limitless',
'Total',
// Temporal
'Eternal',
'Ancient',
'Timeless',
'Enduring',
'Ageless',
'Immortal',
'Primal',
'Nascent',
'First',
'Elder',
'Lasting',
'Undying',
'Perpetual',
'Final',
'Prime',
// Movement & Energy
'Sidbuck',
'Swift',
'Drifting',
'Spinning',
'Surging',
'Pulsing',
'Soaring',
'Racing',
'Falling',
'Rising',
'Circling',
'Streaking',
'Hurtling',
'Floating',
'Orbiting',
'Spiraling',
// Colors of Space
'Ethereal',
'Phantom',
'Shadow',
'Crimson',
'Azure',
'Violet',
'Indigo',
'Amber',
'Sapphire',
'Obsidian',
'Silver',
'Golden',
'Scarlet',
'Cobalt',
'Emerald',
'Ruby',
'Onyx',
'Ivory',
// Physical Properties
'Magnetic',
'Quantum',
'Thermal',
'Photonic',
'Ionic',
'Plasma',
'Spectral',
'Charged',
'Polar',
'Dense',
'Magenta',
'Turquoise',
'Indigo',
'Jade',
'Noble',
'Regal',
'Imperial',
'Royal',
'Supreme',
'Prime',
'Elite',
'Ultra',
'Mega',
'Hyper',
'Super',
'Neo',
'Cyber',
'Digital',
'Virtual',
'Sonic',
'Atomic',
'Nuclear',
'Electric',
'Kinetic',
'Static',
// Atmosphere & Mystery
'Ethereal',
'Mystic',
'Phantom',
'Shadow',
'Silent',
'Distant',
'Hidden',
'Veiled',
'Fading',
'Arcane',
'Cryptic',
'Obscure',
'Dim',
'Dusky',
'Shrouded',
// Temperature & State
'Frozen',
'Burning',
'Molten',
'Volatile',
'Icy',
'Fiery',
'Cool',
'Warm',
'Cold',
'Hot',
'Searing',
'Frigid',
'Scalding',
'Chilled',
'Heated',
// Power & Force
'Mighty',
'Fierce',
'Raging',
'Wild',
'Serene',
'Tranquil',
'Harmonic',
'Resonant',
'Steady',
'Bold',
'Potent',
'Violent',
'Calm',
'Furious',
'Forceful',
// Texture & Form
'Smooth',
'Jagged',
'Fractured',
'Solid',
'Hollow',
'Curved',
'Sharp',
'Fluid',
'Rigid',
'Warped',
// Rare & Precious
'Noble',
'Pure',
'Rare',
'Pristine',
'Flawless',
'Unique',
'Exotic',
'Sacred',
'Divine',
'Hallowed',
'Laser',
'Plasma',
'Magnetic',
]
const NOUNS = [
// Stars & Stellar Objects
'Star',
'Sun',
'Pulsar',
'Quasar',
'Magnetar',
'Nova',
'Supernova',
'Hypernova',
'Neutron',
'Dwarf',
'Giant',
'Protostar',
'Blazar',
'Cepheid',
'Binary',
// Galaxies & Clusters
'Phoenix',
'Dragon',
'Eagle',
'Wolf',
'Lion',
'Tiger',
'Panther',
'Falcon',
'Hawk',
'Raven',
'Swan',
'Dove',
'Butterfly',
'Firefly',
'Dragonfly',
'Hummingbird',
'Galaxy',
'Nebula',
'Cluster',
'Void',
'Filament',
'Halo',
'Bulge',
'Spiral',
'Ellipse',
'Arm',
'Disk',
'Shell',
'Remnant',
'Cloud',
'Dust',
// Planets & Moons
'Planet',
'Moon',
'World',
'Exoplanet',
'Jovian',
'Titan',
'Europa',
'Io',
'Callisto',
'Ganymede',
'Triton',
'Phobos',
'Deimos',
'Enceladus',
'Charon',
// Small Bodies
'Comet',
'Meteor',
'Star',
'Moon',
'Sun',
'Planet',
'Asteroid',
'Meteorite',
'Bolide',
'Fireball',
'Iceball',
'Plutino',
'Centaur',
'Trojan',
'Shard',
'Fragment',
'Debris',
'Rock',
'Ice',
// Constellations & Myths
'Orion',
'Andromeda',
'Perseus',
'Pegasus',
'Phoenix',
'Draco',
'Cygnus',
'Aquila',
'Lyra',
'Vega',
'Centaurus',
'Hydra',
'Sirius',
'Polaris',
'Altair',
// Celestial Phenomena
'Eclipse',
'Constellation',
'Aurora',
'Corona',
'Flare',
'Storm',
'Vortex',
'Jet',
'Burst',
'Pulse',
'Wave',
'Ripple',
'Shimmer',
'Glow',
'Flash',
'Spark',
// Cosmic Structures
'Eclipse',
'Solstice',
'Equinox',
'Horizon',
'Zenith',
'Nadir',
'Apex',
'Meridian',
'Equinox',
'Solstice',
'Transit',
'Aphelion',
'Orbit',
'Axis',
'Pole',
'Equator',
'Limb',
'Arc',
// Space & Dimensions
'Cosmos',
'Universe',
'Dimension',
'Realm',
'Expanse',
'Infinity',
'Continuum',
'Manifold',
'Abyss',
'Ether',
'Vacuum',
'Space',
'Fabric',
'Plane',
'Domain',
// Energy & Particles
'Photon',
'Neutrino',
'Proton',
'Electron',
'Positron',
'Quark',
'Boson',
'Fermion',
'Tachyon',
'Graviton',
'Meson',
'Gluon',
'Lepton',
'Muon',
'Pion',
// Regions & Zones
'Sector',
'Quadrant',
'Zone',
'Belt',
'Ring',
'Field',
'Stream',
'Current',
'Wake',
'Region',
'Frontier',
'Border',
'Edge',
'Margin',
'Rim',
// Navigation & Discovery
'Beacon',
'Signal',
'Probe',
'Voyager',
'Pioneer',
'Seeker',
'Wanderer',
'Nomad',
'Drifter',
'Scout',
'Explorer',
'Ranger',
'Surveyor',
'Sentinel',
'Watcher',
// Portals & Passages
'Gateway',
'Portal',
'Nexus',
'Castle',
'Tower',
'Bridge',
'Conduit',
'Channel',
'Passage',
'Rift',
'Warp',
'Fold',
'Tunnel',
'Crossing',
'Link',
'Path',
'Route',
// Core & Systems
'Core',
'Matrix',
'Lattice',
'Network',
'Circuit',
'Array',
'Reactor',
'Engine',
'Forge',
'Crucible',
'Hub',
'Node',
'Kernel',
'Center',
'Heart',
// Cosmic Objects
'Crater',
'Rift',
'Chasm',
'Garden',
'Fountain',
'Palace',
'Temple',
'Cathedral',
'Lighthouse',
'Windmill',
'Waterfall',
'Canyon',
'Valley',
'Peak',
'Ridge',
'Basin',
'Plateau',
'Valley',
'Trench',
'Cliff',
'Ocean',
'River',
'Lake',
'Stream',
'Pond',
'Bay',
'Cove',
'Harbor',
'Island',
'Peninsula',
'Archipelago',
'Atoll',
'Reef',
'Lagoon',
'Fjord',
'Delta',
'Cake',
'Cookie',
'Muffin',
'Cupcake',
'Pie',
'Tart',
'Brownie',
'Donut',
'Pancake',
'Waffle',
'Croissant',
'Bagel',
'Pretzel',
'Biscuit',
'Scone',
'Crumpet',
'Thunder',
'Blizzard',
'Tornado',
'Hurricane',
'Tsunami',
'Volcano',
'Glacier',
'Avalanche',
'Vortex',
'Tempest',
'Maelstrom',
'Whirlwind',
'Cyclone',
'Typhoon',
'Monsoon',
'Anvil',
'Hammer',
'Forge',
'Blade',
'Sword',
'Shield',
'Arrow',
'Spear',
'Crown',
'Throne',
'Scepter',
'Orb',
'Gem',
'Crystal',
'Prism',
'Spectrum',
'Beacon',
'Signal',
'Pulse',
'Wave',
'Surge',
'Tide',
'Current',
'Flow',
'Circuit',
'Node',
'Core',
'Matrix',
'Network',
'System',
'Engine',
'Reactor',
'Generator',
'Dynamo',
'Catalyst',
'Nexus',
'Portal',
'Gateway',
'Passage',
'Conduit',
'Channel',
]
/**

View File

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