From d9de4bf0080ac2caa08c35f4472053ccfe6cd9f2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 1 Oct 2026 01:34:51 -0700 Subject: [PATCH 1/2] feat(chat): cycle available composer modes with Shift+Tab --- .../components/composer/composer.test.tsx | 82 +++++++++++++++++++ .../home/components/composer/composer.tsx | 40 +++++---- .../components/conversation-mode-selector.tsx | 13 +-- .../hooks/use-conversation-mode-shortcut.ts | 66 +++++++++++++++ .../home/components/user-input/user-input.tsx | 19 +++-- .../user-input/utils/conversation-modes.ts | 11 +++ 6 files changed, 202 insertions(+), 29 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes.ts diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index defa0366828..a51730ba6ed 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -85,6 +85,7 @@ vi.mock('@/hooks/use-chat-input-focus', () => ({ useChatInputFocus: vi.fn() })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock) import { Composer } from '@/app/o/[organizationId]/home/components/composer/composer' +import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' @@ -187,6 +188,7 @@ beforeEach(() => { afterEach(async () => { await act(async () => root.unmount()) + vi.useRealTimers() container.remove() queryClient.clear() }) @@ -251,6 +253,86 @@ function fileList(files: File[]): FileList { return Object.assign(files, { item: (index: number) => files[index] ?? null }) } +it.each([ + { searchEnabled: true, planEnabled: true, modes: ['assistant', 'agent', 'plan', 'assistant'] }, + { searchEnabled: true, planEnabled: false, modes: ['assistant', 'agent', 'assistant'] }, + { searchEnabled: false, planEnabled: true, modes: ['agent', 'plan', 'agent'] }, +] satisfies { searchEnabled: boolean; planEnabled: boolean; modes: ChatRequestMode[] }[])( + 'cycles available modes without losing the draft or selection (Search: $searchEnabled, Plan: $planEnabled)', + async ({ searchEnabled, planEnabled, modes }) => { + vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) + let currentMode = modes[0] + function Harness() { + const [mode, setMode] = useState(modes[0]) + currentMode = mode + const [value, setValue] = useState('Summarize this draft') + const files = useFileAttachments({ + userId: 'user-a', + organizationId: 'organization-a', + requestMode: mode, + }) + return ( + {}} + onSubmit={() => {}} + /> + ) + } + await act(async () => + root.render( + + + + + + ) + ) + const input = container.querySelector('[aria-label="Ask Sim"]')! + await act(async () => { + input.focus() + input.setSelectionRange(10, 14, 'backward') + }) + for (const expectedMode of modes.slice(1)) { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + await act(async () => { + container.querySelector('[aria-label="Ask Sim"]')!.dispatchEvent(event) + }) + await act(async () => vi.advanceTimersToNextFrame()) + const nextInput = container.querySelector('[aria-label="Ask Sim"]')! + expect(currentMode).toBe(expectedMode) + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(nextInput) + expect(nextInput.value).toBe('Summarize this draft') + expect([ + nextInput.selectionStart, + nextInput.selectionEnd, + nextInput.selectionDirection, + ]).toEqual([10, 14, 'backward']) + } + } +) + async function paste(files: File[]) { const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 57d1cb40b36..123bd5d6331 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -24,6 +24,7 @@ import { usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options' +import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut' import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' @@ -100,6 +101,26 @@ export function Composer({ onPasteFiles: files.processFiles, }) const { textareaRef } = editor + const handleModeChange = (mode: ChatRequestMode) => { + if ( + mode === 'assistant' && + (editor.getActiveContexts().length > 0 || + files.attachedFiles.some((file) => !isAssistantImageType(file.type))) + ) { + toast.info( + 'Remove resource and skill mentions and non-image attachments before switching to Search.' + ) + return + } + onModeChange?.(mode) + } + const handleModeShortcut = useConversationModeShortcut({ + value: requestMode, + searchEnabled, + onChange: showModeSelector && onModeChange ? handleModeChange : undefined, + textareaRef, + pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null, + }) const editorRef = useRef(editor) editorRef.current = editor const lastPublished = useRef(value) @@ -222,23 +243,7 @@ export function Composer({ { - if ( - mode === 'assistant' && - (editor.getActiveContexts().length > 0 || - files.attachedFiles.some((file) => !isAssistantImageType(file.type))) - ) { - toast.info( - 'Remove resource and skill mentions and non-image attachments before switching to Search.' - ) - return - } - onModeChange(mode) - } - : undefined - } + onChange={onModeChange ? handleModeChange : undefined} /> )} @@ -276,6 +281,7 @@ export function Composer({ return (
@@ -52,7 +49,11 @@ export function ConversationModeSelector({ /> - {!open && Select mode} + {!open && ( + + {onChange ? Select mode : 'Select mode'} + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut.ts new file mode 100644 index 00000000000..d71783cd150 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut.ts @@ -0,0 +1,66 @@ +import { type KeyboardEvent, type RefObject, useCallback } from 'react' +import { getConversationModes } from '@/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes' +import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' + +interface UseConversationModeShortcutOptions { + value: ChatRequestMode + searchEnabled?: boolean + onChange?: (mode: ChatRequestMode) => void + textareaRef: RefObject + pickerOpen: boolean +} + +/** Cycles the focused composer's available modes without disturbing its draft selection. */ +export function useConversationModeShortcut({ + value, + searchEnabled = false, + onChange, + textareaRef, + pickerOpen, +}: UseConversationModeShortcutOptions) { + const planEnabled = useFeatureFlag('mothership-plan-mode') + + return useCallback( + (event: KeyboardEvent) => { + const textarea = textareaRef.current + if ( + !onChange || + pickerOpen || + !textarea || + event.target !== textarea || + event.defaultPrevented || + event.key !== 'Tab' || + !event.shiftKey || + event.altKey || + event.ctrlKey || + event.metaKey || + event.nativeEvent.isComposing + ) + return + + const modes = getConversationModes(searchEnabled, planEnabled) + if (modes.length < 2) return + event.preventDefault() + if (event.repeat) return + const nextMode = modes[(modes.findIndex((mode) => mode.value === value) + 1) % modes.length] + const { selectionStart, selectionEnd, selectionDirection } = textarea + onChange(nextMode.value) + + // Search and Build mount different textareas; restore the selection after React commits. + requestAnimationFrame(() => { + const nextTextarea = textareaRef.current + if ( + !nextTextarea || + (document.activeElement !== document.body && + document.activeElement !== textarea && + document.activeElement !== nextTextarea) + ) + return + nextTextarea.focus({ preventScroll: true }) + nextTextarea.setSelectionRange(selectionStart, selectionEnd, selectionDirection) + }) + }, + [value, searchEnabled, planEnabled, onChange, textareaRef, pickerOpen] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index b65a031654f..5fcab6402d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -32,8 +32,10 @@ import { } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { ConversationModeSelector } from '@/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector' import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar' +import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut' import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event' import type { + ChatRequestMode, FileAttachmentForApi, MothershipResource, QueuedMessage, @@ -134,6 +136,15 @@ const UserInputImpl = forwardRef(function UserI const editorRef = useRef(editor) editorRef.current = editor const textareaRef = editor.textareaRef + const handleModeChange = (mode: ChatRequestMode) => { + if (mode === 'agent' || mode === 'plan') onModeChange?.(mode) + } + const handleModeShortcut = useConversationModeShortcut({ + value: requestMode, + onChange: onModeChange ? handleModeChange : undefined, + textareaRef, + pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null, + }) useChatInputFocus({ textareaRef }) /** @@ -552,6 +563,7 @@ const UserInputImpl = forwardRef(function UserI return (
{ composerOwnsFocusRef.current = true }} @@ -623,12 +635,7 @@ const UserInputImpl = forwardRef(function UserI Skills {onModeChange && ( - { - if (mode === 'agent' || mode === 'plan') onModeChange(mode) - }} - /> + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes.ts new file mode 100644 index 00000000000..b95032afc95 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes.ts @@ -0,0 +1,11 @@ +const CONVERSATION_MODES = [ + { value: 'assistant', label: 'Search' }, + { value: 'agent', label: 'Build' }, + { value: 'plan', label: 'Plan' }, +] as const + +export function getConversationModes(searchEnabled: boolean, planEnabled: boolean) { + return CONVERSATION_MODES.filter( + ({ value }) => value === 'agent' || (value === 'assistant' ? searchEnabled : planEnabled) + ) +} From 5d0e6000f6be3e1c234947dc49b32873518a3487 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 1 Oct 2026 01:43:54 -0700 Subject: [PATCH 2/2] fix(chat): skip unavailable Search during mode cycling --- .../components/composer/composer.test.tsx | 32 ++++++++++++++++--- .../home/components/composer/composer.tsx | 11 +++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index a51730ba6ed..72e85c1c61c 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -1,5 +1,6 @@ /** @vitest-environment jsdom */ -import { act, type ComponentProps, useState } from 'react' +import { act, type ComponentProps, useEffect, useState } from 'react' +import { ToastProvider } from '@sim/emcn' import { createMockDeploymentShape, deploymentShapeMock, @@ -257,9 +258,15 @@ it.each([ { searchEnabled: true, planEnabled: true, modes: ['assistant', 'agent', 'plan', 'assistant'] }, { searchEnabled: true, planEnabled: false, modes: ['assistant', 'agent', 'assistant'] }, { searchEnabled: false, planEnabled: true, modes: ['agent', 'plan', 'agent'] }, -] satisfies { searchEnabled: boolean; planEnabled: boolean; modes: ChatRequestMode[] }[])( - 'cycles available modes without losing the draft or selection (Search: $searchEnabled, Plan: $planEnabled)', - async ({ searchEnabled, planEnabled, modes }) => { + { searchEnabled: true, planEnabled: true, withDocument: true, modes: ['agent', 'plan', 'agent'] }, +] satisfies { + searchEnabled: boolean + planEnabled: boolean + withDocument?: boolean + modes: ChatRequestMode[] +}[])( + 'cycles available modes without losing the draft or selection (Search: $searchEnabled, Plan: $planEnabled, document: $withDocument)', + async ({ searchEnabled, planEnabled, modes, withDocument = false }) => { vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }) let currentMode = modes[0] function Harness() { @@ -271,6 +278,22 @@ it.each([ organizationId: 'organization-a', requestMode: mode, }) + const { restoreAttachedFiles } = files + useEffect(() => { + if (withDocument) { + restoreAttachedFiles([ + { + id: 'document-a', + name: 'Draft.pdf', + type: 'application/pdf', + size: 1024, + key: 'sample/draft.pdf', + path: '', + uploading: false, + }, + ]) + } + }, [restoreAttachedFiles]) return ( + ) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 123bd5d6331..03cb140dacb 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -101,12 +101,11 @@ export function Composer({ onPasteFiles: files.processFiles, }) const { textareaRef } = editor + const searchBlocked = + editor.getActiveContexts().length > 0 || + files.attachedFiles.some((file) => !isAssistantImageType(file.type)) const handleModeChange = (mode: ChatRequestMode) => { - if ( - mode === 'assistant' && - (editor.getActiveContexts().length > 0 || - files.attachedFiles.some((file) => !isAssistantImageType(file.type))) - ) { + if (mode === 'assistant' && searchBlocked) { toast.info( 'Remove resource and skill mentions and non-image attachments before switching to Search.' ) @@ -116,7 +115,7 @@ export function Composer({ } const handleModeShortcut = useConversationModeShortcut({ value: requestMode, - searchEnabled, + searchEnabled: searchEnabled && !searchBlocked, onChange: showModeSelector && onModeChange ? handleModeChange : undefined, textareaRef, pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null,