Skip to content

Commit e6e3a1f

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(ui): use native terminal row buttons (#8263)
* Use native buttons for terminal log rows * test(terminal): verify complete native row markup * fix(terminal): expose selected row state and use absolute imports * fix(terminal): identify current output without toggle semantics --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 6da76e4 commit e6e3a1f

9 files changed

Lines changed: 194 additions & 71 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface EntryBlockTileProps {
1515
export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) {
1616
return (
1717
<BlockTile
18+
as='span'
1819
blockType={blockType}
1920
icon={getBlockIcon(blockType) ?? undefined}
2021
bgColor={getBlockColor(blockType)}

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile'
22
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
33
export { OutputPanel, type OutputPanelProps } from './output-panel'
44
export { StatusDisplay, type StatusDisplayProps } from './status-display'
5+
export { TerminalRowButton, type TerminalRowButtonProps } from './terminal-row-button'
56
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
'use client'
22

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

77
/**
8-
* Running badge component - displays a consistent "Running" indicator
8+
* Inline running badge for valid content inside a native terminal row button.
99
*/
1010
const RunningBadge = memo(function RunningBadge() {
11-
return (
12-
<Badge variant='green' className={BADGE_STYLE}>
13-
Running
14-
</Badge>
15-
)
11+
return <span className={cn(badgeVariants({ variant: 'green' }), BADGE_STYLE)}>Running</span>
1612
})
1713

1814
/**
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { BlockTileView } from '@sim/workflow-renderer'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { renderToStaticMarkup } from 'react-dom/server'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import {
10+
StatusDisplay,
11+
TerminalRowButton,
12+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
13+
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
14+
15+
let root: Root
16+
let host: HTMLDivElement
17+
18+
beforeEach(() => {
19+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
20+
host = document.createElement('div')
21+
document.body.appendChild(host)
22+
root = createRoot(host)
23+
})
24+
25+
afterEach(() => {
26+
act(() => root.unmount())
27+
host.remove()
28+
})
29+
30+
describe('TerminalRowButton', () => {
31+
it('renders selected disclosure semantics and handles one native click locally', () => {
32+
const onClick = vi.fn()
33+
const onParentClick = vi.fn()
34+
35+
act(() => {
36+
root.render(
37+
<div onClick={onParentClick}>
38+
<TerminalRowButton selected aria-expanded data-entry-id='entry-1' onClick={onClick}>
39+
<span>Workflow result</span>
40+
</TerminalRowButton>
41+
</div>
42+
)
43+
})
44+
45+
const button = host.querySelector('button')!
46+
expect(button.type).toBe('button')
47+
expect(button.getAttribute('aria-expanded')).toBe('true')
48+
expect(button.getAttribute('aria-current')).toBe('true')
49+
expect(button.getAttribute('data-entry-id')).toBe('entry-1')
50+
expect(button.className).toBe(ROW_STYLES.rowSelected)
51+
act(() => button.focus())
52+
expect(document.activeElement).toBe(button)
53+
act(() => button.click())
54+
expect(onClick).toHaveBeenCalledTimes(1)
55+
expect(onParentClick).not.toHaveBeenCalled()
56+
})
57+
58+
it('keeps the base chip row when selection and expansion are absent', () => {
59+
act(() => {
60+
root.render(<TerminalRowButton>Block output</TerminalRowButton>)
61+
})
62+
const button = host.querySelector('button')!
63+
expect(button.className).toBe(ROW_STYLES.row)
64+
expect(button.hasAttribute('aria-expanded')).toBe(false)
65+
expect(button.hasAttribute('aria-current')).toBe(false)
66+
expect(button.textContent).toBe('Block output')
67+
})
68+
69+
it('does not mark an unselected output row as current', () => {
70+
act(() => {
71+
root.render(<TerminalRowButton selected={false}>Other output</TerminalRowButton>)
72+
})
73+
expect(host.querySelector('button')?.hasAttribute('aria-current')).toBe(false)
74+
})
75+
76+
it('keeps the running status inline inside a native button', () => {
77+
const html = renderToStaticMarkup(
78+
<TerminalRowButton>
79+
<StatusDisplay isRunning isCanceled={false} formattedDuration='-' />
80+
</TerminalRowButton>
81+
)
82+
expect(html).toMatch(/^<button\b/)
83+
expect(html).toContain('>Running</span>')
84+
expect(html).not.toContain('<div')
85+
})
86+
87+
it('keeps the complete tile, label, chevron, and status as valid button contents', () => {
88+
const Icon = ({ className }: { className?: string }) => <svg className={className} />
89+
const html = renderToStaticMarkup(
90+
<TerminalRowButton aria-expanded={false}>
91+
<span className={ROW_STYLES.content}>
92+
<BlockTileView as='span' blockType='agent' icon={Icon} bgColor='#33C482' useAccent />
93+
<span className={ROW_STYLES.label}>Agent</span>
94+
<svg aria-hidden='true' />
95+
</span>
96+
<span className={ROW_STYLES.status}>
97+
<StatusDisplay isRunning isCanceled={false} formattedDuration='-' />
98+
</span>
99+
</TerminalRowButton>
100+
)
101+
const document = new DOMParser().parseFromString(html, 'text/html')
102+
const button = document.querySelector('button')
103+
expect(button?.getAttribute('aria-expanded')).toBe('false')
104+
expect(button?.textContent).toContain('Agent')
105+
expect(button?.textContent).toContain('Running')
106+
expect(button?.querySelector('[data-workflow-type-icon="agent"]')).not.toBeNull()
107+
expect(button?.querySelectorAll('button, a, div')).toHaveLength(0)
108+
expect(document.body.children).toHaveLength(1)
109+
})
110+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { ButtonHTMLAttributes } from 'react'
2+
import { cn } from '@sim/emcn'
3+
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
4+
5+
export interface TerminalRowButtonProps
6+
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-current'> {
7+
/** Use the selected chip surface for the active output row. */
8+
selected?: boolean
9+
}
10+
11+
/** Native terminal row action with the established EMCN chip surface. */
12+
export function TerminalRowButton({
13+
selected,
14+
className,
15+
onClick,
16+
type,
17+
...props
18+
}: TerminalRowButtonProps) {
19+
return (
20+
<button
21+
type={type ?? 'button'}
22+
className={cn(selected ? ROW_STYLES.rowSelected : ROW_STYLES.row, className)}
23+
aria-current={selected ? 'true' : undefined}
24+
onClick={(event) => {
25+
event.stopPropagation()
26+
onClick?.(event)
27+
}}
28+
{...props}
29+
/>
30+
)
31+
}

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx‎

Lines changed: 24 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
ChevronDown,
88
cn,
99
disclosureChevronClass,
10-
handleKeyboardActivation,
1110
Popover,
1211
PopoverContent,
1312
PopoverItem,
@@ -27,6 +26,7 @@ import {
2726
LogRowContextMenu,
2827
OutputPanel,
2928
StatusDisplay,
29+
TerminalRowButton,
3030
ToggleButton,
3131
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
3232
import {
@@ -110,33 +110,25 @@ const BlockRow = memo(function BlockRow({
110110
const isCanceled = Boolean(entry.isCanceled)
111111

112112
return (
113-
<div
113+
<TerminalRowButton
114114
data-entry-id={entry.id}
115-
role='button'
116-
tabIndex={0}
117-
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
118-
onClick={(e) => {
119-
e.stopPropagation()
120-
onSelect(entry)
121-
}}
122-
onKeyDown={(event) =>
123-
handleKeyboardActivation(event, () => onSelect(entry), { stopPropagation: true })
124-
}
115+
selected={isSelected}
116+
onClick={() => onSelect(entry)}
125117
>
126-
<div className={ROW_STYLES.content}>
118+
<span className={ROW_STYLES.content}>
127119
<EntryBlockTile blockType={entry.blockType} />
128120
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
129121
{entry.blockName}
130122
</span>
131-
</div>
123+
</span>
132124
<span className={cn(ROW_STYLES.status, !isRunning && ROW_STYLES.statusIdle)}>
133125
<StatusDisplay
134126
isRunning={isRunning}
135127
isCanceled={isCanceled}
136128
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
137129
/>
138130
</span>
139-
</div>
131+
</TerminalRowButton>
140132
)
141133
})
142134

@@ -175,32 +167,23 @@ const IterationNodeRow = memo(function IterationNodeRow({
175167
return (
176168
<div className='flex min-w-0 flex-col'>
177169
{/* Iteration Header */}
178-
<div
179-
role='button'
180-
tabIndex={0}
181-
className={ROW_STYLES.row}
182-
onClick={(e) => {
183-
e.stopPropagation()
184-
onToggle()
185-
}}
186-
onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })}
187-
>
188-
<div className={ROW_STYLES.content}>
170+
<TerminalRowButton aria-expanded={hasChildren ? isExpanded : undefined} onClick={onToggle}>
171+
<span className={ROW_STYLES.content}>
189172
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
190173
{iterationLabel}
191174
</span>
192175
{hasChildren && (
193176
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
194177
)}
195-
</div>
178+
</span>
196179
<span className={cn(ROW_STYLES.status, !hasRunningChild && ROW_STYLES.statusIdle)}>
197180
<StatusDisplay
198181
isRunning={hasRunningChild}
199182
isCanceled={hasCanceledChild}
200183
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
201184
/>
202185
</span>
203-
</div>
186+
</TerminalRowButton>
204187

205188
{/* Nested Blocks */}
206189
{renderChildren && isExpanded && hasChildren && (
@@ -258,33 +241,25 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
258241
return (
259242
<div className='flex min-w-0 flex-col'>
260243
{/* Subflow Header */}
261-
<div
262-
role='button'
263-
tabIndex={0}
264-
className={ROW_STYLES.row}
265-
onClick={(e) => {
266-
e.stopPropagation()
267-
onToggleNode(nodeId)
268-
}}
269-
onKeyDown={(event) =>
270-
handleKeyboardActivation(event, () => onToggleNode(nodeId), { stopPropagation: true })
271-
}
244+
<TerminalRowButton
245+
aria-expanded={hasChildren ? isExpanded : undefined}
246+
onClick={() => onToggleNode(nodeId)}
272247
>
273-
<div className={ROW_STYLES.content}>
248+
<span className={ROW_STYLES.content}>
274249
<EntryBlockTile blockType={entry.blockType} />
275250
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>{displayName}</span>
276251
{hasChildren && (
277252
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
278253
)}
279-
</div>
254+
</span>
280255
<span className={cn(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
281256
<StatusDisplay
282257
isRunning={hasRunningDescendant}
283258
isCanceled={hasCanceledDescendant}
284259
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
285260
/>
286261
</span>
287-
</div>
262+
</TerminalRowButton>
288263

289264
{/* Nested Iterations */}
290265
{renderChildren && isExpanded && hasChildren && (
@@ -347,43 +322,31 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
347322
return (
348323
<div className='flex min-w-0 flex-col'>
349324
{/* Workflow Block Header */}
350-
<div
351-
role='button'
352-
tabIndex={0}
353-
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
354-
onClick={(e) => {
355-
e.stopPropagation()
325+
<TerminalRowButton
326+
aria-expanded={hasChildren ? isExpanded : undefined}
327+
selected={isSelected}
328+
onClick={() => {
356329
if (!isSelected) onSelectEntry(entry)
357330
if (hasChildren) onToggleNode(nodeId)
358331
}}
359-
onKeyDown={(event) =>
360-
handleKeyboardActivation(
361-
event,
362-
() => {
363-
if (!isSelected) onSelectEntry(entry)
364-
if (hasChildren) onToggleNode(nodeId)
365-
},
366-
{ stopPropagation: true }
367-
)
368-
}
369332
>
370-
<div className={ROW_STYLES.content}>
333+
<span className={ROW_STYLES.content}>
371334
<EntryBlockTile blockType={entry.blockType} />
372335
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
373336
{entry.blockName}
374337
</span>
375338
{hasChildren && (
376339
<ChevronDown className={cn(disclosureChevronClass, !isExpanded && '-rotate-90')} />
377340
)}
378-
</div>
341+
</span>
379342
<span className={cn(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
380343
<StatusDisplay
381344
isRunning={hasRunningDescendant}
382345
isCanceled={hasCanceledDescendant}
383346
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
384347
/>
385348
</span>
386-
</div>
349+
</TerminalRowButton>
387350

388351
{/* Nested Child Blocks — rendered through EntryNodeRow for full loop/parallel support */}
389352
{renderChildren && isExpanded && hasChildren && (

‎packages/emcn/src/components/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar'
2-
export { Badge, type BadgeProps } from './badge/badge'
2+
export { Badge, type BadgeProps, badgeVariants } from './badge/badge'
33
export { Banner } from './banner/banner'
44
export {
55
BulkActionButton,

‎packages/workflow-renderer/src/block-tile-view.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,22 @@ describe('shared block tile', () => {
4848
expect(html).toContain('text-black!')
4949
expect(html).toContain('>A</span>')
5050
})
51+
52+
it('renders an inline provider tile inside native row buttons', () => {
53+
const inline = renderToStaticMarkup(
54+
<BlockTileView
55+
as='span'
56+
blockType='provider'
57+
icon={Icon}
58+
bgColor='#33C482'
59+
useAccent={false}
60+
/>
61+
)
62+
const ordinary = renderToStaticMarkup(
63+
<BlockTileView blockType='provider' icon={Icon} bgColor='#33C482' useAccent={false} />
64+
)
65+
expect(inline).toMatch(/^<span\b/)
66+
expect(ordinary).toMatch(/^<div\b/)
67+
expect(inline.replace(/^<span/, '<div').replace(/<\/span>$/, '</div>')).toBe(ordinary)
68+
})
5169
})

0 commit comments

Comments
 (0)