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
Expand Up @@ -4,7 +4,9 @@
import {
act,
type ButtonHTMLAttributes,
cloneElement,
type InputHTMLAttributes,
type ReactElement,
type ReactNode,
type Ref,
} from 'react'
Expand All @@ -21,6 +23,9 @@ const {
mockHandleRunWorkflow,
mockReadSSEEvents,
mockRevokeObjectURL,
mockTooltipPointerEnter,
executionState,
registryState,
WorkflowAttachmentUploadErrorMock,
} = vi.hoisted(() => {
class WorkflowAttachmentUploadErrorMock extends Error {
Expand Down Expand Up @@ -61,16 +66,14 @@ const {
mockHandleRunWorkflow: vi.fn(),
mockReadSSEEvents: vi.fn(),
mockRevokeObjectURL: vi.fn(),
mockTooltipPointerEnter: vi.fn(),
executionState: { isExecuting: false },
registryState: { activeWorkflowId: 'workflow-1' },
WorkflowAttachmentUploadErrorMock,
}
})

vi.mock('@sim/emcn', () => ({
Badge: ({
children,
className: _className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
Button: ({
children,
className: _className,
Expand Down Expand Up @@ -104,7 +107,10 @@ vi.mock('@sim/emcn', () => ({
PopoverTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tooltip: {
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children: ReactElement }) =>
cloneElement(children as ReactElement<{ onPointerEnter?: () => void }>, {
onPointerEnter: mockTooltipPointerEnter,
}),
Content: ({ children }: { children: ReactNode }) => <div>{children}</div>,
},
Trash: () => <span data-icon='Trash' />,
Expand Down Expand Up @@ -199,7 +205,7 @@ vi.mock('@/stores/chat/store', () => ({
}))

vi.mock('@/stores/execution', () => ({
useIsCurrentWorkflowExecuting: () => false,
useIsCurrentWorkflowExecuting: () => executionState.isExecuting,
}))

vi.mock('@/stores/operation-queue/store', () => ({
Expand All @@ -214,7 +220,7 @@ vi.mock('@/stores/terminal', () => ({

vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: (selector: (state: { activeWorkflowId: string }) => unknown) =>
selector({ activeWorkflowId: 'workflow-1' }),
selector(registryState),
}))

vi.mock('@/stores/workflows/subblock/store', () => ({
Expand All @@ -234,6 +240,7 @@ vi.mock('@/stores/chat/utils', () => ({
}))

import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { MAX_CHAT_FILES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'

let container: HTMLDivElement
let root: Root
Expand Down Expand Up @@ -280,6 +287,8 @@ describe('floating chat attachment uploads', () => {
beforeEach(async () => {
vi.useFakeTimers()
vi.clearAllMocks()
executionState.isExecuting = false
registryState.activeWorkflowId = 'workflow-1'
mockCreateObjectURL.mockReturnValue('blob:diagram-preview')
mockReadSSEEvents.mockResolvedValue(undefined)
vi.stubGlobal('FileReader', mockFileReader)
Expand Down Expand Up @@ -308,6 +317,70 @@ describe('floating chat attachment uploads', () => {
vi.unstubAllGlobals()
})

it('opens file selection through a named native action', () => {
const action = container.querySelector<HTMLButtonElement>('button[aria-label="Attach file"]')
const fileInput = container.querySelector<HTMLInputElement>('#floating-chat-file-input')
expect(action?.type).toBe('button')
expect(action?.disabled).toBe(false)
expect(action?.querySelector('[data-icon="Paperclip"]')).not.toBeNull()
expect(fileInput).not.toBeNull()

const openPicker = vi.fn()
if (fileInput) fileInput.click = openPicker
act(() => action?.click())
expect(openPicker).toHaveBeenCalledTimes(1)
})

it.each(['executing', 'no workflow'] as const)('prevents file selection with %s', (condition) => {
executionState.isExecuting = condition === 'executing'
registryState.activeWorkflowId = condition === 'no workflow' ? '' : 'workflow-1'
act(() => root.render(<Chat />))

const action = container.querySelector<HTMLButtonElement>('button[aria-label="Attach file"]')
const fileInput = container.querySelector<HTMLInputElement>('#floating-chat-file-input')
expect(action?.disabled).toBe(true)
expect(fileInput?.disabled).toBe(true)

const openPicker = vi.fn()
if (fileInput) fileInput.click = openPicker
act(() => action?.click())
expect(openPicker).not.toHaveBeenCalled()
})

it('keeps the tooltip hover target available when attachment is disabled', () => {
executionState.isExecuting = true
act(() => root.render(<Chat />))

const action = container.querySelector<HTMLButtonElement>('button[aria-label="Attach file"]')
const trigger = action?.parentElement
expect(action?.disabled).toBe(true)
expect(trigger?.tagName).toBe('SPAN')
expect(trigger?.hasAttribute('disabled')).toBe(false)

act(() => trigger?.dispatchEvent(new Event('pointerover', { bubbles: true })))
expect(mockTooltipPointerEnter).toHaveBeenCalledTimes(1)
})

it('disables attachment selection at the file limit', () => {
const fileInput = container.querySelector<HTMLInputElement>('#floating-chat-file-input')
if (!fileInput) throw new Error('Expected file input')
Object.defineProperty(fileInput, 'files', {
configurable: true,
value: Array.from(
{ length: MAX_CHAT_FILES },
(_, index) => new File(['x'], `file-${index}.txt`)
),
})
act(() => fileInput.dispatchEvent(new Event('change', { bubbles: true })))

const action = container.querySelector<HTMLButtonElement>('button[aria-label="Attach file"]')
expect(action?.disabled).toBe(true)
const openPicker = vi.fn()
fileInput.click = openPicker
act(() => action?.click())
expect(openPicker).not.toHaveBeenCalled()
})

it('uses uploaded URLs for message previews without base64 conversion', async () => {
const file = new File(['diagram'], 'diagram.png', { type: 'image/png' })
mockHandleRunWorkflow.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
ComposerActionButton,
cn,
Expand Down Expand Up @@ -1090,16 +1089,20 @@ export function Chat() {
<div className='-translate-y-1/2 absolute top-1/2 right-[2px] flex items-center gap-2.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
className={cn(
'cursor-pointer rounded-md border-0! bg-transparent! p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= MAX_CHAT_FILES) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='h-3.5! w-3.5!' />
</Badge>
<span className='inline-flex'>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Keyboard focus loses tooltip When a keyboard user focuses the enabled attachment button, “Attach file” no longer appears as a tooltip. The tooltip’s focus handler is now attached to the surrounding span and checks whether that span is focused, rather than the button. This makes the action’s label less discoverable for keyboard users.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

<Button
type='button'
variant='ghost'
aria-label='Attach file'
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
disabled={
!activeWorkflowId || isExecuting || chatFiles.length >= MAX_CHAT_FILES
}
className='size-3.5 rounded-md p-0 focus-visible:ring-1 focus-visible:ring-[var(--text-muted)] disabled:opacity-50'
>
<Paperclip className='h-3.5! w-3.5!' />
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content>Attach file</Tooltip.Content>
</Tooltip.Root>
Expand Down