Skip to content

Commit 60a1f6f

Browse files
authored
feat(chat): cycle available composer modes with Shift+Tab (#8511)
* feat(chat): cycle available composer modes with Shift+Tab * fix(chat): skip unavailable Search during mode cycling
1 parent 7077210 commit 60a1f6f

6 files changed

Lines changed: 226 additions & 30 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @vitest-environment jsdom */
2-
import { act, type ComponentProps, useState } from 'react'
2+
import { act, type ComponentProps, useEffect, useState } from 'react'
3+
import { ToastProvider } from '@sim/emcn'
34
import {
45
createMockDeploymentShape,
56
deploymentShapeMock,
@@ -85,6 +86,7 @@ vi.mock('@/hooks/use-chat-input-focus', () => ({ useChatInputFocus: vi.fn() }))
8586
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
8687

8788
import { Composer } from '@/app/o/[organizationId]/home/components/composer/composer'
89+
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
8890
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
8991
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
9092

@@ -187,6 +189,7 @@ beforeEach(() => {
187189

188190
afterEach(async () => {
189191
await act(async () => root.unmount())
192+
vi.useRealTimers()
190193
container.remove()
191194
queryClient.clear()
192195
})
@@ -251,6 +254,109 @@ function fileList(files: File[]): FileList {
251254
return Object.assign(files, { item: (index: number) => files[index] ?? null })
252255
}
253256

257+
it.each([
258+
{ searchEnabled: true, planEnabled: true, modes: ['assistant', 'agent', 'plan', 'assistant'] },
259+
{ searchEnabled: true, planEnabled: false, modes: ['assistant', 'agent', 'assistant'] },
260+
{ searchEnabled: false, planEnabled: true, modes: ['agent', 'plan', 'agent'] },
261+
{ searchEnabled: true, planEnabled: true, withDocument: true, modes: ['agent', 'plan', 'agent'] },
262+
] satisfies {
263+
searchEnabled: boolean
264+
planEnabled: boolean
265+
withDocument?: boolean
266+
modes: ChatRequestMode[]
267+
}[])(
268+
'cycles available modes without losing the draft or selection (Search: $searchEnabled, Plan: $planEnabled, document: $withDocument)',
269+
async ({ searchEnabled, planEnabled, modes, withDocument = false }) => {
270+
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] })
271+
let currentMode = modes[0]
272+
function Harness() {
273+
const [mode, setMode] = useState(modes[0])
274+
currentMode = mode
275+
const [value, setValue] = useState('Summarize this draft')
276+
const files = useFileAttachments({
277+
userId: 'user-a',
278+
organizationId: 'organization-a',
279+
requestMode: mode,
280+
})
281+
const { restoreAttachedFiles } = files
282+
useEffect(() => {
283+
if (withDocument) {
284+
restoreAttachedFiles([
285+
{
286+
id: 'document-a',
287+
name: 'Draft.pdf',
288+
type: 'application/pdf',
289+
size: 1024,
290+
key: 'sample/draft.pdf',
291+
path: '',
292+
uploading: false,
293+
},
294+
])
295+
}
296+
}, [restoreAttachedFiles])
297+
return (
298+
<Composer
299+
requestMode={mode}
300+
searchEnabled={searchEnabled}
301+
showModeSelector
302+
onModeChange={setMode}
303+
value={value}
304+
onChange={setValue}
305+
files={files}
306+
isInitialView
307+
isSending={false}
308+
onStop={() => {}}
309+
onSubmit={() => {}}
310+
/>
311+
)
312+
}
313+
await act(async () =>
314+
root.render(
315+
<QueryClientProvider client={queryClient}>
316+
<FeatureFlagsProvider
317+
flags={{
318+
dashboards: false,
319+
'table-row-ttl': false,
320+
'mothership-model-selector': false,
321+
'mothership-plan-mode': planEnabled,
322+
}}
323+
>
324+
<Harness />
325+
<ToastProvider />
326+
</FeatureFlagsProvider>
327+
</QueryClientProvider>
328+
)
329+
)
330+
const input = container.querySelector<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
331+
await act(async () => {
332+
input.focus()
333+
input.setSelectionRange(10, 14, 'backward')
334+
})
335+
for (const expectedMode of modes.slice(1)) {
336+
const event = new KeyboardEvent('keydown', {
337+
key: 'Tab',
338+
shiftKey: true,
339+
bubbles: true,
340+
cancelable: true,
341+
})
342+
await act(async () => {
343+
container.querySelector<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!.dispatchEvent(event)
344+
})
345+
await act(async () => vi.advanceTimersToNextFrame())
346+
const nextInput = container.querySelector<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
347+
expect(currentMode).toBe(expectedMode)
348+
expect(event.defaultPrevented).toBe(true)
349+
expect(document.activeElement).toBe(nextInput)
350+
expect(nextInput.value).toBe('Summarize this draft')
351+
expect([
352+
nextInput.selectionStart,
353+
nextInput.selectionEnd,
354+
nextInput.selectionDirection,
355+
]).toEqual([10, 14, 'backward'])
356+
}
357+
}
358+
)
359+
254360
async function paste(files: File[]) {
255361
const event = new Event('paste', { bubbles: true, cancelable: true })
256362
Object.defineProperty(event, 'clipboardData', {

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
usePromptEditor,
2525
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor'
2626
import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options'
27+
import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut'
2728
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
2829
import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
2930
import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
@@ -100,6 +101,25 @@ export function Composer({
100101
onPasteFiles: files.processFiles,
101102
})
102103
const { textareaRef } = editor
104+
const searchBlocked =
105+
editor.getActiveContexts().length > 0 ||
106+
files.attachedFiles.some((file) => !isAssistantImageType(file.type))
107+
const handleModeChange = (mode: ChatRequestMode) => {
108+
if (mode === 'assistant' && searchBlocked) {
109+
toast.info(
110+
'Remove resource and skill mentions and non-image attachments before switching to Search.'
111+
)
112+
return
113+
}
114+
onModeChange?.(mode)
115+
}
116+
const handleModeShortcut = useConversationModeShortcut({
117+
value: requestMode,
118+
searchEnabled: searchEnabled && !searchBlocked,
119+
onChange: showModeSelector && onModeChange ? handleModeChange : undefined,
120+
textareaRef,
121+
pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null,
122+
})
103123
const editorRef = useRef(editor)
104124
editorRef.current = editor
105125
const lastPublished = useRef(value)
@@ -222,23 +242,7 @@ export function Composer({
222242
<ConversationModeSelector
223243
value={requestMode}
224244
searchEnabled={searchEnabled}
225-
onChange={
226-
onModeChange
227-
? (mode) => {
228-
if (
229-
mode === 'assistant' &&
230-
(editor.getActiveContexts().length > 0 ||
231-
files.attachedFiles.some((file) => !isAssistantImageType(file.type)))
232-
) {
233-
toast.info(
234-
'Remove resource and skill mentions and non-image attachments before switching to Search.'
235-
)
236-
return
237-
}
238-
onModeChange(mode)
239-
}
240-
: undefined
241-
}
245+
onChange={onModeChange ? handleModeChange : undefined}
242246
/>
243247
)}
244248
</>
@@ -276,6 +280,7 @@ export function Composer({
276280

277281
return (
278282
<div
283+
onKeyDown={handleModeShortcut}
279284
onDragEnter={files.handleDragEnter}
280285
onDragLeave={files.handleDragLeave}
281286
onDragOver={files.handleDragOver}

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import { ChipDropdown, Tooltip } from '@sim/emcn'
5+
import { getConversationModes } from '@/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes'
56
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
67
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
78

@@ -19,11 +20,7 @@ export function ConversationModeSelector({
1920
}: ConversationModeSelectorProps) {
2021
const planEnabled = useFeatureFlag('mothership-plan-mode')
2122
const [open, setOpen] = useState(false)
22-
const options = [
23-
...(searchEnabled ? [{ value: 'assistant', label: 'Search' }] : []),
24-
{ value: 'agent', label: 'Build' },
25-
...(planEnabled ? [{ value: 'plan', label: 'Plan' }] : []),
26-
]
23+
const options = getConversationModes(searchEnabled, planEnabled)
2724
if (options.length < 2) return null
2825
return (
2926
<Tooltip.Root>
@@ -52,7 +49,11 @@ export function ConversationModeSelector({
5249
/>
5350
</span>
5451
</Tooltip.Trigger>
55-
{!open && <Tooltip.Content side='top'>Select mode</Tooltip.Content>}
52+
{!open && (
53+
<Tooltip.Content side='top'>
54+
{onChange ? <Tooltip.Shortcut keys='⇧ Tab'>Select mode</Tooltip.Shortcut> : 'Select mode'}
55+
</Tooltip.Content>
56+
)}
5657
</Tooltip.Root>
5758
)
5859
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { type KeyboardEvent, type RefObject, useCallback } from 'react'
2+
import { getConversationModes } from '@/app/workspace/[workspaceId]/home/components/user-input/utils/conversation-modes'
3+
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
4+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
5+
6+
interface UseConversationModeShortcutOptions {
7+
value: ChatRequestMode
8+
searchEnabled?: boolean
9+
onChange?: (mode: ChatRequestMode) => void
10+
textareaRef: RefObject<HTMLTextAreaElement | null>
11+
pickerOpen: boolean
12+
}
13+
14+
/** Cycles the focused composer's available modes without disturbing its draft selection. */
15+
export function useConversationModeShortcut({
16+
value,
17+
searchEnabled = false,
18+
onChange,
19+
textareaRef,
20+
pickerOpen,
21+
}: UseConversationModeShortcutOptions) {
22+
const planEnabled = useFeatureFlag('mothership-plan-mode')
23+
24+
return useCallback(
25+
(event: KeyboardEvent<HTMLDivElement>) => {
26+
const textarea = textareaRef.current
27+
if (
28+
!onChange ||
29+
pickerOpen ||
30+
!textarea ||
31+
event.target !== textarea ||
32+
event.defaultPrevented ||
33+
event.key !== 'Tab' ||
34+
!event.shiftKey ||
35+
event.altKey ||
36+
event.ctrlKey ||
37+
event.metaKey ||
38+
event.nativeEvent.isComposing
39+
)
40+
return
41+
42+
const modes = getConversationModes(searchEnabled, planEnabled)
43+
if (modes.length < 2) return
44+
event.preventDefault()
45+
if (event.repeat) return
46+
const nextMode = modes[(modes.findIndex((mode) => mode.value === value) + 1) % modes.length]
47+
const { selectionStart, selectionEnd, selectionDirection } = textarea
48+
onChange(nextMode.value)
49+
50+
// Search and Build mount different textareas; restore the selection after React commits.
51+
requestAnimationFrame(() => {
52+
const nextTextarea = textareaRef.current
53+
if (
54+
!nextTextarea ||
55+
(document.activeElement !== document.body &&
56+
document.activeElement !== textarea &&
57+
document.activeElement !== nextTextarea)
58+
)
59+
return
60+
nextTextarea.focus({ preventScroll: true })
61+
nextTextarea.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
62+
})
63+
},
64+
[value, searchEnabled, planEnabled, onChange, textareaRef, pickerOpen]
65+
)
66+
}

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import {
3232
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
3333
import { ConversationModeSelector } from '@/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector'
3434
import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user-input/components/input-toolbar'
35+
import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut'
3536
import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event'
3637
import type {
38+
ChatRequestMode,
3739
FileAttachmentForApi,
3840
MothershipResource,
3941
QueuedMessage,
@@ -134,6 +136,15 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
134136
const editorRef = useRef(editor)
135137
editorRef.current = editor
136138
const textareaRef = editor.textareaRef
139+
const handleModeChange = (mode: ChatRequestMode) => {
140+
if (mode === 'agent' || mode === 'plan') onModeChange?.(mode)
141+
}
142+
const handleModeShortcut = useConversationModeShortcut({
143+
value: requestMode,
144+
onChange: onModeChange ? handleModeChange : undefined,
145+
textareaRef,
146+
pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null,
147+
})
137148
useChatInputFocus({ textareaRef })
138149

139150
/**
@@ -552,6 +563,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
552563
return (
553564
<div
554565
onClick={handleContainerClick}
566+
onKeyDown={handleModeShortcut}
555567
onFocusCapture={() => {
556568
composerOwnsFocusRef.current = true
557569
}}
@@ -623,12 +635,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
623635
<Tooltip.Content side='top'>Skills</Tooltip.Content>
624636
</Tooltip.Root>
625637
{onModeChange && (
626-
<ConversationModeSelector
627-
value={requestMode}
628-
onChange={(mode) => {
629-
if (mode === 'agent' || mode === 'plan') onModeChange(mode)
630-
}}
631-
/>
638+
<ConversationModeSelector value={requestMode} onChange={handleModeChange} />
632639
)}
633640
</>
634641
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const CONVERSATION_MODES = [
2+
{ value: 'assistant', label: 'Search' },
3+
{ value: 'agent', label: 'Build' },
4+
{ value: 'plan', label: 'Plan' },
5+
] as const
6+
7+
export function getConversationModes(searchEnabled: boolean, planEnabled: boolean) {
8+
return CONVERSATION_MODES.filter(
9+
({ value }) => value === 'agent' || (value === 'assistant' ? searchEnabled : planEnabled)
10+
)
11+
}

0 commit comments

Comments
 (0)