diff --git a/packages/web/src/components/ChatComposer.tsx b/packages/web/src/components/ChatComposer.tsx index ebecc8922..4168a20f7 100644 --- a/packages/web/src/components/ChatComposer.tsx +++ b/packages/web/src/components/ChatComposer.tsx @@ -12,7 +12,7 @@ import SessionStatusBar from './SessionStatusBar.tsx'; import { borderColors } from '../lib/design-tokens.ts'; import { cn } from '../lib/utils.ts'; -interface ChatComposerProps { +export interface ChatComposerProps { sessionId: string; readonly: boolean; sessionStatus?: string; @@ -44,10 +44,15 @@ interface ChatComposerProps { content: string, images?: MessageImage[], deliveryMode?: MessageDeliveryMode - ) => Promise; + ) => Promise; onOpenTools: () => void; onEnterRewindMode: () => void; onExitRewindMode: () => void; + agentMentionCandidates?: Array<{ id: string; name: string }>; + /** Override the default placeholder text in the message input */ + inputPlaceholder?: string; + /** Optional inline error message rendered above the status bar (used by task sessions) */ + errorMessage?: string | null; } export function ChatComposer({ @@ -82,9 +87,19 @@ export function ChatComposer({ onOpenTools, onEnterRewindMode, onExitRewindMode, + agentMentionCandidates, + inputPlaceholder, + errorMessage, }: ChatComposerProps) { return ( )} diff --git a/packages/web/src/components/space/TaskSessionChatComposer.tsx b/packages/web/src/components/space/TaskSessionChatComposer.tsx new file mode 100644 index 000000000..e8af431ea --- /dev/null +++ b/packages/web/src/components/space/TaskSessionChatComposer.tsx @@ -0,0 +1,86 @@ +import type { MessageDeliveryMode, MessageImage } from '@neokai/shared'; +import { ChatComposer } from '../ChatComposer.tsx'; +import { useModelSwitcher } from '../../hooks'; + +interface TaskSessionChatComposerProps { + sessionId: string; + mentionCandidates: Array<{ id: string; name: string }>; + hasTaskAgentSession: boolean; + canSend: boolean; + isSending: boolean; + isProcessing: boolean; + errorMessage?: string | null; + onSend: (message: string) => Promise; +} + +export function TaskSessionChatComposer({ + sessionId, + mentionCandidates, + hasTaskAgentSession, + canSend, + isSending, + isProcessing, + errorMessage, + onSend, +}: TaskSessionChatComposerProps) { + const { + currentModel, + currentModelInfo, + availableModels, + switching: modelSwitching, + loading: modelLoading, + switchModel, + } = useModelSwitcher(sessionId); + + // Return the boolean so MessageInput can restore the draft when sending fails + const handleSend = async ( + content: string, + _images?: MessageImage[], + _deliveryMode?: MessageDeliveryMode + ): Promise => { + return onSend(content); + }; + + return ( +
+ {}} + onCoordinatorModeChange={() => {}} + onSandboxModeChange={() => {}} + onSend={handleSend} + onOpenTools={() => {}} + onEnterRewindMode={() => {}} + onExitRewindMode={() => {}} + agentMentionCandidates={mentionCandidates} + inputPlaceholder={ + hasTaskAgentSession ? 'Message task agent...' : 'Message task agent (auto-start)...' + } + errorMessage={errorMessage} + /> +
+ ); +} diff --git a/packages/web/src/components/space/ThreadedChatComposer.tsx b/packages/web/src/components/space/ThreadedChatComposer.tsx deleted file mode 100644 index a29cec828..000000000 --- a/packages/web/src/components/space/ThreadedChatComposer.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'preact/hooks'; -import { InputTextarea } from '../InputTextarea'; -import { isAgentWorking } from '../../lib/state.ts'; -import { useInterrupt } from '../../hooks'; -import MentionAutocomplete from './MentionAutocomplete'; - -interface MentionAgent { - id: string; - name: string; -} - -interface ThreadedChatComposerProps { - taskSessionId: string; - mentionCandidates: MentionAgent[]; - hasTaskAgentSession: boolean; - canSend: boolean; - isSending: boolean; - errorMessage?: string | null; - onSend: (message: string) => Promise; -} - -export function ThreadedChatComposer({ - taskSessionId, - mentionCandidates, - hasTaskAgentSession, - canSend, - isSending, - errorMessage, - onSend, -}: ThreadedChatComposerProps) { - const [draft, setDraft] = useState(''); - const [mentionQuery, setMentionQuery] = useState(null); - const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0); - const textareaRef = useRef(null); - const lastCursorRef = useRef(0); - const agentWorking = isAgentWorking.value; - const { handleInterrupt } = useInterrupt({ sessionId: taskSessionId }); - - const mentionAgents = useMemo(() => { - if (mentionQuery === null) return []; - return mentionCandidates.filter((agent) => - agent.name.toLowerCase().startsWith(mentionQuery.toLowerCase()) - ); - }, [mentionCandidates, mentionQuery]); - - const handleDraftChange = useCallback((value: string) => { - const cursor = textareaRef.current?.selectionStart ?? value.length; - lastCursorRef.current = cursor; - setDraft(value); - - const textBeforeCursor = value.slice(0, cursor); - const match = textBeforeCursor.match(/@(\w*)$/); - if (match) { - setMentionQuery(match[1]); - setMentionSelectedIndex(0); - } else { - setMentionQuery(null); - } - }, []); - - const handleMentionSelect = useCallback( - (name: string) => { - if (!textareaRef.current) return; - const textarea = textareaRef.current; - const cursor = textarea.selectionStart ?? lastCursorRef.current; - const textBeforeCursor = draft.slice(0, cursor); - const textAfterCursor = draft.slice(cursor); - const match = textBeforeCursor.match(/@(\w*)$/); - if (!match) return; - const start = cursor - match[0].length; - const newValue = draft.slice(0, start) + '@' + name + ' ' + textAfterCursor; - setDraft(newValue); - setMentionQuery(null); - setMentionSelectedIndex(0); - setTimeout(() => { - if (textareaRef.current) { - const newCursor = start + name.length + 2; - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(newCursor, newCursor); - } - }, 0); - }, - [draft] - ); - - const handleMentionClose = useCallback(() => { - setMentionQuery(null); - setMentionSelectedIndex(0); - }, []); - - const submitDraft = useCallback(async () => { - if (!canSend) return; - const nextMessage = draft.trim(); - if (!nextMessage) return; - const sent = await onSend(nextMessage); - if (sent) { - setDraft(''); - setMentionQuery(null); - setMentionSelectedIndex(0); - } - }, [canSend, draft, onSend]); - - const handleFormSubmit = useCallback( - (e: Event) => { - e.preventDefault(); - void submitDraft(); - }, - [submitDraft] - ); - - return ( -
- {errorMessage && ( -

- {errorMessage} -

- )} -
-
- {mentionQuery !== null && mentionAgents.length > 0 && ( - - )} - { - if (mentionQuery !== null && mentionAgents.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setMentionSelectedIndex((i) => Math.min(i + 1, mentionAgents.length - 1)); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setMentionSelectedIndex((i) => Math.max(i - 1, 0)); - return; - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (mentionAgents[mentionSelectedIndex]) { - handleMentionSelect(mentionAgents[mentionSelectedIndex].name); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - handleMentionClose(); - return; - } - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - void submitDraft(); - } - }} - onSubmit={() => { - void submitDraft(); - }} - disabled={isSending} - placeholder={ - hasTaskAgentSession ? 'Message task agent...' : 'Message task agent (auto-start)...' - } - textareaRef={textareaRef} - transparent={true} - isAgentWorking={agentWorking} - onStop={handleInterrupt} - /> -
-
-
- ); -} diff --git a/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx new file mode 100644 index 000000000..fcc459723 --- /dev/null +++ b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render } from '@testing-library/preact'; +import type { ChatComposerProps } from '../../ChatComposer'; + +// Mock useModelSwitcher — avoids real WebSocket/RPC calls in unit tests +vi.mock('../../../hooks', () => ({ + useModelSwitcher: () => ({ + currentModel: 'claude-sonnet-4-6', + currentModelInfo: null, + availableModels: [], + switching: false, + loading: false, + switchModel: vi.fn(async () => {}), + }), +})); + +// Mock ChatComposer — it depends on MessageInput, SessionStatusBar, and other +// components that require WebSocket connections and complex browser APIs. +// Capture the props passed to it so tests can inspect them. +let lastChatComposerProps: ChatComposerProps | null = null; +vi.mock('../../ChatComposer', () => ({ + ChatComposer: (props: ChatComposerProps) => { + lastChatComposerProps = props; + return ( +
+ ); + }, +})); + +import { TaskSessionChatComposer } from '../TaskSessionChatComposer'; + +const mentionCandidates = [ + { id: 'a1', name: 'Coder' }, + { id: 'a2', name: 'Reviewer' }, +]; + +function renderComposer(overrides: Partial[0]> = {}) { + const onSend = vi.fn().mockResolvedValue(true); + const view = render( + + ); + return { ...view, onSend }; +} + +describe('TaskSessionChatComposer', () => { + beforeEach(() => { + cleanup(); + lastChatComposerProps = null; + }); + + afterEach(() => { + cleanup(); + }); + + it('renders the wrapper with correct data-testid', () => { + const { getByTestId } = renderComposer(); + expect(getByTestId('task-session-chat-composer')).toBeTruthy(); + }); + + it('renders the inner ChatComposer', () => { + const { getByTestId } = renderComposer(); + expect(getByTestId('mock-chat-composer')).toBeTruthy(); + }); + + it('passes sessionId to ChatComposer', () => { + renderComposer({ sessionId: 'my-session' }); + expect(lastChatComposerProps?.sessionId).toBe('my-session'); + }); + + it('passes agentMentionCandidates to ChatComposer', () => { + renderComposer(); + expect(lastChatComposerProps?.agentMentionCandidates).toEqual(mentionCandidates); + }); + + it('passes errorMessage to ChatComposer when provided', () => { + renderComposer({ errorMessage: 'Something went wrong' }); + expect(lastChatComposerProps?.errorMessage).toBe('Something went wrong'); + }); + + it('passes null errorMessage to ChatComposer when not provided', () => { + renderComposer({ errorMessage: null }); + expect(lastChatComposerProps?.errorMessage).toBeNull(); + }); + + it('disables input when canSend is false', () => { + renderComposer({ canSend: false, isSending: false }); + expect(lastChatComposerProps?.isWaitingForInput).toBe(true); + }); + + it('disables input when isSending is true', () => { + renderComposer({ canSend: true, isSending: true }); + expect(lastChatComposerProps?.isWaitingForInput).toBe(true); + }); + + it('enables input when canSend is true and not sending', () => { + renderComposer({ canSend: true, isSending: false }); + expect(lastChatComposerProps?.isWaitingForInput).toBe(false); + }); + + it('uses task agent session placeholder when hasTaskAgentSession is true', () => { + renderComposer({ hasTaskAgentSession: true }); + expect(lastChatComposerProps?.inputPlaceholder).toBe('Message task agent...'); + }); + + it('uses auto-start placeholder when hasTaskAgentSession is false', () => { + renderComposer({ hasTaskAgentSession: false }); + expect(lastChatComposerProps?.inputPlaceholder).toBe('Message task agent (auto-start)...'); + }); + + it('forwards isProcessing to ChatComposer', () => { + renderComposer({ isProcessing: true }); + expect(lastChatComposerProps?.isProcessing).toBe(true); + }); +}); diff --git a/packages/web/src/components/space/__tests__/ThreadedChatComposer.test.tsx b/packages/web/src/components/space/__tests__/ThreadedChatComposer.test.tsx deleted file mode 100644 index 3134ab024..000000000 --- a/packages/web/src/components/space/__tests__/ThreadedChatComposer.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -// @ts-nocheck -import { signal } from '@preact/signals'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact'; - -const mockAgentWorking = signal(false); -const mockHandleInterrupt = vi.fn(async () => {}); - -vi.mock('../../../lib/state.ts', () => ({ - get isAgentWorking() { - return { - get value() { - return mockAgentWorking.value; - }, - }; - }, -})); - -vi.mock('../../../hooks', () => ({ - useInterrupt: () => ({ - interrupting: false, - handleInterrupt: mockHandleInterrupt, - }), -})); - -import { ThreadedChatComposer } from '../ThreadedChatComposer'; - -const mentionCandidates = [ - { id: 'a1', name: 'Coder' }, - { id: 'a2', name: 'Reviewer' }, -]; - -function renderComposer(overrides: Partial[0]> = {}) { - const onSend = vi.fn().mockResolvedValue(true); - const view = render( - - ); - return { ...view, onSend }; -} - -function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { - Object.defineProperty(textarea, 'selectionStart', { - get: () => value.length, - configurable: true, - }); - fireEvent.input(textarea, { target: { value } }); -} - -describe('ThreadedChatComposer', () => { - beforeEach(() => { - cleanup(); - mockAgentWorking.value = false; - mockHandleInterrupt.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders MessageInput-style send button and session placeholder', () => { - const { getByPlaceholderText, getByTestId } = renderComposer(); - expect(getByPlaceholderText('Message task agent...')).toBeTruthy(); - expect(getByTestId('send-button')).toBeTruthy(); - }); - - it('renders auto-ensure placeholder when no task session exists', () => { - const { getByPlaceholderText } = renderComposer({ hasTaskAgentSession: false }); - expect(getByPlaceholderText('Message task agent (auto-start)...')).toBeTruthy(); - }); - - it('submits with send button and clears draft on success', async () => { - const { getByPlaceholderText, getByTestId, onSend } = renderComposer(); - const textarea = getByPlaceholderText('Message task agent...') as HTMLTextAreaElement; - - setTextareaValue(textarea, 'Ship it'); - fireEvent.click(getByTestId('send-button')); - - await waitFor(() => expect(onSend).toHaveBeenCalledWith('Ship it')); - await waitFor(() => expect(textarea.value).toBe('')); - }); - - it('does not clear draft when send returns false', async () => { - const onSend = vi.fn().mockResolvedValue(false); - const { getByPlaceholderText, getByTestId } = renderComposer({ onSend }); - const textarea = getByPlaceholderText('Message task agent...') as HTMLTextAreaElement; - - setTextareaValue(textarea, 'Needs retry'); - fireEvent.click(getByTestId('send-button')); - - await waitFor(() => expect(onSend).toHaveBeenCalledWith('Needs retry')); - expect(textarea.value).toBe('Needs retry'); - }); - - it('uses Enter to select mention when mention list is open', async () => { - const { getByPlaceholderText, getByTestId, queryByTestId, onSend } = renderComposer(); - const textarea = getByPlaceholderText('Message task agent...') as HTMLTextAreaElement; - - setTextareaValue(textarea, '@Co'); - await waitFor(() => expect(getByTestId('mention-autocomplete')).toBeTruthy()); - - fireEvent.keyDown(textarea, { key: 'Enter' }); - await waitFor(() => expect(queryByTestId('mention-autocomplete')).toBeNull()); - expect(textarea.value).toContain('@Coder'); - expect(onSend).not.toHaveBeenCalled(); - }); - - it('does not submit on Shift+Enter', () => { - const { getByPlaceholderText, onSend } = renderComposer(); - const textarea = getByPlaceholderText('Message task agent...'); - - setTextareaValue(textarea as HTMLTextAreaElement, 'line one'); - fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); - expect(onSend).not.toHaveBeenCalled(); - }); - - it('should show stop button when agent is working and textarea is empty', () => { - mockAgentWorking.value = true; - const { getByTestId, queryByTestId } = renderComposer(); - - expect(getByTestId('stop-button')).toBeTruthy(); - expect(queryByTestId('send-button')).toBeNull(); - }); - - it('should show send button when agent is working but has content', () => { - mockAgentWorking.value = true; - const { getByPlaceholderText, getByTestId, queryByTestId } = renderComposer(); - const textarea = getByPlaceholderText('Message task agent...') as HTMLTextAreaElement; - - setTextareaValue(textarea, 'Queued follow-up'); - - expect(getByTestId('send-button')).toBeTruthy(); - expect(queryByTestId('stop-button')).toBeNull(); - }); -});