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
2 changes: 1 addition & 1 deletion .claude/rules/sim-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ paths:

```typescript
// ✓ Good
import { Chip } from '@sim/emcn'
import { useWorkflowStore } from '@/stores/workflows/store'
import { Button } from '@/components/ui/button'

// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/sim-imports.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ globs: ["apps/sim/**/*.ts","apps/sim/**/*.tsx"]

```typescript
// ✓ Good
import { Chip } from '@sim/emcn'
import { useWorkflowStore } from '@/stores/workflows/store'
import { Button } from '@/components/ui/button'

// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const { SECRET, searchTargetRef } = vi.hoisted(() => ({
}))

vi.mock('@sim/emcn', () => ({
Chip: ({
onClick,
disabled,
'aria-label': label,
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type='button' onClick={onClick} disabled={disabled} aria-label={label} />
),
CODE_LINE_HEIGHT_PX: 21,
Code: {
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Expand Down Expand Up @@ -72,10 +79,6 @@ vi.mock('react-simple-code-editor', () => ({
),
}))

vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
}))

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))
Expand Down Expand Up @@ -254,3 +257,25 @@ describe('Code password masking', () => {
expect(highlighted()).toContain(SECRET_MATCH)
})
})

describe('Code copy action', () => {
it('copies the current value through the shared chip action', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
vi.useFakeTimers()
act(() =>
root.render(
<Code
blockId='block-1'
subBlockId='privateKey'
showCopyButton
wandConfig={{ enabled: false, prompt: '' }}
/>
)
)
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Copy code"]')!.click())
expect(writeText).toHaveBeenCalledExactlyOnceWith(SECRET)
act(() => vi.advanceTimersByTime(2000))
vi.useRealTimers()
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactElement } from 'react'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import {
Chip,
CODE_LINE_HEIGHT_PX,
Code as CodeEditor,
calculateGutterWidth,
Expand All @@ -10,11 +11,10 @@ import {
highlight,
languages,
} from '@sim/emcn'
import { Check, Wand } from '@sim/emcn/icons'
import { Check } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor'
import { Button } from '@/components/ui/button'
import { CodeLanguage } from '@/lib/execution/languages'
import {
isLikelyReferenceSegment,
Expand Down Expand Up @@ -43,6 +43,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
Expand Down Expand Up @@ -906,22 +907,12 @@ export const Code = memo(function Code({
return (
<>
{showCopyButton && code && (
<Button
type='button'
variant='ghost'
size='sm'
<Chip
onClick={handleCopy}
disabled={!code}
className={cn(
'size-8 p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover-hover:scale-105 hover-hover:bg-muted/50 hover-hover:text-foreground',
'active:scale-95'
)}
leftIcon={copied ? Check : Duplicate}
aria-label='Copy code'
>
{copied ? <Check className='h-3.5 w-3.5' /> : <Duplicate className='h-3.5 w-3.5' />}
</Button>
/>
)}
{!hideInternalWand && (
<WandPromptBar
Expand All @@ -943,16 +934,11 @@ export const Code = memo(function Code({
!isPreview &&
!readOnly &&
!hideInternalWand && (
<Button
variant='ghost'
size='icon'
<WandButton
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
disabled={isAiLoading || isAiStreaming}
aria-label='Generate code with AI'
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'
>
<Wand className='size-4' />
</Button>
/>
)}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const { SECRET, searchTargetRef } = vi.hoisted(() => ({
}))

vi.mock('@sim/emcn', () => ({
Chip: ({
onClick,
disabled,
'aria-label': label,
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type='button' onClick={onClick} disabled={disabled} aria-label={label} />
),
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
}))
Expand All @@ -19,12 +26,6 @@ vi.mock('@sim/emcn/icons', () => ({
Wand: () => null,
}))

vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children?: React.ReactNode }) => (
<button type='button'>{children}</button>
),
}))

vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller',
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import {
useState,
} from 'react'
import { cn, Textarea } from '@sim/emcn'
import { ChevronsUpDown, Wand } from '@sim/emcn/icons'
import { ChevronsUpDown } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { Button } from '@/components/ui/button'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
maskSecretText,
Expand All @@ -22,6 +21,7 @@ import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
Expand Down Expand Up @@ -400,20 +400,15 @@ export function LongInput({
{/* Wand Button - only show if not hidden by parent */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='ghost'
size='icon'
<WandButton
onClick={
wandHook.isPromptVisible
? wandHook.hidePromptInline
: wandHook.showPromptInline
}
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
aria-label='Generate content with AI'
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'
>
<Wand className='size-4' />
</Button>
/>
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { cn, Input } from '@sim/emcn'
import { Wand } from '@sim/emcn/icons'
import { useReactFlow } from '@xyflow/react'
import { Button } from '@/components/ui/button'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
maskSecretText,
Expand All @@ -13,6 +11,7 @@ import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { WandButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-button'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
Expand Down Expand Up @@ -410,18 +409,13 @@ export const ShortInput = memo(function ShortInput({
{/* Wand Button - only show if not hidden by parent */}
{isWandEnabled && !isPreview && !wandHook.isStreaming && !hideInternalWand && (
<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'>
<Button
variant='ghost'
size='icon'
<WandButton
onClick={
wandHook.isPromptVisible ? wandHook.hidePromptInline : wandHook.showPromptInline
}
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
aria-label='Generate content with AI'
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'
>
<Wand className='size-4' />
</Button>
/>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MouseEventHandler } from 'react'
import { Chip } from '@sim/emcn'
import { Wand } from '@sim/emcn/icons'

interface WandButtonProps {
onClick: MouseEventHandler<HTMLButtonElement>
disabled?: boolean
'aria-label'?: string
}

/** AI prompt trigger shared by text fields and the code editor. Chrome belongs to EMCN. */
export function WandButton({
onClick,
disabled,
'aria-label': label = 'Generate content with AI',
}: WandButtonProps) {
return (
<Chip
variant='border-shadow'
shape='round'
leftIcon={Wand}
onClick={onClick}
disabled={disabled}
aria-label={label}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/** @vitest-environment jsdom */
import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'

let container: HTMLDivElement
let root: Root
const onSubmit = vi.fn()
const onCancel = vi.fn()
const onChange = vi.fn()

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

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.useRealTimers()
})

function render(props: Partial<ComponentProps<typeof WandPromptBar>> = {}) {
act(() =>
root.render(
<WandPromptBar
isVisible
isLoading={false}
isStreaming={false}
promptValue='Explain this code'
onSubmit={onSubmit}
onCancel={onCancel}
onChange={onChange}
{...props}
/>
)
)
}

function button(label: string) {
return container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
}

function key(key: string) {
act(() =>
container
.querySelector('input')!
.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
)
}

describe('WandPromptBar actions', () => {
it('submits the same prompt by click and Enter, and blocks empty prompts', () => {
render()
act(() => button('Generate content').click())
key('Enter')
expect(onSubmit.mock.calls).toEqual([['Explain this code'], ['Explain this code']])
render({ promptValue: ' ' })
expect(button('Generate content').disabled).toBe(true)
act(() => button('Generate content').click())
key('Enter')
expect(onSubmit).toHaveBeenCalledTimes(2)
})

it('preserves pending and streaming restrictions', () => {
render({ isLoading: true })
expect(container.querySelector('input')!.disabled).toBe(true)
expect(button('Generate content').disabled).toBe(true)
act(() => {
button('Generate content').click()
button('Close AI prompt').click()
})
key('Escape')
act(() => vi.runAllTimers())
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()

render({ isStreaming: true })
expect(container.querySelector('input')!.value).toBe('Generating...')
expect(button('Generate content')).toBeNull()
act(() => button('Close AI prompt').click())
act(() => vi.runAllTimers())
expect(onCancel).not.toHaveBeenCalled()
})

it.each(['button', 'Escape', 'outside'] as const)(
'closes through %s after the existing exit delay',
(method) => {
render()
if (method === 'button') act(() => button('Close AI prompt').click())
else if (method === 'Escape') key('Escape')
else act(() => document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
expect(onCancel).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(150))
expect(onCancel).toHaveBeenCalledTimes(1)
}
)
})
Loading
Loading