From d4fc7005d85ffa87806a1d74e26226a62b5a2f79 Mon Sep 17 00:00:00 2001 From: Nuwan Date: Tue, 27 Jan 2026 15:21:21 +0530 Subject: [PATCH] feat(09-01): create JKChatComposer with validation and keyboard handling - Controlled textarea for message input (1-255 chars after trim) - Character count display (X/255) with color-coded feedback - Enter to send, Shift+Enter for newline - Disabled states: disconnected, sending, invalid input - Validation messages for error states - Error display for send failures - React.memo and useCallback optimizations Co-Authored-By: Claude Sonnet 4.5 --- .../components/client/chat/JKChatComposer.js | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 jam-ui/src/components/client/chat/JKChatComposer.js diff --git a/jam-ui/src/components/client/chat/JKChatComposer.js b/jam-ui/src/components/client/chat/JKChatComposer.js new file mode 100644 index 000000000..c0aba25f0 --- /dev/null +++ b/jam-ui/src/components/client/chat/JKChatComposer.js @@ -0,0 +1,206 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { sendMessage, selectSendStatus, selectSendError } from '../../../store/features/sessionChatSlice'; +import { selectSessionId } from '../../../store/features/activeSessionSlice'; +import { useJamServerContext } from '../../../context/JamServerContext'; + +/** + * JKChatComposer - Message composition component + * + * Features: + * - Controlled textarea for message input + * - Character count display (X/255) with color-coded feedback + * - Validation: 1-255 characters after trim + * - Keyboard handling: Enter to send, Shift+Enter for newline + * - Disabled states: disconnected, sending, invalid input + * - Error display for send failures + * - Disconnected warning + * + * Validation levels: + * - Gray: 0-230 characters (normal) + * - Yellow: 231-255 characters (approaching limit) + * - Red: 256+ characters (over limit) + */ +const JKChatComposer = () => { + const dispatch = useDispatch(); + const [inputText, setInputText] = useState(''); + + // Redux selectors + const sessionId = useSelector(selectSessionId); + const sendStatus = useSelector(selectSendStatus); + const sendError = useSelector(selectSendError); + + // WebSocket connection status + const { isConnected } = useJamServerContext(); + + // Validation and state calculations + const trimmedText = useMemo(() => inputText.trim(), [inputText]); + const charCount = inputText.length; + const isOverLimit = charCount > 255; + const isNearLimit = charCount > 230 && charCount <= 255; // 90% threshold + const isValid = trimmedText.length > 0 && trimmedText.length <= 255; + const isSending = sendStatus === 'loading'; + const canSend = isValid && isConnected && !isSending; + + // Character count color + const countColor = isOverLimit ? '#dc3545' : isNearLimit ? '#ffc107' : '#6c757d'; + + // Validation message + const getValidationMessage = useCallback(() => { + if (isOverLimit) { + return `Message is ${charCount - 255} characters too long`; + } + if (!isConnected) { + return 'Waiting for connection...'; + } + if (trimmedText.length === 0 && charCount > 0) { + return 'Message cannot be empty'; + } + return null; + }, [isOverLimit, charCount, isConnected, trimmedText.length]); + + const validationMessage = getValidationMessage(); + + // Event handlers + const handleInputChange = useCallback(e => { + setInputText(e.target.value); + }, []); + + const handleSend = useCallback(async () => { + if (!canSend) return; + + // Send message via Redux thunk + await dispatch( + sendMessage({ + channel: sessionId, + sessionId, + message: trimmedText + }) + ); + + // Clear input on success + setInputText(''); + }, [canSend, dispatch, sessionId, trimmedText]); + + const handleKeyDown = useCallback( + e => { + // Enter without Shift: send message + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // Prevent newline insertion + handleSend(); + } + // Shift+Enter: allow default (insert newline) + }, + [handleSend] + ); + + return ( +
+ {/* Textarea */} +