Skip to content

Commit 67dea87

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Use native buttons for terminal log rows
1 parent d9db35e commit 67dea87

9 files changed

Lines changed: 156 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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { renderToStaticMarkup } from 'react-dom/server'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
9+
import { StatusDisplay } from './status-display'
10+
import { TerminalRowButton } from './terminal-row-button'
11+
12+
let root: Root
13+
let host: HTMLDivElement
14+
15+
beforeEach(() => {
16+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
17+
host = document.createElement('div')
18+
document.body.appendChild(host)
19+
root = createRoot(host)
20+
})
21+
22+
afterEach(() => {
23+
act(() => root.unmount())
24+
host.remove()
25+
})
26+
27+
describe('TerminalRowButton', () => {
28+
it('renders selected disclosure semantics and handles one native click locally', () => {
29+
const onClick = vi.fn()
30+
const onParentClick = vi.fn()
31+
32+
act(() => {
33+
root.render(
34+
<div onClick={onParentClick}>
35+
<TerminalRowButton selected aria-expanded data-entry-id='entry-1' onClick={onClick}>
36+
<span>Workflow result</span>
37+
</TerminalRowButton>
38+
</div>
39+
)
40+
})
41+
42+
const button = host.querySelector('button')!
43+
expect(button.type).toBe('button')
44+
expect(button.getAttribute('aria-expanded')).toBe('true')
45+
expect(button.getAttribute('data-entry-id')).toBe('entry-1')
46+
expect(button.className).toBe(ROW_STYLES.rowSelected)
47+
act(() => button.focus())
48+
expect(document.activeElement).toBe(button)
49+
act(() => button.click())
50+
expect(onClick).toHaveBeenCalledTimes(1)
51+
expect(onParentClick).not.toHaveBeenCalled()
52+
})
53+
54+
it('keeps the base chip row when selection and expansion are absent', () => {
55+
act(() => {
56+
root.render(<TerminalRowButton>Block output</TerminalRowButton>)
57+
})
58+
const button = host.querySelector('button')!
59+
expect(button.className).toBe(ROW_STYLES.row)
60+
expect(button.hasAttribute('aria-expanded')).toBe(false)
61+
expect(button.textContent).toBe('Block output')
62+
})
63+
64+
it('keeps the running status inline inside a native button', () => {
65+
const html = renderToStaticMarkup(
66+
<TerminalRowButton>
67+
<StatusDisplay isRunning isCanceled={false} formattedDuration='-' />
68+
</TerminalRowButton>
69+
)
70+
expect(html).toMatch(/^<button\b/)
71+
expect(html).toContain('>Running</span>')
72+
expect(html).not.toContain('<div')
73+
})
74+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 extends ButtonHTMLAttributes<HTMLButtonElement> {
6+
/** Use the selected chip surface for the active output row. */
7+
selected?: boolean
8+
}
9+
10+
/** Native terminal row action with the established EMCN chip surface. */
11+
export function TerminalRowButton({
12+
selected = false,
13+
className,
14+
onClick,
15+
type,
16+
...props
17+
}: TerminalRowButtonProps) {
18+
return (
19+
<button
20+
type={type ?? 'button'}
21+
className={cn(selected ? ROW_STYLES.rowSelected : ROW_STYLES.row, className)}
22+
onClick={(event) => {
23+
event.stopPropagation()
24+
onClick?.(event)
25+
}}
26+
{...props}
27+
/>
28+
)
29+
}

‎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
})

‎packages/workflow-renderer/src/workflow-type.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ const TILE_ICON_SIZE_CLASS = {
108108

109109
export interface BlockTileViewProps
110110
extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'style'> {
111+
/** Inline root for a tile placed inside a native button. */
112+
as?: 'div' | 'span'
111113
/**
112114
* Block the tile represents; decides whether it takes the canvas role accent.
113115
* Omitted by rows that name no block, such as catalog and section headers.
@@ -125,6 +127,7 @@ export interface BlockTileViewProps
125127

126128
/** Shared block tile; callers resolve registry metadata before rendering. */
127129
export function BlockTileView({
130+
as: Element = 'div',
128131
blockType,
129132
icon,
130133
bgColor,
@@ -154,7 +157,7 @@ export function BlockTileView({
154157
const foregroundClass = isLightTileColor(fill) ? 'text-black!' : 'text-white!'
155158

156159
return (
157-
<div
160+
<Element
158161
className={cn(chipIconSlotClass, 'overflow-hidden rounded-md [&_img]:size-full', sizeClass)}
159162
style={{ background: fill }}
160163
{...props}
@@ -172,7 +175,7 @@ export function BlockTileView({
172175
<span className={cn('font-bold text-micro', foregroundClass)}>{fallbackLabel}</span>
173176
)
174177
)}
175-
</div>
178+
</Element>
176179
)
177180
}
178181

0 commit comments

Comments
 (0)