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
@@ -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,
Expand Down Expand Up @@ -85,6 +86,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'

Expand Down Expand Up @@ -187,6 +189,7 @@ beforeEach(() => {

afterEach(async () => {
await act(async () => root.unmount())
vi.useRealTimers()
container.remove()
queryClient.clear()
})
Expand Down Expand Up @@ -251,6 +254,109 @@ 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'] },
{ 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() {
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,
})
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 (
<Composer
requestMode={mode}
searchEnabled={searchEnabled}
showModeSelector
onModeChange={setMode}
value={value}
onChange={setValue}
files={files}
isInitialView
isSending={false}
onStop={() => {}}
onSubmit={() => {}}
/>
)
}
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'table-row-ttl': false,
'mothership-model-selector': false,
'mothership-plan-mode': planEnabled,
}}
>
<Harness />
<ToastProvider />
</FeatureFlagsProvider>
</QueryClientProvider>
)
)
const input = container.querySelector<HTMLTextAreaElement>('[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<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!.dispatchEvent(event)
})
await act(async () => vi.advanceTimersToNextFrame())
const nextInput = container.querySelector<HTMLTextAreaElement>('[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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -100,6 +101,25 @@ 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' && searchBlocked) {
toast.info(
'Remove resource and skill mentions and non-image attachments before switching to Search.'
)
return
}
onModeChange?.(mode)
}
const handleModeShortcut = useConversationModeShortcut({
value: requestMode,
searchEnabled: searchEnabled && !searchBlocked,
Comment thread
waleedlatif1 marked this conversation as resolved.
onChange: showModeSelector && onModeChange ? handleModeChange : undefined,
textareaRef,
pickerOpen: editor.mentionQuery !== null || editor.slashQuery !== null,
})
const editorRef = useRef(editor)
editorRef.current = editor
const lastPublished = useRef(value)
Expand Down Expand Up @@ -222,23 +242,7 @@ export function Composer({
<ConversationModeSelector
value={requestMode}
searchEnabled={searchEnabled}
onChange={
onModeChange
? (mode) => {
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}
/>
)}
</>
Expand Down Expand Up @@ -276,6 +280,7 @@ export function Composer({

return (
<div
onKeyDown={handleModeShortcut}
onDragEnter={files.handleDragEnter}
onDragLeave={files.handleDragLeave}
onDragOver={files.handleDragOver}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react'
import { ChipDropdown, Tooltip } from '@sim/emcn'
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'

Expand All @@ -19,11 +20,7 @@ export function ConversationModeSelector({
}: ConversationModeSelectorProps) {
const planEnabled = useFeatureFlag('mothership-plan-mode')
const [open, setOpen] = useState(false)
const options = [
...(searchEnabled ? [{ value: 'assistant', label: 'Search' }] : []),
{ value: 'agent', label: 'Build' },
...(planEnabled ? [{ value: 'plan', label: 'Plan' }] : []),
]
const options = getConversationModes(searchEnabled, planEnabled)
if (options.length < 2) return null
return (
<Tooltip.Root>
Expand Down Expand Up @@ -52,7 +49,11 @@ export function ConversationModeSelector({
/>
</span>
</Tooltip.Trigger>
{!open && <Tooltip.Content side='top'>Select mode</Tooltip.Content>}
{!open && (
<Tooltip.Content side='top'>
{onChange ? <Tooltip.Shortcut keys='⇧ Tab'>Select mode</Tooltip.Shortcut> : 'Select mode'}
</Tooltip.Content>
)}
</Tooltip.Root>
)
}
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>
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<HTMLDivElement>) => {
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)
Comment thread
waleedlatif1 marked this conversation as resolved.

// 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]
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -134,6 +136,15 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(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 })

/**
Expand Down Expand Up @@ -552,6 +563,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
return (
<div
onClick={handleContainerClick}
onKeyDown={handleModeShortcut}
onFocusCapture={() => {
composerOwnsFocusRef.current = true
}}
Expand Down Expand Up @@ -623,12 +635,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
<Tooltip.Content side='top'>Skills</Tooltip.Content>
</Tooltip.Root>
{onModeChange && (
<ConversationModeSelector
value={requestMode}
onChange={(mode) => {
if (mode === 'agent' || mode === 'plan') onModeChange(mode)
}}
/>
<ConversationModeSelector value={requestMode} onChange={handleModeChange} />
)}
</>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
)
}
Loading