Skip to content

Commit d9de4bf

Browse files
committed
feat(chat): cycle available composer modes with Shift+Tab
1 parent 76a82b0 commit d9de4bf

6 files changed

Lines changed: 202 additions & 29 deletions

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ vi.mock('@/hooks/use-chat-input-focus', () => ({ useChatInputFocus: vi.fn() }))
8585
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
8686

8787
import { Composer } from '@/app/o/[organizationId]/home/components/composer/composer'
88+
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
8889
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
8990
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
9091

@@ -187,6 +188,7 @@ beforeEach(() => {
187188

188189
afterEach(async () => {
189190
await act(async () => root.unmount())
191+
vi.useRealTimers()
190192
container.remove()
191193
queryClient.clear()
192194
})
@@ -251,6 +253,86 @@ function fileList(files: File[]): FileList {
251253
return Object.assign(files, { item: (index: number) => files[index] ?? null })
252254
}
253255

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

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

Lines changed: 23 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,26 @@ export function Composer({
100101
onPasteFiles: files.processFiles,
101102
})
102103
const { textareaRef } = editor
104+
const handleModeChange = (mode: ChatRequestMode) => {
105+
if (
106+
mode === 'assistant' &&
107+
(editor.getActiveContexts().length > 0 ||
108+
files.attachedFiles.some((file) => !isAssistantImageType(file.type)))
109+
) {
110+
toast.info(
111+
'Remove resource and skill mentions and non-image attachments before switching to Search.'
112+
)
113+
return
114+
}
115+
onModeChange?.(mode)
116+
}
117+
const handleModeShortcut = useConversationModeShortcut({
118+
value: requestMode,
119+
searchEnabled,
120+
onChange: showModeSelector && onModeChange ? handleModeChange : undefined,
121+
textareaRef,
122+
pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null,
123+
})
103124
const editorRef = useRef(editor)
104125
editorRef.current = editor
105126
const lastPublished = useRef(value)
@@ -222,23 +243,7 @@ export function Composer({
222243
<ConversationModeSelector
223244
value={requestMode}
224245
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-
}
246+
onChange={onModeChange ? handleModeChange : undefined}
242247
/>
243248
)}
244249
</>
@@ -276,6 +281,7 @@ export function Composer({
276281

277282
return (
278283
<div
284+
onKeyDown={handleModeShortcut}
279285
onDragEnter={files.handleDragEnter}
280286
onDragLeave={files.handleDragLeave}
281287
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)