Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface EntryBlockTileProps {
export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) {
return (
<BlockTile
as='span'
blockType={blockType}
icon={getBlockIcon(blockType) ?? undefined}
bgColor={getBlockColor(blockType)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile'
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
export { OutputPanel, type OutputPanelProps } from './output-panel'
export { StatusDisplay, type StatusDisplayProps } from './status-display'
export { TerminalRowButton, type TerminalRowButtonProps } from './terminal-row-button'
export { ToggleButton, type ToggleButtonProps } from './toggle-button'
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
'use client'

import { memo } from 'react'
import { Badge } from '@sim/emcn'
import { badgeVariants, cn } from '@sim/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'

/**
* Running badge component - displays a consistent "Running" indicator
* Inline running badge for valid content inside a native terminal row button.
*/
const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLE}>
Running
</Badge>
)
return <span className={cn(badgeVariants({ variant: 'green' }), BADGE_STYLE)}>Running</span>
})

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { BlockTileView } from '@sim/workflow-renderer'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
StatusDisplay,
TerminalRowButton,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'

let root: Root
let host: HTMLDivElement

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
})

describe('TerminalRowButton', () => {
it('renders selected disclosure semantics and handles one native click locally', () => {
const onClick = vi.fn()
const onParentClick = vi.fn()

act(() => {
root.render(
<div onClick={onParentClick}>
<TerminalRowButton selected aria-expanded data-entry-id='entry-1' onClick={onClick}>
<span>Workflow result</span>
</TerminalRowButton>
</div>
)
})

const button = host.querySelector('button')!
expect(button.type).toBe('button')
expect(button.getAttribute('aria-expanded')).toBe('true')
expect(button.getAttribute('aria-current')).toBe('true')
expect(button.getAttribute('data-entry-id')).toBe('entry-1')
expect(button.className).toBe(ROW_STYLES.rowSelected)
act(() => button.focus())
expect(document.activeElement).toBe(button)
act(() => button.click())
expect(onClick).toHaveBeenCalledTimes(1)
expect(onParentClick).not.toHaveBeenCalled()
})

it('keeps the base chip row when selection and expansion are absent', () => {
act(() => {
root.render(<TerminalRowButton>Block output</TerminalRowButton>)
})
const button = host.querySelector('button')!
expect(button.className).toBe(ROW_STYLES.row)
expect(button.hasAttribute('aria-expanded')).toBe(false)
expect(button.hasAttribute('aria-current')).toBe(false)
expect(button.textContent).toBe('Block output')
})

it('does not mark an unselected output row as current', () => {
act(() => {
root.render(<TerminalRowButton selected={false}>Other output</TerminalRowButton>)
})
expect(host.querySelector('button')?.hasAttribute('aria-current')).toBe(false)
})

it('keeps the running status inline inside a native button', () => {
const html = renderToStaticMarkup(
<TerminalRowButton>
<StatusDisplay isRunning isCanceled={false} formattedDuration='-' />
</TerminalRowButton>
)
expect(html).toMatch(/^<button\b/)
expect(html).toContain('>Running</span>')
expect(html).not.toContain('<div')
})

it('keeps the complete tile, label, chevron, and status as valid button contents', () => {
const Icon = ({ className }: { className?: string }) => <svg className={className} />
const html = renderToStaticMarkup(
<TerminalRowButton aria-expanded={false}>
<span className={ROW_STYLES.content}>
<BlockTileView as='span' blockType='agent' icon={Icon} bgColor='#33C482' useAccent />
<span className={ROW_STYLES.label}>Agent</span>
<svg aria-hidden='true' />
</span>
<span className={ROW_STYLES.status}>
<StatusDisplay isRunning isCanceled={false} formattedDuration='-' />
</span>
</TerminalRowButton>
)
const document = new DOMParser().parseFromString(html, 'text/html')
const button = document.querySelector('button')
expect(button?.getAttribute('aria-expanded')).toBe('false')
expect(button?.textContent).toContain('Agent')
expect(button?.textContent).toContain('Running')
expect(button?.querySelector('[data-workflow-type-icon="agent"]')).not.toBeNull()
expect(button?.querySelectorAll('button, a, div')).toHaveLength(0)
expect(document.body.children).toHaveLength(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '@sim/emcn'
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'

export interface TerminalRowButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-current'> {
/** Use the selected chip surface for the active output row. */
selected?: boolean
}

/** Native terminal row action with the established EMCN chip surface. */
export function TerminalRowButton({
selected,
className,
onClick,
type,
...props
}: TerminalRowButtonProps) {
return (
<button
type={type ?? 'button'}
className={cn(selected ? ROW_STYLES.rowSelected : ROW_STYLES.row, className)}
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
aria-current={selected ? 'true' : undefined}
onClick={(event) => {
event.stopPropagation()
onClick?.(event)
}}
{...props}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ChevronDown,
cn,
disclosureChevronClass,
handleKeyboardActivation,
Popover,
PopoverContent,
PopoverItem,
Expand All @@ -27,6 +26,7 @@ import {
LogRowContextMenu,
OutputPanel,
StatusDisplay,
TerminalRowButton,
ToggleButton,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
import {
Expand Down Expand Up @@ -110,33 +110,25 @@ const BlockRow = memo(function BlockRow({
const isCanceled = Boolean(entry.isCanceled)

return (
<div
<TerminalRowButton
data-entry-id={entry.id}
role='button'
tabIndex={0}
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onSelect(entry)
}}
onKeyDown={(event) =>
handleKeyboardActivation(event, () => onSelect(entry), { stopPropagation: true })
}
selected={isSelected}
onClick={() => onSelect(entry)}
>
<div className={ROW_STYLES.content}>
<span className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{entry.blockName}
</span>
</div>
</span>
<span className={cn(ROW_STYLES.status, !isRunning && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
</TerminalRowButton>
)
})

Expand Down Expand Up @@ -175,32 +167,23 @@ const IterationNodeRow = memo(function IterationNodeRow({
return (
<div className='flex min-w-0 flex-col'>
{/* Iteration Header */}
<div
role='button'
tabIndex={0}
className={ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })}
>
<div className={ROW_STYLES.content}>
<TerminalRowButton aria-expanded={hasChildren ? isExpanded : undefined} onClick={onToggle}>
<span className={ROW_STYLES.content}>
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{iterationLabel}
</span>
{hasChildren && (
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
</span>
<span className={cn(ROW_STYLES.status, !hasRunningChild && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningChild}
isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
</TerminalRowButton>

{/* Nested Blocks */}
{renderChildren && isExpanded && hasChildren && (
Expand Down Expand Up @@ -258,33 +241,25 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
return (
<div className='flex min-w-0 flex-col'>
{/* Subflow Header */}
<div
role='button'
tabIndex={0}
className={ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onToggleNode(nodeId)
}}
onKeyDown={(event) =>
handleKeyboardActivation(event, () => onToggleNode(nodeId), { stopPropagation: true })
}
<TerminalRowButton
aria-expanded={hasChildren ? isExpanded : undefined}
onClick={() => onToggleNode(nodeId)}
>
<div className={ROW_STYLES.content}>
<span className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>{displayName}</span>
{hasChildren && (
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
</span>
<span className={cn(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
</TerminalRowButton>

{/* Nested Iterations */}
{renderChildren && isExpanded && hasChildren && (
Expand Down Expand Up @@ -347,43 +322,31 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
return (
<div className='flex min-w-0 flex-col'>
{/* Workflow Block Header */}
<div
role='button'
tabIndex={0}
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
<TerminalRowButton
aria-expanded={hasChildren ? isExpanded : undefined}
selected={isSelected}
onClick={() => {
if (!isSelected) onSelectEntry(entry)
if (hasChildren) onToggleNode(nodeId)
}}
onKeyDown={(event) =>
handleKeyboardActivation(
event,
() => {
if (!isSelected) onSelectEntry(entry)
if (hasChildren) onToggleNode(nodeId)
},
{ stopPropagation: true }
)
}
>
<div className={ROW_STYLES.content}>
<span className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{entry.blockName}
</span>
{hasChildren && (
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
</span>
<span className={cn(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
</TerminalRowButton>

{/* Nested Child Blocks — rendered through EntryNodeRow for full loop/parallel support */}
{renderChildren && isExpanded && hasChildren && (
Expand Down
2 changes: 1 addition & 1 deletion packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar'
export { Badge, type BadgeProps } from './badge/badge'
export { Badge, type BadgeProps, badgeVariants } from './badge/badge'
export { Banner } from './banner/banner'
export {
BulkActionButton,
Expand Down
18 changes: 18 additions & 0 deletions packages/workflow-renderer/src/block-tile-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,22 @@ describe('shared block tile', () => {
expect(html).toContain('text-black!')
expect(html).toContain('>A</span>')
})

it('renders an inline provider tile inside native row buttons', () => {
const inline = renderToStaticMarkup(
<BlockTileView
as='span'
blockType='provider'
icon={Icon}
bgColor='#33C482'
useAccent={false}
/>
)
const ordinary = renderToStaticMarkup(
<BlockTileView blockType='provider' icon={Icon} bgColor='#33C482' useAccent={false} />
)
expect(inline).toMatch(/^<span\b/)
expect(ordinary).toMatch(/^<div\b/)
expect(inline.replace(/^<span/, '<div').replace(/<\/span>$/, '</div>')).toBe(ordinary)
})
})
Loading
Loading