mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(ui/ux): connections and styling
This commit is contained in:
@@ -84,7 +84,6 @@ export function ControlBar() {
|
||||
const [runCount, setRunCount] = useState(1)
|
||||
const [completedRuns, setCompletedRuns] = useState(0)
|
||||
const [isMultiRunning, setIsMultiRunning] = useState(false)
|
||||
const [showRunProgress, setShowRunProgress] = useState(false)
|
||||
|
||||
// Get notifications for current workflow
|
||||
const workflowNotifications = activeWorkflowId
|
||||
@@ -305,7 +304,6 @@ export function ControlBar() {
|
||||
// Reset state for a new batch of runs
|
||||
setCompletedRuns(0)
|
||||
setIsMultiRunning(true)
|
||||
setShowRunProgress(runCount > 1)
|
||||
|
||||
try {
|
||||
// Run the workflow multiple times sequentially
|
||||
@@ -331,10 +329,6 @@ export function ControlBar() {
|
||||
addNotification('error', 'Failed to complete all workflow runs', activeWorkflowId)
|
||||
} finally {
|
||||
setIsMultiRunning(false)
|
||||
// Keep progress visible for a moment after completion
|
||||
if (runCount > 1) {
|
||||
setTimeout(() => setShowRunProgress(false), 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,15 +577,6 @@ export function ControlBar() {
|
||||
*/
|
||||
const renderRunButton = () => (
|
||||
<div className="flex items-center">
|
||||
{showRunProgress && (
|
||||
<div className="mr-3 w-28">
|
||||
<Progress value={(completedRuns / runCount) * 100} className="h-2 bg-muted" />
|
||||
<p className="text-xs text-muted-foreground mt-1 text-center">
|
||||
{completedRuns}/{runCount} runs
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex ml-1">
|
||||
{/* Main Run Button */}
|
||||
<Button
|
||||
@@ -603,12 +588,12 @@ export function ControlBar() {
|
||||
(isExecuting || isMultiRunning) &&
|
||||
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#7F2FFF] disabled:hover:shadow-none',
|
||||
'rounded-r-none border-r border-r-[#6420cc] py-2 px-4 h-10'
|
||||
'rounded-r-none border-r border-r-[#6420cc] py-1.5 px-3 h-10 text-sm rounded-l-sm'
|
||||
)}
|
||||
onClick={handleMultipleRuns}
|
||||
disabled={isExecuting || isMultiRunning}
|
||||
>
|
||||
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
|
||||
<Play className={cn('!h-3 !w-3', 'fill-current stroke-current')} />
|
||||
{isMultiRunning
|
||||
? `Running ${completedRuns}/${runCount}`
|
||||
: isExecuting
|
||||
@@ -623,26 +608,26 @@ export function ControlBar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-2 font-medium',
|
||||
'px-1.5 font-medium',
|
||||
'bg-[#7F2FFF] hover:bg-[#7028E6]',
|
||||
'shadow-[0_0_0_0_#7F2FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
(isExecuting || isMultiRunning) &&
|
||||
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
|
||||
'disabled:opacity-50 disabled:hover:bg-[#7F2FFF] disabled:hover:shadow-none',
|
||||
'rounded-l-none h-10'
|
||||
'rounded-l-none h-10 rounded-r-sm'
|
||||
)}
|
||||
disabled={isExecuting || isMultiRunning}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-20">
|
||||
<DropdownMenuContent align="end" className="min-w-20">
|
||||
{RUN_COUNT_OPTIONS.map((count) => (
|
||||
<DropdownMenuItem
|
||||
key={count}
|
||||
onClick={() => setRunCount(count)}
|
||||
className={cn('justify-center', runCount === count && 'bg-muted')}
|
||||
className={cn('justify-center cursor-pointer', runCount === count && 'bg-muted')}
|
||||
>
|
||||
{count}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) {
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
className="group flex items-center gap-3 rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-accent/50 cursor-pointer active:cursor-grabbing"
|
||||
className="group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors hover:bg-accent/50 cursor-pointer active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
|
||||
@@ -46,14 +46,14 @@ export function Toolbar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-14 top-16 z-10 h-[calc(100vh-4rem)] w-64 border-r bg-background sm:block">
|
||||
<div className="fixed left-14 top-16 z-10 h-[calc(100vh-4rem)] w-60 border-r bg-background sm:block">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-4 pb-1 sticky top-0 bg-background z-20">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[50%] h-4 w-4 -translate-y-[50%] text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
className="pl-9"
|
||||
className="pl-9 rounded-md"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
|
||||
+16
-2
@@ -69,9 +69,23 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
|
||||
}))
|
||||
}
|
||||
|
||||
// Group connections by their ID for better organization
|
||||
const connectionsByBlock = incomingConnections.reduce(
|
||||
(acc, connection) => {
|
||||
acc[connection.id] = connection
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ConnectedBlock>
|
||||
)
|
||||
|
||||
// Sort connections by name to make it easier to find blocks
|
||||
const sortedConnections = Object.values(connectionsByBlock).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="absolute -left-[180px] top-0 space-y-2 flex flex-col items-end w-[160px]">
|
||||
{incomingConnections.map((connection) => (
|
||||
<div className="absolute -left-[240px] top-0 space-y-2 flex flex-col items-end w-[220px] max-h-[400px] overflow-y-auto">
|
||||
{sortedConnections.map((connection) => (
|
||||
<div key={connection.id} className="space-y-2">
|
||||
{Array.isArray(connection.outputType) ? (
|
||||
// Handle array of field names
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ export function Dropdown({ options, defaultValue, blockId, subBlockId }: Dropdow
|
||||
<SelectTrigger className="text-left">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-48">
|
||||
{options.map((option) => (
|
||||
<SelectItem key={getOptionValue(option)} value={getOptionValue(option)}>
|
||||
{getOptionLabel(option)}
|
||||
|
||||
@@ -53,6 +53,63 @@ function extractFieldsFromSchema(schema: any): Field[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all blocks along paths leading to the target block
|
||||
* This is a reverse traversal from the target node to find all ancestors
|
||||
* along connected paths
|
||||
* @param edges - List of all edges in the graph
|
||||
* @param targetNodeId - ID of the target block we're finding connections for
|
||||
* @returns Array of unique ancestor node IDs
|
||||
*/
|
||||
function findAllPathNodes(edges: any[], targetNodeId: string): string[] {
|
||||
// We'll use a reverse topological sort approach by tracking "distance" from target
|
||||
const nodeDistances = new Map<string, number>()
|
||||
const visited = new Set<string>()
|
||||
const queue: [string, number][] = [[targetNodeId, 0]] // [nodeId, distance]
|
||||
const pathNodes = new Set<string>()
|
||||
|
||||
// Build a reverse adjacency list for faster traversal
|
||||
const reverseAdjList: Record<string, string[]> = {}
|
||||
for (const edge of edges) {
|
||||
if (!reverseAdjList[edge.target]) {
|
||||
reverseAdjList[edge.target] = []
|
||||
}
|
||||
reverseAdjList[edge.target].push(edge.source)
|
||||
}
|
||||
|
||||
// BFS to find all ancestors and their shortest distance from target
|
||||
while (queue.length > 0) {
|
||||
const [currentNodeId, distance] = queue.shift()!
|
||||
|
||||
if (visited.has(currentNodeId)) {
|
||||
// If we've seen this node before, update its distance if this path is shorter
|
||||
const currentDistance = nodeDistances.get(currentNodeId) || Infinity
|
||||
if (distance < currentDistance) {
|
||||
nodeDistances.set(currentNodeId, distance)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
visited.add(currentNodeId)
|
||||
nodeDistances.set(currentNodeId, distance)
|
||||
|
||||
// Don't add the target node itself to the results
|
||||
if (currentNodeId !== targetNodeId) {
|
||||
pathNodes.add(currentNodeId)
|
||||
}
|
||||
|
||||
// Get all incoming edges from the reverse adjacency list
|
||||
const incomingNodeIds = reverseAdjList[currentNodeId] || []
|
||||
|
||||
// Add all source nodes to the queue with incremented distance
|
||||
for (const sourceId of incomingNodeIds) {
|
||||
queue.push([sourceId, distance + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(pathNodes)
|
||||
}
|
||||
|
||||
export function useBlockConnections(blockId: string) {
|
||||
const { edges, blocks } = useWorkflowStore(
|
||||
(state) => ({
|
||||
@@ -62,7 +119,51 @@ export function useBlockConnections(blockId: string) {
|
||||
shallow
|
||||
)
|
||||
|
||||
const incomingConnections = edges
|
||||
// Find all blocks along paths leading to this block
|
||||
const allPathNodeIds = findAllPathNodes(edges, blockId)
|
||||
|
||||
// Map each path node to a ConnectedBlock structure
|
||||
const allPathConnections = allPathNodeIds.map(sourceId => {
|
||||
const sourceBlock = blocks[sourceId]
|
||||
if (!sourceBlock) return null
|
||||
|
||||
// Get the response format from the subblock store
|
||||
const responseFormatValue = useSubBlockStore
|
||||
.getState()
|
||||
.getValue(sourceId, 'responseFormat')
|
||||
|
||||
let responseFormat
|
||||
|
||||
try {
|
||||
responseFormat =
|
||||
typeof responseFormatValue === 'string' && responseFormatValue
|
||||
? JSON.parse(responseFormatValue)
|
||||
: responseFormatValue // Handle case where it's already an object
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse response format:', { e })
|
||||
responseFormat = undefined
|
||||
}
|
||||
|
||||
// Get the default output type from the block's outputs
|
||||
const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({
|
||||
name: key,
|
||||
type: 'string',
|
||||
}))
|
||||
|
||||
// Extract fields from the response format using our helper function
|
||||
const outputFields = responseFormat ? extractFieldsFromSchema(responseFormat) : defaultOutputs
|
||||
|
||||
return {
|
||||
id: sourceBlock.id,
|
||||
type: sourceBlock.type,
|
||||
outputType: outputFields.map((field: Field) => field.name),
|
||||
name: sourceBlock.name,
|
||||
responseFormat,
|
||||
}
|
||||
}).filter(Boolean) as ConnectedBlock[]
|
||||
|
||||
// Keep the original incoming connections for compatibility
|
||||
const directIncomingConnections = edges
|
||||
.filter((edge) => edge.target === blockId)
|
||||
.map((edge) => {
|
||||
const sourceBlock = blocks[edge.source]
|
||||
@@ -103,7 +204,8 @@ export function useBlockConnections(blockId: string) {
|
||||
})
|
||||
|
||||
return {
|
||||
incomingConnections,
|
||||
hasIncomingConnections: incomingConnections.length > 0,
|
||||
incomingConnections: allPathConnections,
|
||||
directIncomingConnections,
|
||||
hasIncomingConnections: allPathConnections.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function WorkflowLayout({ children }: { children: React.ReactNode
|
||||
<>
|
||||
<ControlBar />
|
||||
<Toolbar />
|
||||
<Chat />
|
||||
{/* <Chat /> */}
|
||||
<Console />
|
||||
<main className="grid items-start gap-2 bg-muted/40 h-[calc(100vh-4rem)]">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
|
||||
@@ -34,7 +34,10 @@ export default function Level() {
|
||||
{levels.map((levelItem) => (
|
||||
<DropdownMenuItem
|
||||
key={levelItem.value}
|
||||
onClick={() => setLevel(levelItem.value)}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setLevel(levelItem.value)
|
||||
}}
|
||||
className="flex items-center justify-between p-2 cursor-pointer text-sm"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -25,7 +25,10 @@ export default function Timeline() {
|
||||
{timeRanges.map((range) => (
|
||||
<DropdownMenuItem
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setTimeRange(range)
|
||||
}}
|
||||
className="flex items-center justify-between p-2 cursor-pointer text-sm"
|
||||
>
|
||||
<span>{range}</span>
|
||||
|
||||
@@ -59,10 +59,13 @@ export default function Workflow() {
|
||||
<ChevronDown className="h-4 w-4 ml-2 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[220px] max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuContent align="start" className="w-[180px] max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem
|
||||
key="all"
|
||||
onClick={clearSelections}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
clearSelections()
|
||||
}}
|
||||
className="flex items-center justify-between p-2 cursor-pointer text-sm"
|
||||
>
|
||||
<span>All workflows</span>
|
||||
@@ -74,7 +77,10 @@ export default function Workflow() {
|
||||
{workflows.map((workflow) => (
|
||||
<DropdownMenuItem
|
||||
key={workflow.id}
|
||||
onClick={() => toggleWorkflowId(workflow.id)}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
toggleWorkflowId(workflow.id)
|
||||
}}
|
||||
className="flex items-center justify-between p-2 cursor-pointer text-sm"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -10,7 +10,7 @@ import Workflow from './components/workflow'
|
||||
*/
|
||||
export function Filters() {
|
||||
return (
|
||||
<div className="p-4 w-64 border-r h-full overflow-auto">
|
||||
<div className="p-4 w-60 border-r h-full overflow-auto">
|
||||
<h2 className="text-sm font-medium mb-4 pl-2">Filters</h2>
|
||||
|
||||
{/* Timeline Filter */}
|
||||
|
||||
@@ -37,19 +37,19 @@ export function Toolbar({ scrollToSection, activeSection }: ToolbarProps) {
|
||||
// Set categories including special sections
|
||||
useEffect(() => {
|
||||
// Start with special sections like 'popular' and 'recent'
|
||||
const specialSections = ['popular']
|
||||
const specialSections = ['popular', 'recent']
|
||||
|
||||
// Add categories from centralized definitions
|
||||
const categoryValues = CATEGORIES.map((cat) => cat.value)
|
||||
|
||||
// Add 'recent' as the last item
|
||||
const allCategories = [...specialSections, ...categoryValues, 'recent']
|
||||
// Put special sections first, then regular categories
|
||||
const allCategories = [...specialSections, ...categoryValues]
|
||||
|
||||
setCategories(allCategories)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-4 w-64 border-r h-full overflow-auto">
|
||||
<div className="p-4 w-60 border-r h-full overflow-auto">
|
||||
<h2 className="text-sm font-medium mb-4 pl-2">Categories</h2>
|
||||
<nav className="space-y-1">
|
||||
{categories.map((category) => (
|
||||
|
||||
@@ -16,7 +16,7 @@ type GitHubResponse =
|
||||
export const GitHubBlock: BlockConfig<GitHubResponse> = {
|
||||
type: 'github',
|
||||
name: 'GitHub',
|
||||
description: 'Interact with GitHub repositories and PRs',
|
||||
description: 'Interact with GitHub',
|
||||
longDescription:
|
||||
'Access GitHub repositories, pull requests, and comments through the GitHub API. Automate code reviews, PR management, and repository interactions within your workflow.',
|
||||
category: 'tools',
|
||||
|
||||
@@ -25,7 +25,7 @@ const SelectTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-4 w-4 opacity-50 transition-transform duration-200 ease-in-out" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
@@ -37,10 +37,13 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 bg-popover sticky top-0 z-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<ChevronUp className="h-4 w-4 opacity-70 hover:opacity-100 transition-opacity" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
@@ -51,10 +54,13 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 bg-popover sticky bottom-0 z-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-4 w-4 opacity-70 hover:opacity-100 transition-opacity" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
@@ -78,7 +84,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
'p-1 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user