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
@@ -1,7 +1,7 @@
'use client'

import { useRef } from 'react'
import { Button, Chip, cn, Tooltip } from '@sim/emcn'
import { Chip, ComposerActionButton, cn, Tooltip } from '@sim/emcn'
import { ArrowUp, Plus, StopFilled } from '@sim/emcn/icons'
import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE } from '@/lib/uploads/shared/assistant-images'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
Expand All @@ -14,11 +14,6 @@ import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
import { useVoiceInput } from '@/hooks/use-voice-input'

const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0'
const SEND_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'

interface ComposerProps {
value: string
files: ReturnType<typeof useFileAttachments>
Expand Down Expand Up @@ -130,29 +125,19 @@ export function Composer({
/>
)}
{isSending ? (
<Button
type='button'
variant='ghost'
onClick={onStop}
aria-label='Stop generation'
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
>
<ComposerActionButton type='button' onClick={onStop} aria-label='Stop generation'>
<StopFilled className='block size-[14px] fill-white dark:fill-black' />
</Button>
</ComposerActionButton>
) : (
<Button
<ComposerActionButton
type='button'
variant='ghost'
onClick={submit}
disabled={!canSubmit}
aria-label='Send'
className={cn(
SEND_BUTTON_BASE,
canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED
)}
active={canSubmit}
>
<ArrowUp className='block size-[16px] text-white dark:text-black' />
</Button>
</ComposerActionButton>
)}
</div>
</div>
Expand Down
24 changes: 11 additions & 13 deletions apps/sim/app/o/[organizationId]/search/search.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import {
ComposerActionButton,
cn,
scrollFadeAttributes,
scrollFadeClass,
useScrollEdges,
} from '@sim/emcn'
import { ArrowUp, Search } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
Expand All @@ -25,11 +31,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useVoiceInput } from '@/hooks/use-voice-input'

const SUBMIT_BUTTON_BASE = 'size-[28px] shrink-0 rounded-full border-0 p-0'
const SUBMIT_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
const SUBMIT_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'

interface SearchFieldProps {
initialValue: string
onSubmit: (value: string) => void
Expand Down Expand Up @@ -98,19 +99,16 @@ function SearchField({
onToggle={voice.toggleListening}
/>
)}
<Button
<ComposerActionButton
type='button'
variant='ghost'
onClick={() => onSubmit(value)}
disabled={!canSubmit}
aria-label='Search'
className={cn(
SUBMIT_BUTTON_BASE,
canSubmit ? SUBMIT_BUTTON_ACTIVE : SUBMIT_BUTTON_DISABLED
)}
active={canSubmit}
className='shrink-0'
>
<ArrowUp className='block size-[16px] text-white dark:text-black' />
</Button>
</ComposerActionButton>
</div>
<MicrophonePermissionHelp
open={voice.permissionHelpOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,6 @@ export const SCROLLER_CLASSES = cn(
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)

export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0'
export const SEND_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'

export const SPEECH_RECOGNITION_LANG = 'en-US'

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
'use client'

import React from 'react'
import { Button, cn } from '@sim/emcn'
import { ComposerActionButton } from '@sim/emcn'
import { ArrowUp, StopFilled } from '@sim/emcn/icons'
import {
SEND_BUTTON_ACTIVE,
SEND_BUTTON_BASE,
SEND_BUTTON_DISABLED,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'

interface SendButtonProps {
isSending: boolean
Expand All @@ -24,26 +19,23 @@ export const SendButton = React.memo(function SendButton({
}: SendButtonProps) {
if (isSending) {
return (
<Button
<ComposerActionButton
onClick={onStopGeneration}
variant='ghost'
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
title='Stop generation'
aria-label='Stop generation'
>
<StopFilled className='block h-[14px] w-[14px] fill-white dark:fill-black' />
</Button>
</ComposerActionButton>
)
}
return (
<Button
<ComposerActionButton
onClick={onSubmit}
aria-label='Send message'
variant='ghost'
disabled={!canSubmit}
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
active={canSubmit}
>
<ArrowUp className='block h-[16px] w-[16px] text-white dark:text-black' />
</Button>
</ComposerActionButton>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ vi.mock('@sim/emcn', () => ({
<button {...props}>{children}</button>
),
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
ComposerActionButton: ({
children,
size: _size,
active: _active,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { size?: string; active?: boolean }) => (
<button {...props}>{children}</button>
),
Input: ({
ref,
className: _className,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState }
import {
Badge,
Button,
ComposerActionButton,
cn,
Input,
Popover,
Expand Down Expand Up @@ -1102,34 +1103,28 @@ export function Chat() {
</Tooltip.Root>

{isStreaming ? (
<Button
<ComposerActionButton
aria-label='Stop generation'
onClick={handleStopStreaming}
variant='ghost'
className='size-[22px] rounded-full bg-[#383838] p-0 hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
size='sm'
>
<Square className='h-2.5 w-2.5 fill-white text-white dark:fill-black dark:text-black' />
</Button>
</ComposerActionButton>
) : (
<Button
<ComposerActionButton
aria-label='Send message'
onClick={handleSendMessage}
variant='ghost'
size='sm'
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
isExecuting ||
isStreaming
}
className={cn(
'size-[22px] rounded-full p-0',
chatMessage.trim() || chatFiles.length > 0
? 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]'
: 'bg-[#808080] dark:bg-[#808080]'
)}
active={!!(chatMessage.trim() || chatFiles.length > 0)}
>
<ArrowUp className='size-3.5 text-white dark:text-black' />
</Button>
</ComposerActionButton>
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/** @vitest-environment jsdom */
import { act, createRef, type ReactNode } from 'react'
import { Button, ComposerActionButton } from '@sim/emcn'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

let root: Root | null = null
let container: HTMLDivElement | null = null

function mount(children: ReactNode) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(children))
return container
}

function button() {
const element = container?.querySelector('button')
if (!element) throw new Error('Button did not render')
return element
}

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
})

/** Exact pre-migration class inputs from the organization composer and workflow chat. */
const PREVIOUS = {
md: {
base: 'size-[28px] rounded-full border-0 p-0',
active: 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]',
},
sm: {
base: 'size-[22px] rounded-full p-0',
active: 'bg-[#383838] hover-hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover-hover:bg-[#CFCFCF]',
},
} as const

describe('ComposerActionButton', () => {
for (const size of ['md', 'sm'] as const) {
for (const active of [true, false]) {
it(`preserves the previous ${size} markup with active=${active}`, () => {
const previous = PREVIOUS[size]
const view = mount(
<>
<Button
variant='ghost'
aria-label='Send'
className={`${previous.base} ${active ? previous.active : 'bg-[#808080] dark:bg-[#808080]'} shrink-0`}
>
<svg className='size-[16px] text-white dark:text-black' aria-hidden='true' />
</Button>
<ComposerActionButton
aria-label='Send'
size={size === 'md' ? undefined : size}
active={active ? undefined : false}
className='shrink-0'
>
<svg className='size-[16px] text-white dark:text-black' aria-hidden='true' />
</ComposerActionButton>
</>
)
const [before, after] = view.querySelectorAll('button')
before.className = before.className.split(/\s+/).sort().join(' ')
after.className = after.className.split(/\s+/).sort().join(' ')
expect(after.outerHTML).toBe(before.outerHTML)
})
}
}

it('forwards refs and events while keeping active appearance independent of disabled', () => {
const ref = createRef<HTMLButtonElement>()
const onClick = vi.fn()
const onKeyDown = vi.fn()
const action = (disabled: boolean) => (
<ComposerActionButton
ref={ref}
aria-label='Send message'
data-action='send'
active
disabled={disabled}
onClick={onClick}
onKeyDown={onKeyDown}
/>
)
mount(action(false))
expect(ref.current).toBe(button())
expect(button().dataset.action).toBe('send')
act(() => button().focus())
expect(document.activeElement).toBe(button())
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
act(() => button().dispatchEvent(keyEvent))
expect(onKeyDown).toHaveBeenCalledTimes(1)
expect(onKeyDown.mock.calls[0][0].nativeEvent).toBe(keyEvent)
act(() => button().click())
expect(onClick).toHaveBeenCalledTimes(1)
const activeClasses = button().className
act(() => root?.render(action(true)))
expect(button().disabled).toBe(true)
expect(button().className).toBe(activeClasses)
act(() => button().click())
expect(onClick).toHaveBeenCalledTimes(1)
})

for (const type of [undefined, 'button'] as const) {
it(`preserves native form behavior for type=${type ?? 'omitted'}`, () => {
const onSubmit = vi.fn((event) => event.preventDefault())
mount(
<form onSubmit={onSubmit}>
<ComposerActionButton aria-label='Search' type={type} />
</form>
)
act(() => button().click())
expect(onSubmit).toHaveBeenCalledTimes(type === 'button' ? 0 : 1)
})
}
})
Loading
Loading