From 35cd039922c3606c3eb0f22d53a383d0e473981a Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 22:41:40 -0400 Subject: [PATCH 1/3] feat(space): replace ThreadedChatComposer with ChatComposer in task view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces TaskSessionChatComposer — a thin wrapper around the rich ChatComposer — for the Space task pane, replacing the minimal ThreadedChatComposer. Adds agent @mention autocomplete support to MessageInput/InputTextarea/ChatComposer so workflow agents can be mentioned directly in the full composer experience. - Add agentMentionCandidates prop + state to MessageInput with @-query detection, keyboard navigation (↑↓/Enter/Esc), and handleAgentMentionSelect - Add showAgentMentionAutocomplete/agentMentionCandidates/etc. props to InputTextarea; render MentionAutocomplete with highest priority over reference and command autocomplete - Add agentMentionCandidates and inputPlaceholder props to ChatComposer and thread them through to MessageInput - Create TaskSessionChatComposer with features disabled, model switcher, placeholder derived from hasTaskAgentSession, and onSend adapter - Update SpaceTaskPane to use TaskSessionChatComposer, removing the wrapping absolute div (ChatComposer owns its own positioning) - Add TaskSessionChatComposer unit tests (11 cases) --- packages/web/src/components/ChatComposer.tsx | 7 + packages/web/src/components/InputTextarea.tsx | 23 ++- packages/web/src/components/MessageInput.tsx | 131 +++++++++++++++++- .../src/components/space/SpaceTaskPane.tsx | 22 ++- .../space/TaskSessionChatComposer.tsx | 87 ++++++++++++ .../TaskSessionChatComposer.test.tsx | 123 ++++++++++++++++ 6 files changed, 373 insertions(+), 20 deletions(-) create mode 100644 packages/web/src/components/space/TaskSessionChatComposer.tsx create mode 100644 packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx diff --git a/packages/web/src/components/ChatComposer.tsx b/packages/web/src/components/ChatComposer.tsx index ebecc8922..f13448632 100644 --- a/packages/web/src/components/ChatComposer.tsx +++ b/packages/web/src/components/ChatComposer.tsx @@ -48,6 +48,9 @@ interface ChatComposerProps { onOpenTools: () => void; onEnterRewindMode: () => void; onExitRewindMode: () => void; + agentMentionCandidates?: Array<{ id: string; name: string }>; + /** Override the default placeholder text in the message input */ + inputPlaceholder?: string; } export function ChatComposer({ @@ -82,6 +85,8 @@ export function ChatComposer({ onOpenTools, onEnterRewindMode, onExitRewindMode, + agentMentionCandidates, + inputPlaceholder, }: 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..1dea39e78 --- /dev/null +++ b/packages/web/src/components/space/TaskSessionChatComposer.tsx @@ -0,0 +1,87 @@ +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; + errorMessage?: string | null; + onSend: (message: string) => Promise; +} + +export function TaskSessionChatComposer({ + sessionId, + mentionCandidates, + hasTaskAgentSession, + canSend, + isSending, + errorMessage, + onSend, +}: TaskSessionChatComposerProps) { + const { + currentModel, + currentModelInfo, + availableModels, + switching: modelSwitching, + loading: modelLoading, + switchModel, + } = useModelSwitcher(sessionId); + + const handleSend = async ( + content: string, + _images?: MessageImage[], + _deliveryMode?: MessageDeliveryMode + ): Promise => { + await onSend(content); + }; + + return ( +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + {}} + onCoordinatorModeChange={() => {}} + onSandboxModeChange={() => {}} + onSend={handleSend} + onOpenTools={() => {}} + onEnterRewindMode={() => {}} + onExitRewindMode={() => {}} + agentMentionCandidates={mentionCandidates} + inputPlaceholder={ + hasTaskAgentSession ? 'Message task agent...' : 'Message task agent (auto-start)...' + } + /> +
+ ); +} 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..bf409f70d --- /dev/null +++ b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx @@ -0,0 +1,123 @@ +// @ts-nocheck +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render } from '@testing-library/preact'; + +// 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: Record = {}; +vi.mock('../../ChatComposer', () => ({ + ChatComposer: (props: Record) => { + 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 = {}; + }); + + 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('shows error message when errorMessage is provided', () => { + const { getByText } = renderComposer({ errorMessage: 'Something went wrong' }); + expect(getByText('Something went wrong')).toBeTruthy(); + }); + + it('does not show error banner when errorMessage is null', () => { + const { queryByText } = renderComposer({ errorMessage: null }); + expect(queryByText('Something went wrong')).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)...'); + }); +}); From 5c66380c3ed6a0bad2ff71d538586fb1fbdc7683 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 22:48:06 -0400 Subject: [PATCH 2/3] fix(space): route error message through ChatComposer in TaskSessionChatComposer ChatComposer renders absolute-positioned, so an external error banner in TaskSessionChatComposer would be hidden behind the footer. Add errorMessage prop to ChatComposer to render the banner inside the footer div, and thread it through TaskSessionChatComposer. --- packages/web/src/components/ChatComposer.tsx | 10 ++++++++++ .../src/components/space/TaskSessionChatComposer.tsx | 6 +----- .../space/__tests__/TaskSessionChatComposer.test.tsx | 12 ++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/ChatComposer.tsx b/packages/web/src/components/ChatComposer.tsx index f13448632..02ed11d54 100644 --- a/packages/web/src/components/ChatComposer.tsx +++ b/packages/web/src/components/ChatComposer.tsx @@ -51,6 +51,8 @@ interface ChatComposerProps { 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({ @@ -87,9 +89,17 @@ export function ChatComposer({ onExitRewindMode, agentMentionCandidates, inputPlaceholder, + errorMessage, }: ChatComposerProps) { return ( ); diff --git a/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx index bf409f70d..b5907938b 100644 --- a/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx +++ b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx @@ -86,14 +86,14 @@ describe('TaskSessionChatComposer', () => { expect(lastChatComposerProps.agentMentionCandidates).toEqual(mentionCandidates); }); - it('shows error message when errorMessage is provided', () => { - const { getByText } = renderComposer({ errorMessage: 'Something went wrong' }); - expect(getByText('Something went wrong')).toBeTruthy(); + it('passes errorMessage to ChatComposer when provided', () => { + renderComposer({ errorMessage: 'Something went wrong' }); + expect(lastChatComposerProps.errorMessage).toBe('Something went wrong'); }); - it('does not show error banner when errorMessage is null', () => { - const { queryByText } = renderComposer({ errorMessage: null }); - expect(queryByText('Something went wrong')).toBeNull(); + it('passes null errorMessage to ChatComposer when not provided', () => { + renderComposer({ errorMessage: null }); + expect(lastChatComposerProps.errorMessage).toBeNull(); }); it('disables input when canSend is false', () => { From 7c21d1d7e01a07f05fb7cdcfb8fbe92b3e1d05a7 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 23:08:42 -0400 Subject: [PATCH 3/3] fix(space): address review feedback on TaskSessionChatComposer - Delete ThreadedChatComposer.tsx and its test (dead code, no longer imported) - Fix draft-loss regression: MessageInput.onSend now accepts boolean|void return; handleSubmit saves content before clearing and restores on false return so task send failures preserve the user's message - Wire isProcessing from SpaceTaskPane.isAgentActive instead of hardcoding false; SessionStatusBar now shows correct processing indicator for task sessions - Remove @ts-nocheck from test file; use typed ChatComposerProps capture and add isProcessing forwarding test --- packages/web/src/components/ChatComposer.tsx | 4 +- packages/web/src/components/MessageInput.tsx | 17 +- .../src/components/space/SpaceTaskPane.tsx | 1 + .../space/TaskSessionChatComposer.tsx | 9 +- .../components/space/ThreadedChatComposer.tsx | 177 ------------------ .../TaskSessionChatComposer.test.tsx | 32 ++-- .../__tests__/ThreadedChatComposer.test.tsx | 143 -------------- 7 files changed, 41 insertions(+), 342 deletions(-) delete mode 100644 packages/web/src/components/space/ThreadedChatComposer.tsx delete mode 100644 packages/web/src/components/space/__tests__/ThreadedChatComposer.test.tsx diff --git a/packages/web/src/components/ChatComposer.tsx b/packages/web/src/components/ChatComposer.tsx index 02ed11d54..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,7 +44,7 @@ interface ChatComposerProps { content: string, images?: MessageImage[], deliveryMode?: MessageDeliveryMode - ) => Promise; + ) => Promise; onOpenTools: () => void; onEnterRewindMode: () => void; onExitRewindMode: () => void; diff --git a/packages/web/src/components/MessageInput.tsx b/packages/web/src/components/MessageInput.tsx index 8e4b0c966..668a01cc6 100644 --- a/packages/web/src/components/MessageInput.tsx +++ b/packages/web/src/components/MessageInput.tsx @@ -70,7 +70,7 @@ interface MessageInputProps { content: string, images?: MessageImage[], deliveryMode?: MessageDeliveryMode - ) => Promise; + ) => Promise; disabled?: boolean; autoScroll?: boolean; onAutoScrollChange?: (autoScroll: boolean) => void; @@ -336,12 +336,20 @@ export default function MessageInput({ const outgoing = extractOutgoingMessage(); if (!outgoing) return; - // Clear UI + // Save content before clearing so we can restore it if the send fails. + const savedContent = outgoing.content; + + // Clear UI optimistically clearDraft(); clearAttachments(); - // Send message with images - await onSend(outgoing.content, outgoing.images, deliveryMode); + // Send message with images; a boolean false return signals failure + const result = await onSend(savedContent, outgoing.images, deliveryMode); + if (result === false) { + // Restore the draft so the user doesn't lose their message + setContent(savedContent); + return; + } if ( agentWorking || deliveryMode === 'defer' || @@ -356,6 +364,7 @@ export default function MessageInput({ extractOutgoingMessage, clearDraft, clearAttachments, + setContent, onSend, agentWorking, queuedForCurrentTurn.length, diff --git a/packages/web/src/components/space/SpaceTaskPane.tsx b/packages/web/src/components/space/SpaceTaskPane.tsx index 39036c872..bddbf7d63 100644 --- a/packages/web/src/components/space/SpaceTaskPane.tsx +++ b/packages/web/src/components/space/SpaceTaskPane.tsx @@ -477,6 +477,7 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps) hasTaskAgentSession={!!agentSessionId} canSend={canSendThreadMessage} isSending={sendingThread} + isProcessing={isAgentActive} errorMessage={threadSendError} onSend={sendThreadMessage} /> diff --git a/packages/web/src/components/space/TaskSessionChatComposer.tsx b/packages/web/src/components/space/TaskSessionChatComposer.tsx index 3fcc7b2c3..e8af431ea 100644 --- a/packages/web/src/components/space/TaskSessionChatComposer.tsx +++ b/packages/web/src/components/space/TaskSessionChatComposer.tsx @@ -8,6 +8,7 @@ interface TaskSessionChatComposerProps { hasTaskAgentSession: boolean; canSend: boolean; isSending: boolean; + isProcessing: boolean; errorMessage?: string | null; onSend: (message: string) => Promise; } @@ -18,6 +19,7 @@ export function TaskSessionChatComposer({ hasTaskAgentSession, canSend, isSending, + isProcessing, errorMessage, onSend, }: TaskSessionChatComposerProps) { @@ -30,12 +32,13 @@ export function TaskSessionChatComposer({ 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 => { - await onSend(content); + ): Promise => { + return onSend(content); }; return ( @@ -43,7 +46,7 @@ export function TaskSessionChatComposer({ 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 index b5907938b..fcc459723 100644 --- a/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx +++ b/packages/web/src/components/space/__tests__/TaskSessionChatComposer.test.tsx @@ -1,6 +1,6 @@ -// @ts-nocheck 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', () => ({ @@ -17,9 +17,9 @@ vi.mock('../../../hooks', () => ({ // 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: Record = {}; +let lastChatComposerProps: ChatComposerProps | null = null; vi.mock('../../ChatComposer', () => ({ - ChatComposer: (props: Record) => { + ChatComposer: (props: ChatComposerProps) => { lastChatComposerProps = props; return (
{ beforeEach(() => { cleanup(); - lastChatComposerProps = {}; + lastChatComposerProps = null; }); afterEach(() => { @@ -78,46 +79,51 @@ describe('TaskSessionChatComposer', () => { it('passes sessionId to ChatComposer', () => { renderComposer({ sessionId: 'my-session' }); - expect(lastChatComposerProps.sessionId).toBe('my-session'); + expect(lastChatComposerProps?.sessionId).toBe('my-session'); }); it('passes agentMentionCandidates to ChatComposer', () => { renderComposer(); - expect(lastChatComposerProps.agentMentionCandidates).toEqual(mentionCandidates); + expect(lastChatComposerProps?.agentMentionCandidates).toEqual(mentionCandidates); }); it('passes errorMessage to ChatComposer when provided', () => { renderComposer({ errorMessage: 'Something went wrong' }); - expect(lastChatComposerProps.errorMessage).toBe('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(); + expect(lastChatComposerProps?.errorMessage).toBeNull(); }); it('disables input when canSend is false', () => { renderComposer({ canSend: false, isSending: false }); - expect(lastChatComposerProps.isWaitingForInput).toBe(true); + expect(lastChatComposerProps?.isWaitingForInput).toBe(true); }); it('disables input when isSending is true', () => { renderComposer({ canSend: true, isSending: true }); - expect(lastChatComposerProps.isWaitingForInput).toBe(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); + 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...'); + 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)...'); + 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(); - }); -});