mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
Improvement: subblocks (#2850)
* improvement(panel): increased default width * improvement: subblocks * fix: generate, mcp, ring
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 232px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--panel-width: 290px; /* PANEL_WIDTH.DEFAULT */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
--terminal-height: 155px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
@@ -77,24 +77,6 @@
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > div > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__node.selected > div > div::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1.75px var(--brand-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
@@ -576,32 +558,6 @@ input[type="search"]::-ms-clear {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.streaming-effect {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.streaming-effect::after {
|
||||
content: "";
|
||||
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%
|
||||
);
|
||||
animation: code-shimmer 1.5s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .streaming-effect::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.loading-placeholder::placeholder {
|
||||
animation: placeholder-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
+10
-5
@@ -336,6 +336,10 @@ export function Code({
|
||||
setCode('')
|
||||
}
|
||||
|
||||
handleStreamChunkRef.current = (chunk: string) => {
|
||||
setCode((prev) => prev + chunk)
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
setCode(generatedCode)
|
||||
if (!isPreview && !disabled) {
|
||||
@@ -691,11 +695,7 @@ export function Code({
|
||||
/>
|
||||
)}
|
||||
|
||||
<CodeEditor.Container
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
isStreaming={isAiStreaming}
|
||||
>
|
||||
<CodeEditor.Container onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
{wandConfig?.enabled &&
|
||||
!isAiStreaming &&
|
||||
@@ -761,6 +761,11 @@ export function Code({
|
||||
}}
|
||||
onFocus={() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
// Show tag dropdown on focus when code is empty
|
||||
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
|
||||
+46
@@ -115,6 +115,7 @@ export function ConditionInput({
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
|
||||
|
||||
/**
|
||||
* Determines if a reference string should be highlighted in the editor.
|
||||
@@ -728,6 +729,20 @@ export function ConditionInput({
|
||||
})
|
||||
}, [conditionalBlocks.length])
|
||||
|
||||
// Capture textarea refs from Editor components (condition mode)
|
||||
useEffect(() => {
|
||||
if (!isRouterMode && containerRef.current) {
|
||||
conditionalBlocks.forEach((block) => {
|
||||
const textarea = containerRef.current?.querySelector(
|
||||
`[data-block-id="${block.id}"] textarea`
|
||||
) as HTMLTextAreaElement | null
|
||||
if (textarea) {
|
||||
inputRefs.current.set(block.id, textarea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [conditionalBlocks, isRouterMode])
|
||||
|
||||
// Show loading or empty state if not ready or no blocks
|
||||
if (!isReady || conditionalBlocks.length === 0) {
|
||||
return (
|
||||
@@ -842,6 +857,9 @@ export function ConditionInput({
|
||||
onDrop={(e) => handleDrop(block.id, e)}
|
||||
>
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(block.id, el)
|
||||
}}
|
||||
data-router-block-id={block.id}
|
||||
value={block.value}
|
||||
onChange={(e) => {
|
||||
@@ -869,6 +887,15 @@ export function ConditionInput({
|
||||
)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && block.value.trim() === '') {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: true, cursorPosition: 0 } : b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
@@ -929,6 +956,11 @@ export function ConditionInput({
|
||||
)
|
||||
)
|
||||
}}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(block.id) || null,
|
||||
} as React.RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1006,6 +1038,15 @@ export function ConditionInput({
|
||||
)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && block.value.trim() === '') {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
b.id === block.id ? { ...b, showTags: true, cursorPosition: 0 } : b
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
highlight={(codeToHighlight) => {
|
||||
const placeholders: {
|
||||
placeholder: string
|
||||
@@ -1113,6 +1154,11 @@ export function ConditionInput({
|
||||
)
|
||||
)
|
||||
}}
|
||||
inputRef={
|
||||
{
|
||||
current: inputRefs.current.get(block.id) || null,
|
||||
} as React.RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1
@@ -288,6 +288,7 @@ export function DocumentTagEntry({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
+1
@@ -214,6 +214,7 @@ export function EvalInput({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
placeholder='How accurate is the response?'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
|
||||
+1
@@ -237,6 +237,7 @@ function InputMappingField({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => handleScroll(e)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
+1
@@ -276,6 +276,7 @@ export function KnowledgeTagFilters({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
+24
-1
@@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -159,6 +160,27 @@ export function LongInput({
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
/**
|
||||
* Callback to show tag dropdown when input is empty and focused
|
||||
*/
|
||||
const shouldForceTagDropdown = useCallback(
|
||||
({
|
||||
value,
|
||||
}: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}): { show: boolean } | undefined => {
|
||||
if (isPreview || disabled) return { show: false }
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (value.trim() === '') {
|
||||
return { show: true }
|
||||
}
|
||||
return { show: false }
|
||||
},
|
||||
[isPreview, disabled]
|
||||
)
|
||||
|
||||
// During streaming, use local content; otherwise use the controller value
|
||||
const value = useMemo(() => {
|
||||
if (wandHook.isStreaming) return localContent
|
||||
@@ -294,6 +316,7 @@ export function LongInput({
|
||||
disabled={disabled}
|
||||
isStreaming={wandHook.isStreaming}
|
||||
previewValue={previewValue}
|
||||
shouldForceTagDropdown={shouldForceTagDropdown}
|
||||
>
|
||||
{({ ref, onChange: handleChange, onKeyDown, onDrop, onDragOver, onFocus }) => {
|
||||
const setRefs = (el: HTMLTextAreaElement | null) => {
|
||||
@@ -303,7 +326,7 @@ export function LongInput({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('group relative w-full', wandHook.isStreaming && 'streaming-effect')}
|
||||
className='group relative w-full'
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<Textarea
|
||||
|
||||
+18
-1
@@ -1,3 +1,4 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -111,7 +112,14 @@ function McpInputWithTags({
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.removeAttribute('readOnly')}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.removeAttribute('readOnly')
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
className={cn(!isPassword && 'text-transparent caret-foreground')}
|
||||
/>
|
||||
{!isPassword && (
|
||||
@@ -136,6 +144,7 @@ function McpInputWithTags({
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={inputRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -225,6 +234,13 @@ function McpTextareaWithTags({
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onFocus={() => {
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
@@ -254,6 +270,7 @@ function McpTextareaWithTags({
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+169
-3
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -8,12 +8,30 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 80
|
||||
const MAX_TEXTAREA_HEIGHT_PX = 320
|
||||
|
||||
/** Pattern to match complete message objects in JSON */
|
||||
const COMPLETE_MESSAGE_PATTERN =
|
||||
/"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
|
||||
|
||||
/** Pattern to match incomplete content at end of buffer */
|
||||
const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/
|
||||
|
||||
/** Pattern to match role before content */
|
||||
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*$/
|
||||
|
||||
/**
|
||||
* Unescapes JSON string content
|
||||
*/
|
||||
const unescapeContent = (str: string): string =>
|
||||
str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
|
||||
/**
|
||||
* Interface for individual message in the messages array
|
||||
*/
|
||||
@@ -38,6 +56,8 @@ interface MessagesInputProps {
|
||||
previewValue?: Message[] | null
|
||||
/** Whether the input is disabled */
|
||||
disabled?: boolean
|
||||
/** Ref to expose wand control handlers to parent */
|
||||
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +75,7 @@ export function MessagesInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
wandControlRef,
|
||||
}: MessagesInputProps) {
|
||||
const [messages, setMessages] = useSubBlockValue<Message[]>(blockId, subBlockId, false)
|
||||
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
|
||||
@@ -68,6 +89,142 @@ export function MessagesInput({
|
||||
disabled,
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the current messages as JSON string for wand context
|
||||
*/
|
||||
const getMessagesJson = useCallback((): string => {
|
||||
if (localMessages.length === 0) return ''
|
||||
// Filter out empty messages for cleaner context
|
||||
const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '')
|
||||
if (nonEmptyMessages.length === 0) return ''
|
||||
return JSON.stringify(nonEmptyMessages, null, 2)
|
||||
}, [localMessages])
|
||||
|
||||
/**
|
||||
* Streaming buffer for accumulating JSON content
|
||||
*/
|
||||
const streamBufferRef = useRef<string>('')
|
||||
|
||||
/**
|
||||
* Parses and validates messages from JSON content
|
||||
*/
|
||||
const parseMessages = useCallback((content: string): Message[] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
const validMessages: Message[] = parsed
|
||||
.filter(
|
||||
(m): m is { role: string; content: string } =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
typeof m.role === 'string' &&
|
||||
typeof m.content === 'string'
|
||||
)
|
||||
.map((m) => ({
|
||||
role: (['system', 'user', 'assistant'].includes(m.role)
|
||||
? m.role
|
||||
: 'user') as Message['role'],
|
||||
content: m.content,
|
||||
}))
|
||||
return validMessages.length > 0 ? validMessages : null
|
||||
}
|
||||
} catch {
|
||||
// Parsing failed
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Extracts messages from streaming JSON buffer
|
||||
* Uses simple pattern matching for efficiency
|
||||
*/
|
||||
const extractStreamingMessages = useCallback(
|
||||
(buffer: string): Message[] => {
|
||||
// Try complete JSON parse first
|
||||
const complete = parseMessages(buffer)
|
||||
if (complete) return complete
|
||||
|
||||
const result: Message[] = []
|
||||
|
||||
// Reset regex lastIndex for global pattern
|
||||
COMPLETE_MESSAGE_PATTERN.lastIndex = 0
|
||||
let match
|
||||
while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) {
|
||||
result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) })
|
||||
}
|
||||
|
||||
// Check for incomplete message at end (content still streaming)
|
||||
const lastContentIdx = buffer.lastIndexOf('"content"')
|
||||
if (lastContentIdx !== -1) {
|
||||
const tail = buffer.slice(lastContentIdx)
|
||||
const incomplete = tail.match(INCOMPLETE_CONTENT_PATTERN)
|
||||
if (incomplete) {
|
||||
const head = buffer.slice(0, lastContentIdx)
|
||||
const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN)
|
||||
if (roleMatch) {
|
||||
const content = unescapeContent(incomplete[1])
|
||||
// Only add if not duplicate of last complete message
|
||||
if (result.length === 0 || result[result.length - 1].content !== content) {
|
||||
result.push({ role: roleMatch[1] as Message['role'], content })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[parseMessages]
|
||||
)
|
||||
|
||||
/**
|
||||
* Wand hook for AI-assisted content generation
|
||||
*/
|
||||
const wandHook = useWand({
|
||||
wandConfig: config.wandConfig,
|
||||
currentValue: getMessagesJson(),
|
||||
onStreamStart: () => {
|
||||
streamBufferRef.current = ''
|
||||
setLocalMessages([{ role: 'system', content: '' }])
|
||||
},
|
||||
onStreamChunk: (chunk) => {
|
||||
streamBufferRef.current += chunk
|
||||
const extracted = extractStreamingMessages(streamBufferRef.current)
|
||||
if (extracted.length > 0) {
|
||||
setLocalMessages(extracted)
|
||||
}
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
const validMessages = parseMessages(content)
|
||||
if (validMessages) {
|
||||
setLocalMessages(validMessages)
|
||||
setMessages(validMessages)
|
||||
} else {
|
||||
// Fallback: treat as raw system prompt
|
||||
const trimmed = content.trim()
|
||||
if (trimmed) {
|
||||
const fallback: Message[] = [{ role: 'system', content: trimmed }]
|
||||
setLocalMessages(fallback)
|
||||
setMessages(fallback)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Expose wand control handlers to parent via ref
|
||||
*/
|
||||
useImperativeHandle(
|
||||
wandControlRef,
|
||||
() => ({
|
||||
onWandTrigger: (prompt: string) => {
|
||||
wandHook.generateStream({ prompt })
|
||||
},
|
||||
isWandActive: wandHook.isPromptVisible,
|
||||
isWandStreaming: wandHook.isStreaming,
|
||||
}),
|
||||
[wandHook]
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialize local state from stored or preview value
|
||||
*/
|
||||
@@ -308,7 +465,7 @@ export function MessagesInput({
|
||||
}, [currentMessages, autoResizeTextarea])
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<div className='flex w-full flex-col gap-[10px]'>
|
||||
{currentMessages.map((message, index) => (
|
||||
<div
|
||||
key={`message-${index}`}
|
||||
@@ -364,7 +521,7 @@ export function MessagesInput({
|
||||
type='button'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'-ml-1.5 -my-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
|
||||
(isPreview || disabled) &&
|
||||
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
@@ -372,6 +529,14 @@ export function MessagesInput({
|
||||
aria-label='Select message role'
|
||||
>
|
||||
{formatRole(message.role)}
|
||||
{!isPreview && !disabled && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
|
||||
openPopoverIndex === index && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent minWidth={140} align='start'>
|
||||
@@ -486,6 +651,7 @@ export function MessagesInput({
|
||||
}}
|
||||
onDrop={fieldHandlers.onDrop}
|
||||
onDragOver={fieldHandlers.onDragOver}
|
||||
onFocus={fieldHandlers.onFocus}
|
||||
onScroll={(e) => {
|
||||
const overlay = overlayRefs.current[fieldId]
|
||||
if (overlay) {
|
||||
|
||||
+21
-2
@@ -192,6 +192,24 @@ export function ShortInput({
|
||||
[isApiKeyField, isPreview, disabled, readOnly]
|
||||
)
|
||||
|
||||
const shouldForceTagDropdown = useCallback(
|
||||
({
|
||||
value,
|
||||
}: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}): { show: boolean } | undefined => {
|
||||
if (isPreview || disabled || readOnly) return { show: false }
|
||||
// Show tag dropdown on focus when input is empty (unless it's an API key field)
|
||||
if (!isApiKeyField && value.trim() === '') {
|
||||
return { show: true }
|
||||
}
|
||||
return { show: false }
|
||||
},
|
||||
[isPreview, disabled, readOnly, isApiKeyField]
|
||||
)
|
||||
|
||||
const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : undefined
|
||||
|
||||
const effectiveValue =
|
||||
@@ -316,6 +334,7 @@ export function ShortInput({
|
||||
isStreaming={wandHook.isStreaming}
|
||||
previewValue={previewValue}
|
||||
shouldForceEnvDropdown={shouldForceEnvDropdown}
|
||||
shouldForceTagDropdown={shouldForceTagDropdown}
|
||||
>
|
||||
{({
|
||||
ref,
|
||||
@@ -356,9 +375,9 @@ export function ShortInput({
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={handleChange as (e: React.ChangeEvent<HTMLInputElement>) => void}
|
||||
onFocus={() => {
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus()
|
||||
onFocus(e)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onDrop={onDrop as (e: React.DragEvent<HTMLInputElement>) => void}
|
||||
|
||||
+2
@@ -211,6 +211,7 @@ export function FieldFormat({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncNameOverlayScroll(field.id, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
@@ -464,6 +465,7 @@ export function FieldFormat({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
onScroll={(e) => syncOverlayScroll(field.id, e.currentTarget.scrollLeft)}
|
||||
onPaste={() =>
|
||||
setTimeout(() => {
|
||||
|
||||
+13
-1
@@ -45,6 +45,16 @@ export interface SubBlockInputControllerProps {
|
||||
cursor: number
|
||||
event: 'change' | 'focus' | 'deleteAll'
|
||||
}) => { show: boolean; searchTerm?: string } | undefined
|
||||
/**
|
||||
* Optional callback to force/show the tag dropdown (e.g., empty inputs).
|
||||
* Return { show: true } to override defaults.
|
||||
* Called on 'focus' event.
|
||||
*/
|
||||
shouldForceTagDropdown?: (args: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}) => { show: boolean } | undefined
|
||||
/** Render prop for the actual input element. */
|
||||
children: (args: {
|
||||
ref: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
@@ -54,7 +64,7 @@ export interface SubBlockInputControllerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: () => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onScroll?: (e: React.UIEvent<HTMLTextAreaElement>) => void
|
||||
}) => React.ReactNode
|
||||
}
|
||||
@@ -76,6 +86,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
|
||||
previewValue,
|
||||
workspaceId,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
children,
|
||||
} = props
|
||||
|
||||
@@ -92,6 +103,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
|
||||
previewValue,
|
||||
workspaceId,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
})
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
|
||||
+1
@@ -238,6 +238,7 @@ export function Table({
|
||||
onScroll={handleScroll}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
onFocus={handlers.onFocus}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
|
||||
+16
-9
@@ -1210,7 +1210,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const textAfterCursor = liveValue.slice(liveCursor)
|
||||
|
||||
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
|
||||
if (lastOpenBracket === -1) return
|
||||
|
||||
let processedTag = tag
|
||||
|
||||
@@ -1256,17 +1255,25 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const nextCloseBracket = textAfterCursor.indexOf('>')
|
||||
let remainingTextAfterCursor = textAfterCursor
|
||||
let newValue: string
|
||||
|
||||
if (nextCloseBracket !== -1) {
|
||||
const textBetween = textAfterCursor.slice(0, nextCloseBracket)
|
||||
if (/^[a-zA-Z0-9._]*$/.test(textBetween)) {
|
||||
remainingTextAfterCursor = textAfterCursor.slice(nextCloseBracket + 1)
|
||||
if (lastOpenBracket === -1) {
|
||||
// No '<' found - insert the full tag at cursor position
|
||||
newValue = `${textBeforeCursor}<${processedTag}>${textAfterCursor}`
|
||||
} else {
|
||||
// '<' found - replace from '<' to cursor (and consume trailing '>' if present)
|
||||
const nextCloseBracket = textAfterCursor.indexOf('>')
|
||||
let remainingTextAfterCursor = textAfterCursor
|
||||
|
||||
if (nextCloseBracket !== -1) {
|
||||
const textBetween = textAfterCursor.slice(0, nextCloseBracket)
|
||||
if (/^[a-zA-Z0-9._]*$/.test(textBetween)) {
|
||||
remainingTextAfterCursor = textAfterCursor.slice(nextCloseBracket + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}`
|
||||
newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}`
|
||||
}
|
||||
|
||||
onSelect(newValue)
|
||||
onClose?.()
|
||||
|
||||
+19
@@ -381,6 +381,13 @@ export function VariablesInput({
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && !assignment.value?.trim()) {
|
||||
setActiveFieldId(assignment.id)
|
||||
setCursorPosition(0)
|
||||
setShowTags(true)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
assignment.type === 'object'
|
||||
? '{\n "key": "value"\n}'
|
||||
@@ -434,6 +441,13 @@ export function VariablesInput({
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
onFocus={() => {
|
||||
if (!isPreview && !disabled && !assignment.value?.trim()) {
|
||||
setActiveFieldId(assignment.id)
|
||||
setCursorPosition(0)
|
||||
setShowTags(true)
|
||||
}
|
||||
}}
|
||||
placeholder={`${assignment.type} value`}
|
||||
disabled={isPreview || disabled}
|
||||
autoComplete='off'
|
||||
@@ -475,6 +489,11 @@ export function VariablesInput({
|
||||
inputValue={assignment.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
inputRef={
|
||||
{
|
||||
current: valueInputRefs.current[assignment.id] || null,
|
||||
} as React.RefObject<HTMLTextAreaElement | HTMLInputElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+57
-14
@@ -50,6 +50,16 @@ export interface UseSubBlockInputOptions {
|
||||
cursor: number
|
||||
event: 'change' | 'focus' | 'deleteAll'
|
||||
}) => { show: boolean; searchTerm?: string } | undefined
|
||||
/**
|
||||
* Optional callback to force/show the tag dropdown (e.g., empty inputs).
|
||||
* Return { show: true } to override defaults.
|
||||
* Called on 'focus' event.
|
||||
*/
|
||||
shouldForceTagDropdown?: (args: {
|
||||
value: string
|
||||
cursor: number
|
||||
event: 'focus'
|
||||
}) => { show: boolean } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +99,7 @@ export interface UseSubBlockInputResult {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: () => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onScroll?: (e: React.UIEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
/** Workspace id for env var dropdown scoping. */
|
||||
@@ -114,6 +124,7 @@ export interface UseSubBlockInputResult {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => void
|
||||
}
|
||||
/** Hide dropdowns for a specific field */
|
||||
hideFieldDropdowns: (fieldId: string) => void
|
||||
@@ -153,6 +164,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
previewValue,
|
||||
workspaceId: workspaceIdProp,
|
||||
shouldForceEnvDropdown,
|
||||
shouldForceTagDropdown,
|
||||
} = options
|
||||
|
||||
const params = useParams()
|
||||
@@ -338,22 +350,42 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
[config?.connectionDroppable, valueString, onChange, isPreview, setStoreValue]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (shouldForceEnvDropdown) {
|
||||
// Use a slight delay to ensure the input ref is populated
|
||||
setTimeout(() => {
|
||||
const forced = shouldForceEnvDropdown({
|
||||
value: (inputRef.current as any)?.value ?? valueString,
|
||||
cursor: (inputRef.current as any)?.selectionStart ?? valueString.length,
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
// Get values directly from the event target - no setTimeout needed
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const currentValue = target.value ?? valueString
|
||||
const currentCursor = target.selectionStart ?? currentValue.length
|
||||
|
||||
// Check if tag dropdown should be forced (takes priority as it's more commonly used)
|
||||
if (shouldForceTagDropdown) {
|
||||
const forcedTag = shouldForceTagDropdown({
|
||||
value: currentValue,
|
||||
cursor: currentCursor,
|
||||
event: 'focus',
|
||||
})
|
||||
if (forced?.show) {
|
||||
setShowEnvVars(true)
|
||||
setSearchTerm(forced.searchTerm ?? '')
|
||||
if (forcedTag?.show) {
|
||||
setCursorPosition(currentCursor)
|
||||
setShowTags(true)
|
||||
return // Exit early if tag dropdown is shown
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}, [shouldForceEnvDropdown, valueString])
|
||||
}
|
||||
|
||||
// Check if env var dropdown should be forced
|
||||
if (shouldForceEnvDropdown) {
|
||||
const forcedEnv = shouldForceEnvDropdown({
|
||||
value: currentValue,
|
||||
cursor: currentCursor,
|
||||
event: 'focus',
|
||||
})
|
||||
if (forcedEnv?.show) {
|
||||
setShowEnvVars(true)
|
||||
setSearchTerm(forcedEnv.searchTerm ?? '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldForceEnvDropdown, shouldForceTagDropdown, valueString]
|
||||
)
|
||||
|
||||
const onScroll = useCallback((_: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
// Intentionally empty; consumers may mirror scroll to overlays if needed
|
||||
@@ -469,6 +501,17 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
|
||||
if (config?.connectionDroppable === false) return
|
||||
e.preventDefault()
|
||||
},
|
||||
onFocus: (e: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
// Show tag dropdown on focus when field value is empty
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const currentValue = target.value ?? fieldValue
|
||||
if (!isDisabled && !isStreaming && currentValue.trim() === '') {
|
||||
updateFieldState(fieldId, {
|
||||
showTags: true,
|
||||
cursorPosition: target.selectionStart ?? 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
[isDisabled, isStreaming, config?.connectionDroppable, updateFieldState]
|
||||
|
||||
+73
-76
@@ -1,7 +1,6 @@
|
||||
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Wand2 } from 'lucide-react'
|
||||
import { Label, Tooltip } from '@/components/emcn/components'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, ArrowUp } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import {
|
||||
@@ -154,22 +153,23 @@ const getPreviewValue = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the label with optional validation, description tooltips, and inline wand control.
|
||||
* Renders the label with optional validation and description tooltips.
|
||||
*
|
||||
* @remarks
|
||||
* Handles JSON validation indicators for code blocks, required field markers,
|
||||
* and AI generation (wand) input interface.
|
||||
* Handles JSON validation indicators for code blocks and required field markers.
|
||||
* Includes inline AI generate button when wand is enabled.
|
||||
*
|
||||
* @param config - The sub-block configuration defining the label content
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param wandState - State and handlers for the AI wand feature
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - Optional state and handlers for the AI wand feature
|
||||
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
||||
*/
|
||||
const renderLabel = (
|
||||
config: SubBlockConfig,
|
||||
isValidJson: boolean,
|
||||
wandState: {
|
||||
subBlockValues?: Record<string, any>,
|
||||
wandState?: {
|
||||
isSearchActive: boolean
|
||||
searchQuery: string
|
||||
isWandEnabled: boolean
|
||||
@@ -182,31 +182,19 @@ const renderLabel = (
|
||||
onSearchSubmit: () => void
|
||||
onSearchCancel: () => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
},
|
||||
subBlockValues?: Record<string, any>
|
||||
}
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
|
||||
const {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming,
|
||||
disabled,
|
||||
onSearchClick,
|
||||
onSearchBlur,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchCancel,
|
||||
searchInputRef,
|
||||
} = wandState
|
||||
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
|
||||
|
||||
return (
|
||||
<Label className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label
|
||||
className='flex items-center justify-between gap-[6px] pl-[2px]'
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
@@ -226,42 +214,55 @@ const renderLabel = (
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wand inline prompt */}
|
||||
{isWandEnabled && !isPreview && !disabled && (
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end pr-[4px]'>
|
||||
{!isSearchActive ? (
|
||||
{showWand && (
|
||||
<>
|
||||
{!wandState.isSearchActive ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[12px] w-[12px] flex-shrink-0 p-0 hover:bg-transparent'
|
||||
aria-label='Generate with AI'
|
||||
onClick={onSearchClick}
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={wandState.onSearchClick}
|
||||
>
|
||||
<Wand2 className='!h-[12px] !w-[12px] bg-transparent text-[var(--text-secondary)]' />
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={isStreaming ? 'Generating...' : searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
|
||||
onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
className={cn(
|
||||
'h-[12px] w-full min-w-[100px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none',
|
||||
isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Describe...'
|
||||
/>
|
||||
<div className='-my-1 flex items-center gap-[4px]'>
|
||||
<Input
|
||||
ref={wandState.searchInputRef}
|
||||
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
|
||||
onChange={(e) => wandState.onSearchChange(e.target.value)}
|
||||
onBlur={wandState.onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && wandState.searchQuery.trim() && !wandState.isStreaming) {
|
||||
wandState.onSearchSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
wandState.onSearchCancel()
|
||||
}
|
||||
}}
|
||||
disabled={wandState.isStreaming}
|
||||
className={cn(
|
||||
'h-5 max-w-[200px] flex-1 text-[11px]',
|
||||
wandState.isStreaming && 'text-muted-foreground'
|
||||
)}
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
wandState.onSearchSubmit()
|
||||
}}
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
)
|
||||
@@ -875,6 +876,7 @@ function SubBlockComponent({
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue as any}
|
||||
disabled={isDisabled}
|
||||
wandControlRef={wandControlRef}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -885,25 +887,20 @@ function SubBlockComponent({
|
||||
|
||||
return (
|
||||
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
{
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
subBlockValues
|
||||
)}
|
||||
{renderLabel(config, isValidJson, subBlockValues, {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
disabled: isDisabled,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
})}
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -342,7 +342,7 @@ export function Editor() {
|
||||
ref={subBlocksRef}
|
||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[8px] pb-[8px]'>
|
||||
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px]'>
|
||||
{subBlocks.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||
This block has no subblocks
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workfl
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,8 @@ interface UseBlockVisualProps {
|
||||
|
||||
/**
|
||||
* Provides visual state and interaction handlers for workflow blocks.
|
||||
* Computes ring styling based on execution, diff, deletion, and run path states.
|
||||
* Computes ring styling based on editor open state, execution, diff, deletion, and run path states.
|
||||
* Ring is shown only when the editor panel is open for this block, not during selection/dragging.
|
||||
* In preview mode, uses isPreviewSelected for selection highlighting.
|
||||
*
|
||||
* @param props - The hook properties
|
||||
@@ -36,13 +37,15 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
|
||||
const {
|
||||
isEnabled,
|
||||
isActive: blockIsActive,
|
||||
isActive: isExecuting,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
// In preview mode, use isPreviewSelected for selection state
|
||||
const isActive = isPreview ? isPreviewSelected : blockIsActive
|
||||
// Check if the editor panel is open for this block
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const activeTab = usePanelStore((state) => state.activeTab)
|
||||
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
|
||||
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
@@ -58,14 +61,24 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
const { hasRing, ringClassName: ringStyles } = useMemo(
|
||||
() =>
|
||||
getBlockRingStyles({
|
||||
isActive,
|
||||
isExecuting: isPreview ? false : isExecuting,
|
||||
isEditorOpen: isPreview ? isPreviewSelected : isEditorOpen,
|
||||
isPending: isPreview ? false : isPending,
|
||||
isDeletedBlock: isPreview ? false : isDeletedBlock,
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection: isPreview && isPreviewSelected,
|
||||
}),
|
||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||
[
|
||||
isExecuting,
|
||||
isEditorOpen,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreview,
|
||||
isPreviewSelected,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,10 @@ export type BlockDiffStatus = 'new' | 'edited' | null | undefined
|
||||
export type BlockRunPathStatus = 'success' | 'error' | undefined
|
||||
|
||||
export interface BlockRingOptions {
|
||||
isActive: boolean
|
||||
/** Whether the block is executing (shows green pulsing ring) */
|
||||
isExecuting: boolean
|
||||
/** Whether the editor panel is open for this block (shows blue ring) */
|
||||
isEditorOpen: boolean
|
||||
isPending: boolean
|
||||
isDeletedBlock: boolean
|
||||
diffStatus: BlockDiffStatus
|
||||
@@ -15,17 +18,25 @@ export interface BlockRingOptions {
|
||||
|
||||
/**
|
||||
* Derives visual ring visibility and class names for workflow blocks
|
||||
* based on execution, diff, deletion, and run-path states.
|
||||
* based on editor open state, execution, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||
options
|
||||
const {
|
||||
isExecuting,
|
||||
isEditorOpen,
|
||||
isPending,
|
||||
isDeletedBlock,
|
||||
diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection,
|
||||
} = options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isExecuting ||
|
||||
isEditorOpen ||
|
||||
isPending ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
@@ -33,37 +44,41 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!!runPathStatus
|
||||
|
||||
const ringClassName = cn(
|
||||
// Preview selection: static blue ring (standard thickness, no animation)
|
||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Executing block: pulsing success ring with prominent thickness
|
||||
isActive &&
|
||||
!isPreviewSelection &&
|
||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Executing block: pulsing success ring with prominent thickness (highest priority)
|
||||
isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Editor open or preview selection: static blue ring
|
||||
!isExecuting &&
|
||||
(isEditorOpen || isPreviewSelection) &&
|
||||
'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
!isExecuting && !isEditorOpen && !isPreviewSelection && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
!isExecuting && !isEditorOpen && isPending && 'ring-[var(--warning)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
!isExecuting && !isEditorOpen && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary-2)]',
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--border-success)]',
|
||||
!isActive &&
|
||||
!isExecuting &&
|
||||
!isEditorOpen &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
|
||||
+5
-4
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
@@ -368,17 +369,17 @@ export function ContextMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
<Button
|
||||
variant='tertiary'
|
||||
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'
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||
>
|
||||
<Check className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverFolder>
|
||||
|
||||
@@ -83,9 +83,44 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'messages',
|
||||
// title: 'Messages',
|
||||
title: 'Messages',
|
||||
type: 'messages-input',
|
||||
placeholder: 'Enter messages...',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert at creating professional, comprehensive LLM agent configurations. Generate or modify a JSON array of messages based on the user's request.
|
||||
|
||||
Current messages: {context}
|
||||
|
||||
RULES:
|
||||
1. Generate ONLY a valid JSON array - no markdown, no explanations
|
||||
2. Each message object must have "role" (system/user/assistant) and "content" (string)
|
||||
3. You can generate any number of messages as needed
|
||||
4. Content can be as long as necessary - don't truncate
|
||||
5. If editing existing messages, preserve structure unless asked to change it
|
||||
6. For new agents, create DETAILED, PROFESSIONAL system prompts that include:
|
||||
- A clear role definition starting with "You are..."
|
||||
- Specific methodology or approach guidelines
|
||||
- Structured output format requirements
|
||||
- Critical thinking or quality guidelines
|
||||
- How to handle edge cases and uncertainty
|
||||
|
||||
EXAMPLES:
|
||||
|
||||
Research agent:
|
||||
[{"role": "system", "content": "You are a Research Specialist who synthesizes information into actionable insights.\\n\\n## Approach\\n- Identify key concepts, historical context, and future implications\\n- Distinguish between: established facts, emerging research, contested positions, and your inferences\\n- Attribute claims clearly (e.g., \\"Research suggests...\\", \\"Industry consensus holds...\\")\\n\\n## Response Structure\\n1. **Executive Summary**: 2-3 sentences on key findings\\n2. **Key Findings**: Numbered insights with supporting context\\n3. **Analysis**: Organized by themes—background, current state, perspectives, implications\\n4. **Limitations**: What's uncertain or incomplete\\n5. **Recommendations**: Actionable next steps\\n\\n## Standards\\n- Present multiple viewpoints; avoid single narratives as definitive\\n- Make assumptions explicit; question conventional wisdom\\n- Assess evidence strength; distinguish correlation from causation\\n- Use hedging language (\\"likely\\", \\"suggests\\") over false certainty\\n- Acknowledge knowledge limits directly"}, {"role": "user", "content": ""}]
|
||||
|
||||
Code reviewer:
|
||||
[{"role": "system", "content": "You are a Senior Code Reviewer with expertise in software architecture, security, and best practices. Your role is to provide thorough, constructive code reviews that improve code quality and help developers grow.\\n\\n## Review Methodology\\n\\n1. **Security First**: Check for vulnerabilities including injection attacks, authentication flaws, data exposure, and insecure dependencies.\\n\\n2. **Code Quality**: Evaluate readability, maintainability, adherence to DRY/SOLID principles, and appropriate abstraction levels.\\n\\n3. **Performance**: Identify potential bottlenecks, unnecessary computations, memory leaks, and optimization opportunities.\\n\\n4. **Testing**: Assess test coverage, edge case handling, and testability of the code structure.\\n\\n## Output Format\\n\\n### Summary\\nBrief overview of the code's purpose and overall assessment.\\n\\n### Critical Issues\\nSecurity vulnerabilities or bugs that must be fixed before merging.\\n\\n### Improvements\\nSuggested enhancements with clear explanations of why and how.\\n\\n### Positive Aspects\\nHighlight well-written code to reinforce good practices.\\n\\nBe specific with line references. Provide code examples for suggested changes. Balance critique with encouragement."}, {"role": "user", "content": "<start.input>"}]
|
||||
|
||||
Writing assistant:
|
||||
[{"role": "system", "content": "You are a skilled Writing Editor and Coach. Your role is to help users improve their writing through constructive feedback, editing suggestions, and guidance on style, clarity, and structure.\\n\\n## Editing Approach\\n\\n1. **Clarity**: Ensure ideas are expressed clearly and concisely. Eliminate jargon unless appropriate for the audience.\\n\\n2. **Structure**: Evaluate logical flow, paragraph organization, and transitions between ideas.\\n\\n3. **Voice & Tone**: Maintain consistency and appropriateness for the intended audience and purpose.\\n\\n4. **Grammar & Style**: Correct errors while respecting the author's voice.\\n\\n## Output Format\\n\\n### Overall Impression\\nBrief assessment of the piece's strengths and areas for improvement.\\n\\n### Structural Feedback\\nComments on organization, flow, and logical progression.\\n\\n### Line-Level Edits\\nSpecific suggestions with explanations, not just corrections.\\n\\n### Revised Version\\nWhen appropriate, provide an edited version demonstrating improvements.\\n\\nBe encouraging while honest. Explain the reasoning behind suggestions to help the writer improve."}, {"role": "user", "content": "<start.input>"}]
|
||||
|
||||
Return ONLY the JSON array.`,
|
||||
placeholder: 'Describe what you want to create or change...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
id: 'data',
|
||||
title: 'Response Data',
|
||||
type: 'code',
|
||||
placeholder: '{\n "message": "Hello world",\n "userId": "<variable.userId>"\n}',
|
||||
placeholder: '{\n "message": "Hello world"\n}',
|
||||
language: 'json',
|
||||
condition: { field: 'dataMode', value: 'json' },
|
||||
description:
|
||||
|
||||
@@ -56,8 +56,6 @@ interface CodeContainerProps {
|
||||
className?: string
|
||||
/** Inline styles for the container */
|
||||
style?: React.CSSProperties
|
||||
/** Whether editor is in streaming/AI generation state */
|
||||
isStreaming?: boolean
|
||||
/** Drag and drop handler */
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
/** Drop handler */
|
||||
@@ -77,14 +75,7 @@ interface CodeContainerProps {
|
||||
* </Code.Container>
|
||||
* ```
|
||||
*/
|
||||
function Container({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
isStreaming = false,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: CodeContainerProps) {
|
||||
function Container({ children, className, style, onDragOver, onDrop }: CodeContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -94,8 +85,6 @@ function Container({
|
||||
'dark:bg-[#1F1F1F]',
|
||||
// Overflow handling for long content
|
||||
'overflow-x-auto overflow-y-auto',
|
||||
// Streaming state
|
||||
isStreaming && 'streaming-effect',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const SIDEBAR_WIDTH = {
|
||||
|
||||
/** Right panel width constraints */
|
||||
export const PANEL_WIDTH = {
|
||||
DEFAULT: 290,
|
||||
DEFAULT: 320,
|
||||
MIN: 290,
|
||||
/** Maximum is 40% of viewport, enforced dynamically */
|
||||
MAX_PERCENTAGE: 0.4,
|
||||
|
||||
@@ -126,14 +126,6 @@ export default {
|
||||
strokeDashoffset: '-24',
|
||||
},
|
||||
},
|
||||
'code-shimmer': {
|
||||
'0%': {
|
||||
transform: 'translateX(-100%)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
},
|
||||
'placeholder-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '0.5',
|
||||
@@ -156,7 +148,6 @@ export default {
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
'dash-animation': 'dash-animation 1.5s linear infinite',
|
||||
'code-shimmer': 'code-shimmer 1.5s infinite',
|
||||
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
|
||||
'ring-pulse': 'ring-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user