Skip to content

Commit a8d6e87

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(ui): reuse EMCN chips for AI controls
1 parent 6aabeb3 commit a8d6e87

12 files changed

Lines changed: 190 additions & 119 deletions

File tree

‎.claude/rules/sim-imports.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ paths:
1313

1414
```typescript
1515
// ✓ Good
16+
import { Chip } from '@sim/emcn'
1617
import { useWorkflowStore } from '@/stores/workflows/store'
17-
import { Button } from '@/components/ui/button'
1818

1919
// ✗ Bad
2020
import { useWorkflowStore } from '../../../stores/workflows/store'

‎.cursor/rules/sim-imports.mdc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ globs: ["apps/sim/**/*.ts","apps/sim/**/*.tsx"]
1313

1414
```typescript
1515
// ✓ Good
16+
import { Chip } from '@sim/emcn'
1617
import { useWorkflowStore } from '@/stores/workflows/store'
17-
import { Button } from '@/components/ui/button'
1818

1919
// ✗ Bad
2020
import { useWorkflowStore } from '../../../stores/workflows/store'

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ const { SECRET, searchTargetRef } = vi.hoisted(() => ({
2020
}))
2121

2222
vi.mock('@sim/emcn', () => ({
23+
Chip: ({
24+
onClick,
25+
disabled,
26+
'aria-label': label,
27+
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
28+
<button type='button' onClick={onClick} disabled={disabled} aria-label={label} />
29+
),
2330
CODE_LINE_HEIGHT_PX: 21,
2431
Code: {
2532
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -72,10 +79,6 @@ vi.mock('react-simple-code-editor', () => ({
7279
),
7380
}))
7481

75-
vi.mock('@/components/ui/button', () => ({
76-
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
77-
}))
78-
7982
vi.mock('next/navigation', () => ({
8083
useParams: () => ({ workspaceId: 'workspace-1' }),
8184
}))
@@ -254,3 +257,25 @@ describe('Code password masking', () => {
254257
expect(highlighted()).toContain(SECRET_MATCH)
255258
})
256259
})
260+
261+
describe('Code copy action', () => {
262+
it('copies the current value through the shared chip action', () => {
263+
const writeText = vi.fn().mockResolvedValue(undefined)
264+
vi.stubGlobal('navigator', { clipboard: { writeText } })
265+
vi.useFakeTimers()
266+
act(() =>
267+
root.render(
268+
<Code
269+
blockId='block-1'
270+
subBlockId='privateKey'
271+
showCopyButton
272+
wandConfig={{ enabled: false, prompt: '' }}
273+
/>
274+
)
275+
)
276+
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Copy code"]')!.click())
277+
expect(writeText).toHaveBeenCalledExactlyOnceWith(SECRET)
278+
act(() => vi.advanceTimersByTime(2000))
279+
vi.useRealTimers()
280+
})
281+
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx‎

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ReactElement } from 'react'
22
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
33
import {
4+
Chip,
45
CODE_LINE_HEIGHT_PX,
56
Code as CodeEditor,
67
calculateGutterWidth,
@@ -10,11 +11,10 @@ import {
1011
highlight,
1112
languages,
1213
} from '@sim/emcn'
13-
import { Check, Wand } from '@sim/emcn/icons'
14+
import { Check } from '@sim/emcn/icons'
1415
import { createLogger } from '@sim/logger'
1516
import { useParams } from 'next/navigation'
1617
import Editor from 'react-simple-code-editor'
17-
import { Button } from '@/components/ui/button'
1818
import { CodeLanguage } from '@/lib/execution/languages'
1919
import {
2020
isLikelyReferenceSegment,
@@ -43,6 +43,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
4343
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
4444
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
4545
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
46+
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
4647
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
4748
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
4849
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -906,22 +907,12 @@ export const Code = memo(function Code({
906907
return (
907908
<>
908909
{showCopyButton && code && (
909-
<Button
910-
type='button'
911-
variant='ghost'
912-
size='sm'
910+
<Chip
913911
onClick={handleCopy}
914912
disabled={!code}
915-
className={cn(
916-
'size-8 p-0',
917-
'text-muted-foreground/60 transition-all duration-200',
918-
'hover-hover:scale-105 hover-hover:bg-muted/50 hover-hover:text-foreground',
919-
'active:scale-95'
920-
)}
913+
leftIcon={copied ? Check : Duplicate}
921914
aria-label='Copy code'
922-
>
923-
{copied ? <Check className='h-3.5 w-3.5' /> : <Duplicate className='h-3.5 w-3.5' />}
924-
</Button>
915+
/>
925916
)}
926917
{!hideInternalWand && (
927918
<WandPromptBar
@@ -943,16 +934,11 @@ export const Code = memo(function Code({
943934
!isPreview &&
944935
!readOnly &&
945936
!hideInternalWand && (
946-
<Button
947-
variant='ghost'
948-
size='icon'
937+
<WandButton
949938
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
950939
disabled={isAiLoading || isAiStreaming}
951940
aria-label='Generate code with AI'
952-
className='size-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-xs transition-all duration-200 hover-hover:border-primary/20 hover-hover:bg-muted hover-hover:text-foreground hover-hover:shadow'
953-
>
954-
<Wand className='size-4' />
955-
</Button>
941+
/>
956942
)}
957943
</div>
958944

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.test.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ const { SECRET, searchTargetRef } = vi.hoisted(() => ({
1010
}))
1111

1212
vi.mock('@sim/emcn', () => ({
13+
Chip: ({
14+
onClick,
15+
disabled,
16+
'aria-label': label,
17+
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
18+
<button type='button' onClick={onClick} disabled={disabled} aria-label={label} />
19+
),
1320
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
1421
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
1522
}))
@@ -19,12 +26,6 @@ vi.mock('@sim/emcn/icons', () => ({
1926
Wand: () => null,
2027
}))
2128

22-
vi.mock('@/components/ui/button', () => ({
23-
Button: ({ children }: { children?: React.ReactNode }) => (
24-
<button type='button'>{children}</button>
25-
),
26-
}))
27-
2829
vi.mock(
2930
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller',
3031
() => ({

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import {
88
useState,
99
} from 'react'
1010
import { cn, Textarea } from '@sim/emcn'
11-
import { ChevronsUpDown, Wand } from '@sim/emcn/icons'
11+
import { ChevronsUpDown } from '@sim/emcn/icons'
1212
import { createLogger } from '@sim/logger'
13-
import { Button } from '@/components/ui/button'
1413
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
1514
import {
1615
maskSecretText,
@@ -22,6 +21,7 @@ import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
2221
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
2322
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
2423
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
24+
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
2525
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
2626
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
2727
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -400,20 +400,15 @@ export function LongInput({
400400
{/* Wand Button - only show if not hidden by parent */}
401401
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
402402
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
403-
<Button
404-
variant='ghost'
405-
size='icon'
403+
<WandButton
406404
onClick={
407405
wandHook.isPromptVisible
408406
? wandHook.hidePromptInline
409407
: wandHook.showPromptInline
410408
}
411409
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
412410
aria-label='Generate content with AI'
413-
className='size-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-xs transition-all duration-200 hover-hover:border-primary/20 hover-hover:bg-muted hover-hover:text-foreground hover-hover:shadow'
414-
>
415-
<Wand className='size-4' />
416-
</Button>
411+
/>
417412
</div>
418413
)}
419414

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
22
import { cn, Input } from '@sim/emcn'
3-
import { Wand } from '@sim/emcn/icons'
43
import { useReactFlow } from '@xyflow/react'
5-
import { Button } from '@/components/ui/button'
64
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
75
import {
86
maskSecretText,
@@ -13,6 +11,7 @@ import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/
1311
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
1412
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
1513
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
14+
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
1615
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
1716
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
1817
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -410,18 +409,13 @@ export const ShortInput = memo(function ShortInput({
410409
{/* Wand Button - only show if not hidden by parent */}
411410
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
412411
<div className='-translate-y-1/2 absolute top-1/2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
413-
<Button
414-
variant='ghost'
415-
size='icon'
412+
<WandButton
416413
onClick={
417414
wandHook.isPromptVisible ? wandHook.hidePromptInline : wandHook.showPromptInline
418415
}
419416
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
420417
aria-label='Generate content with AI'
421-
className='size-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-xs transition-all duration-200 hover-hover:border-primary/20 hover-hover:bg-muted hover-hover:text-foreground hover-hover:shadow'
422-
>
423-
<Wand className='size-4' />
424-
</Button>
418+
/>
425419
</div>
426420
)}
427421
</div>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { MouseEventHandler } from 'react'
2+
import { Chip } from '@sim/emcn'
3+
import { Wand } from '@sim/emcn/icons'
4+
5+
interface WandButtonProps {
6+
onClick: MouseEventHandler<HTMLButtonElement>
7+
disabled?: boolean
8+
'aria-label'?: string
9+
}
10+
11+
/** AI prompt trigger shared by text fields and the code editor. Chrome belongs to EMCN. */
12+
export function WandButton({
13+
onClick,
14+
disabled,
15+
'aria-label': label = 'Generate content with AI',
16+
}: WandButtonProps) {
17+
return (
18+
<Chip
19+
variant='border-shadow'
20+
shape='round'
21+
leftIcon={Wand}
22+
onClick={onClick}
23+
disabled={disabled}
24+
aria-label={label}
25+
/>
26+
)
27+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/** @vitest-environment jsdom */
2+
import { act, type ComponentProps } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
6+
7+
let container: HTMLDivElement
8+
let root: Root
9+
const onSubmit = vi.fn()
10+
const onCancel = vi.fn()
11+
const onChange = vi.fn()
12+
13+
beforeEach(() => {
14+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
15+
vi.clearAllMocks()
16+
vi.useFakeTimers()
17+
container = document.createElement('div')
18+
document.body.appendChild(container)
19+
root = createRoot(container)
20+
})
21+
22+
afterEach(() => {
23+
act(() => root.unmount())
24+
container.remove()
25+
vi.useRealTimers()
26+
})
27+
28+
function render(props: Partial<ComponentProps<typeof WandPromptBar>> = {}) {
29+
act(() =>
30+
root.render(
31+
<WandPromptBar
32+
isVisible
33+
isLoading={false}
34+
isStreaming={false}
35+
promptValue='Explain this code'
36+
onSubmit={onSubmit}
37+
onCancel={onCancel}
38+
onChange={onChange}
39+
{...props}
40+
/>
41+
)
42+
)
43+
}
44+
45+
function button(label: string) {
46+
return container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
47+
}
48+
49+
function key(key: string) {
50+
act(() =>
51+
container
52+
.querySelector('input')!
53+
.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
54+
)
55+
}
56+
57+
describe('WandPromptBar actions', () => {
58+
it('submits the same prompt by click and Enter, and blocks empty prompts', () => {
59+
render()
60+
act(() => button('Generate content').click())
61+
key('Enter')
62+
expect(onSubmit.mock.calls).toEqual([['Explain this code'], ['Explain this code']])
63+
render({ promptValue: ' ' })
64+
expect(button('Generate content').disabled).toBe(true)
65+
act(() => button('Generate content').click())
66+
key('Enter')
67+
expect(onSubmit).toHaveBeenCalledTimes(2)
68+
})
69+
70+
it('preserves pending and streaming restrictions', () => {
71+
render({ isLoading: true })
72+
expect(container.querySelector('input')!.disabled).toBe(true)
73+
expect(button('Generate content').disabled).toBe(true)
74+
act(() => {
75+
button('Generate content').click()
76+
button('Close AI prompt').click()
77+
})
78+
key('Escape')
79+
act(() => vi.runAllTimers())
80+
expect(onSubmit).not.toHaveBeenCalled()
81+
expect(onCancel).not.toHaveBeenCalled()
82+
83+
render({ isStreaming: true })
84+
expect(container.querySelector('input')!.value).toBe('Generating...')
85+
expect(button('Generate content')).toBeNull()
86+
act(() => button('Close AI prompt').click())
87+
act(() => vi.runAllTimers())
88+
expect(onCancel).not.toHaveBeenCalled()
89+
})
90+
91+
it.each(['button', 'Escape', 'outside'] as const)(
92+
'closes through %s after the existing exit delay',
93+
(method) => {
94+
render()
95+
if (method === 'button') act(() => button('Close AI prompt').click())
96+
else if (method === 'Escape') key('Escape')
97+
else act(() => document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
98+
expect(onCancel).not.toHaveBeenCalled()
99+
act(() => vi.advanceTimersByTime(150))
100+
expect(onCancel).toHaveBeenCalledTimes(1)
101+
}
102+
)
103+
})

0 commit comments

Comments
 (0)