From b11464ddb5fac611f6cf103adf808a7546886f2e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 12:29:25 -0700 Subject: [PATCH 1/2] fix(chat): use native attachment action --- .../components/chat/chat.test.tsx | 66 +++++++++++++++++-- .../w/[workflowId]/components/chat/chat.tsx | 17 ++--- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx index a28d06bc942..a2598bb6871 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -21,6 +21,8 @@ const { mockHandleRunWorkflow, mockReadSSEEvents, mockRevokeObjectURL, + executionState, + registryState, WorkflowAttachmentUploadErrorMock, } = vi.hoisted(() => { class WorkflowAttachmentUploadErrorMock extends Error { @@ -61,16 +63,13 @@ const { mockHandleRunWorkflow: vi.fn(), mockReadSSEEvents: vi.fn(), mockRevokeObjectURL: vi.fn(), + executionState: { isExecuting: false }, + registryState: { activeWorkflowId: 'workflow-1' }, WorkflowAttachmentUploadErrorMock, } }) vi.mock('@sim/emcn', () => ({ - Badge: ({ - children, - className: _className, - ...props - }: ButtonHTMLAttributes) => , Button: ({ children, className: _className, @@ -199,7 +198,7 @@ vi.mock('@/stores/chat/store', () => ({ })) vi.mock('@/stores/execution', () => ({ - useIsCurrentWorkflowExecuting: () => false, + useIsCurrentWorkflowExecuting: () => executionState.isExecuting, })) vi.mock('@/stores/operation-queue/store', () => ({ @@ -214,7 +213,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', () => ({ @@ -234,6 +233,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 @@ -280,6 +280,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) @@ -308,6 +310,56 @@ describe('floating chat attachment uploads', () => { vi.unstubAllGlobals() }) + it('opens file selection through a named native action', () => { + const action = container.querySelector('button[aria-label="Attach file"]') + const fileInput = container.querySelector('#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()) + + const action = container.querySelector('button[aria-label="Attach file"]') + const fileInput = container.querySelector('#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('disables attachment selection at the file limit', () => { + const fileInput = container.querySelector('#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('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({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index d6a91f24477..497f45bd1b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -2,7 +2,6 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - Badge, Button, ComposerActionButton, cn, @@ -1090,16 +1089,18 @@ export function Chat() {
- 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' - )} + 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' > - + Attach file From f0bff335dc785b73e74f5824fae3629736879a85 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 24 Sep 2026 13:04:52 -0700 Subject: [PATCH 2/2] fix(chat): retain disabled attachment tooltip --- .../components/chat/chat.test.tsx | 23 +++++++++++++++- .../w/[workflowId]/components/chat/chat.tsx | 26 ++++++++++--------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx index a2598bb6871..34f2dd00680 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.test.tsx @@ -4,7 +4,9 @@ import { act, type ButtonHTMLAttributes, + cloneElement, type InputHTMLAttributes, + type ReactElement, type ReactNode, type Ref, } from 'react' @@ -21,6 +23,7 @@ const { mockHandleRunWorkflow, mockReadSSEEvents, mockRevokeObjectURL, + mockTooltipPointerEnter, executionState, registryState, WorkflowAttachmentUploadErrorMock, @@ -63,6 +66,7 @@ const { mockHandleRunWorkflow: vi.fn(), mockReadSSEEvents: vi.fn(), mockRevokeObjectURL: vi.fn(), + mockTooltipPointerEnter: vi.fn(), executionState: { isExecuting: false }, registryState: { activeWorkflowId: 'workflow-1' }, WorkflowAttachmentUploadErrorMock, @@ -103,7 +107,10 @@ vi.mock('@sim/emcn', () => ({ PopoverTrigger: ({ children }: { children: ReactNode }) =>
{children}
, Tooltip: { Root: ({ children }: { children: ReactNode }) =>
{children}
, - Trigger: ({ children }: { children: ReactNode }) =>
{children}
, + Trigger: ({ children }: { children: ReactElement }) => + cloneElement(children as ReactElement<{ onPointerEnter?: () => void }>, { + onPointerEnter: mockTooltipPointerEnter, + }), Content: ({ children }: { children: ReactNode }) =>
{children}
, }, Trash: () => , @@ -340,6 +347,20 @@ describe('floating chat attachment uploads', () => { expect(openPicker).not.toHaveBeenCalled() }) + it('keeps the tooltip hover target available when attachment is disabled', () => { + executionState.isExecuting = true + act(() => root.render()) + + const action = container.querySelector('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('#floating-chat-file-input') if (!fileInput) throw new Error('Expected file input') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 497f45bd1b2..c49da9c7d73 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -1089,18 +1089,20 @@ export function Chat() {
- + + + Attach file