docs(09): create phase plan for message composition
Phase 9: Message Composition & Sending - 2 plans created - 6 total tasks defined - Ready for execution Plan 09-01: Message Composer UI & Validation (3 tasks) - Create JKChatComposer component - Add keyboard handling (Enter/Shift+Enter) - Add character count validation Plan 09-02: Send Message Integration & Real-Time Delivery (3 tasks) - Integrate composer into chat window - Write send message integration tests - Write receive message integration tests
This commit is contained in:
parent
c4acebcdeb
commit
619e37f505
|
|
@ -0,0 +1,418 @@
|
|||
---
|
||||
phase: 09-message-composition
|
||||
plan: 01
|
||||
type: standard
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create message composer component with textarea, send button, validation, and keyboard handling.
|
||||
|
||||
Purpose: Enable users to type and submit chat messages with proper validation and UX patterns (Enter to send, character limits).
|
||||
Output: JKChatComposer component with validation, keyboard handling, and character count display.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-phase.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
|
||||
# Phase 6 design documents
|
||||
@.planning/phases/06-session-chat-research-design/CHAT_COMPONENT_DESIGN.md
|
||||
@.planning/phases/06-session-chat-research-design/IMPLEMENTATION_ROADMAP.md
|
||||
|
||||
# Phase 8 summaries (window and message list)
|
||||
@.planning/phases/08-chat-window-ui/08-01-SUMMARY.md
|
||||
@.planning/phases/08-chat-window-ui/08-02-SUMMARY.md
|
||||
@.planning/phases/08-chat-window-ui/08-03-SUMMARY.md
|
||||
|
||||
# Redux state management (has sendMessage thunk)
|
||||
@jam-ui/src/store/features/sessionChatSlice.js
|
||||
|
||||
# Integration point
|
||||
@jam-ui/src/components/client/JKSessionChatWindow.js
|
||||
|
||||
# Existing chat components for reference
|
||||
@jam-ui/src/components/client/chat/JKChatMessage.js
|
||||
|
||||
# Codebase conventions
|
||||
@.planning/codebase/CONVENTIONS.md
|
||||
@.planning/codebase/CHAT_REACT_PATTERNS.md
|
||||
@CLAUDE.md
|
||||
|
||||
**Key context from Phase 6 design:**
|
||||
- Composer component: Textarea for message input + Send button
|
||||
- Keyboard handling: Enter to send, Shift+Enter for newline
|
||||
- Validation: 1-255 characters, trim whitespace, non-empty after trim
|
||||
- Character count: Display "X/255" below textarea
|
||||
- Disable send when: empty message, over 255 chars, or disconnected
|
||||
- Connection check: Use isConnected from JamServerContext (useJamServerContext hook)
|
||||
|
||||
**Key context from Phase 7:**
|
||||
- sendMessage thunk available: accepts { channel, sessionId, message }
|
||||
- optimistic updates: message added immediately with pending flag
|
||||
- Redux selectors: selectSendStatus, selectSendError
|
||||
|
||||
**Key context from Phase 8:**
|
||||
- JKSessionChatWindow exists with WindowPortal
|
||||
- Message list already integrated
|
||||
- Window dimensions: 450×600px
|
||||
- Inline styles used for MVP (SCSS deferred)
|
||||
|
||||
**Established patterns:**
|
||||
- React.memo for performance optimization
|
||||
- useCallback for event handlers
|
||||
- Controlled components for form inputs
|
||||
- Redux for state, local state for ephemeral UI (input text)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create JKChatComposer component with textarea and send button</name>
|
||||
<files>jam-ui/src/components/client/chat/JKChatComposer.js</files>
|
||||
<action>
|
||||
Create message composer component in chat subdirectory:
|
||||
|
||||
**Component structure:**
|
||||
```javascript
|
||||
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';
|
||||
|
||||
const JKChatComposer = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [inputText, setInputText] = useState('');
|
||||
|
||||
const sessionId = useSelector(selectSessionId);
|
||||
const sendStatus = useSelector(selectSendStatus);
|
||||
const sendError = useSelector(selectSendError);
|
||||
const { server } = useJamServerContext();
|
||||
const isConnected = server?.isConnected ?? false;
|
||||
|
||||
// Character count and validation
|
||||
const trimmedText = inputText.trim();
|
||||
const charCount = inputText.length;
|
||||
const isValid = trimmedText.length > 0 && trimmedText.length <= 255;
|
||||
const isSending = sendStatus === 'loading';
|
||||
const canSend = isValid && isConnected && !isSending;
|
||||
|
||||
const handleInputChange = useCallback((e) => {
|
||||
setInputText(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!canSend) return;
|
||||
|
||||
// Send message
|
||||
await dispatch(sendMessage({
|
||||
channel: sessionId,
|
||||
sessionId,
|
||||
message: trimmedText
|
||||
}));
|
||||
|
||||
// Clear input on success
|
||||
setInputText('');
|
||||
}, [canSend, dispatch, sessionId, trimmedText]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
disabled={!isConnected || isSending}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: charCount > 255 ? '#dc3545' : '#6c757d'
|
||||
}}>
|
||||
{charCount}/255
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: canSend ? '#007bff' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: canSend ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{isSending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{sendError && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
Failed to send message. Please try again.
|
||||
</div>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#fff3cd',
|
||||
color: '#856404',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
Disconnected. Reconnecting...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(JKChatComposer);
|
||||
```
|
||||
|
||||
**Key features:**
|
||||
- Local state for inputText (ephemeral, no need for Redux)
|
||||
- Character count display with red color when > 255
|
||||
- Disabled states: not connected, sending, invalid input
|
||||
- Error display for send failures
|
||||
- Disconnected warning
|
||||
- React.memo for performance
|
||||
- useCallback for handlers to prevent re-renders
|
||||
|
||||
**Validation logic:**
|
||||
- Trim whitespace before checking length
|
||||
- Must be 1-255 characters after trim
|
||||
- Button disabled when invalid or disconnected or sending
|
||||
|
||||
**Why local state for inputText:**
|
||||
Per Phase 6 design, input text is ephemeral UI state that doesn't need persistence across re-renders or sharing with other components.
|
||||
</action>
|
||||
<verify>
|
||||
JKChatComposer component created. Component renders textarea, send button, character count. Validation logic prevents sending empty/long messages.
|
||||
</verify>
|
||||
<done>
|
||||
Composer component exists with all UI elements and validation logic.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add keyboard handling for Enter and Shift+Enter</name>
|
||||
<files>jam-ui/src/components/client/chat/JKChatComposer.js</files>
|
||||
<action>
|
||||
Add keyboard event handler to textarea for Enter-to-send pattern:
|
||||
|
||||
**Add to component:**
|
||||
```javascript
|
||||
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]);
|
||||
```
|
||||
|
||||
**Update textarea:**
|
||||
```jsx
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown} // Add this
|
||||
placeholder="Type a message..."
|
||||
// ... rest of props
|
||||
/>
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Enter key: Calls handleSend() if canSend is true, prevents default newline
|
||||
- Shift+Enter: Allows default behavior (inserts newline)
|
||||
- If send disabled (invalid/disconnected/sending): Enter does nothing
|
||||
|
||||
**Why preventDefault on Enter:**
|
||||
Without preventDefault, pressing Enter would insert a newline AND send the message, leaving a blank line in the textarea.
|
||||
|
||||
**Testing consideration:**
|
||||
This keyboard behavior should be tested in integration tests (Plan 09-02) with Playwright's keyboard simulation.
|
||||
</action>
|
||||
<verify>
|
||||
Keyboard handling added. Press Enter sends message (if valid). Press Shift+Enter inserts newline. No PropTypes warnings.
|
||||
</verify>
|
||||
<done>
|
||||
Enter key sends message, Shift+Enter creates new line. Keyboard UX complete.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add character count validation feedback</name>
|
||||
<files>jam-ui/src/components/client/chat/JKChatComposer.js</files>
|
||||
<action>
|
||||
Enhance character count display with visual feedback and validation messages:
|
||||
|
||||
**Update character count section:**
|
||||
```javascript
|
||||
// Calculate validation state
|
||||
const charCount = inputText.length;
|
||||
const isOverLimit = charCount > 255;
|
||||
const isNearLimit = charCount > 230 && charCount <= 255; // 90% threshold
|
||||
|
||||
// Character count color
|
||||
const countColor = isOverLimit ? '#dc3545' : isNearLimit ? '#ffc107' : '#6c757d';
|
||||
|
||||
// Validation message
|
||||
const getValidationMessage = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
const validationMessage = getValidationMessage();
|
||||
```
|
||||
|
||||
**Update character count display:**
|
||||
```jsx
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: countColor,
|
||||
fontWeight: isOverLimit || isNearLimit ? 'bold' : 'normal'
|
||||
}}>
|
||||
{charCount}/255
|
||||
</span>
|
||||
{validationMessage && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isOverLimit ? '#dc3545' : '#ffc107',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{validationMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSend} disabled={!canSend}>
|
||||
{/* button content */}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Validation feedback levels:**
|
||||
- Gray (default): 0-230 characters
|
||||
- Yellow/warning: 231-255 characters (approaching limit)
|
||||
- Red/error: 256+ characters (over limit)
|
||||
|
||||
**Validation messages:**
|
||||
- Over limit: "Message is X characters too long"
|
||||
- Empty after trim: "Message cannot be empty"
|
||||
- Disconnected: "Waiting for connection..."
|
||||
|
||||
**Why near-limit warning at 230:**
|
||||
Gives user advance notice before hitting hard limit, better UX than sudden red at 256.
|
||||
</action>
|
||||
<verify>
|
||||
Character count shows color-coded feedback. Validation messages appear for over-limit, empty, and disconnected states. Visual feedback enhances UX.
|
||||
</verify>
|
||||
<done>
|
||||
Character count with visual feedback complete. Validation messages guide user input.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] JKChatComposer component renders with textarea and send button
|
||||
- [ ] Character count displays correctly (X/255)
|
||||
- [ ] Send button disabled when message invalid/disconnected/sending
|
||||
- [ ] Enter key sends message, Shift+Enter creates newline
|
||||
- [ ] Character count color changes based on length (gray/yellow/red)
|
||||
- [ ] Validation messages appear for error states
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Component exports properly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
|
||||
- JKChatComposer component created with full validation
|
||||
- Keyboard handling works (Enter/Shift+Enter)
|
||||
- Character count display with color-coded feedback
|
||||
- Validation messages for all error states
|
||||
- Disabled states work correctly (disconnected, sending, invalid)
|
||||
- Component ready for integration in Plan 09-02
|
||||
- No PropTypes warnings or console errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-message-composition/09-01-SUMMARY.md`:
|
||||
|
||||
# Phase 9 Plan 1: Message Composer UI & Validation Summary
|
||||
|
||||
**Created message composer with textarea, keyboard handling (Enter/Shift+Enter), character validation (1-255), and visual feedback**
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- JKChatComposer component with controlled textarea
|
||||
- Enter to send, Shift+Enter for newline
|
||||
- Character count display (X/255) with color-coded feedback
|
||||
- Validation: empty check, 255-char limit, trim whitespace
|
||||
- Disabled states: disconnected, sending, invalid input
|
||||
- Error display for send failures
|
||||
- Visual feedback for validation states
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `jam-ui/src/components/client/chat/JKChatComposer.js` - Composer component (NEW)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
[Document any implementation decisions]
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
[Problems and resolutions, or "None"]
|
||||
|
||||
## Next Step
|
||||
|
||||
Ready for 09-02-PLAN.md (Send Message Integration & Real-Time Delivery)
|
||||
</output>
|
||||
|
|
@ -0,0 +1,599 @@
|
|||
---
|
||||
phase: 09-message-composition
|
||||
plan: 02
|
||||
type: standard
|
||||
---
|
||||
|
||||
<objective>
|
||||
Integrate message composer into chat window, wire up send functionality, and test end-to-end message flows.
|
||||
|
||||
Purpose: Complete message composition feature by connecting composer to Redux state, integrating into window, and validating send/receive behavior with tests.
|
||||
Output: Working message composition with integration tests covering send, receive, and error scenarios.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-phase.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
|
||||
# Phase 9 Plan 1 summary
|
||||
@.planning/phases/09-message-composition/09-01-SUMMARY.md
|
||||
|
||||
# Phase 7 summary (WebSocket integration)
|
||||
@.planning/phases/07-chat-infrastructure/07-03-SUMMARY.md
|
||||
|
||||
# Phase 8 summaries (window structure)
|
||||
@.planning/phases/08-chat-window-ui/08-01-SUMMARY.md
|
||||
@.planning/phases/08-chat-window-ui/08-02-SUMMARY.md
|
||||
|
||||
# Components to integrate
|
||||
@jam-ui/src/components/client/chat/JKChatComposer.js
|
||||
@jam-ui/src/components/client/JKSessionChatWindow.js
|
||||
@jam-ui/src/components/client/chat/JKChatMessageList.js
|
||||
|
||||
# Redux state management
|
||||
@jam-ui/src/store/features/sessionChatSlice.js
|
||||
|
||||
# Test utilities and examples
|
||||
@jam-ui/test/chat/chat-button.spec.ts
|
||||
@jam-ui/test/chat/chat-window.spec.ts
|
||||
@jam-ui/test/utils/test-helpers.ts
|
||||
|
||||
# Codebase conventions
|
||||
@.planning/codebase/CONVENTIONS.md
|
||||
@.planning/codebase/TESTING.md
|
||||
@CLAUDE.md
|
||||
|
||||
**Key context from Plan 09-01:**
|
||||
- JKChatComposer component created with validation and keyboard handling
|
||||
- Component uses local state for inputText
|
||||
- sendMessage thunk already wired to Send button
|
||||
- Validation prevents empty/long/disconnected sends
|
||||
|
||||
**Key context from Phase 7:**
|
||||
- sendMessage thunk: pending → optimistic message added, fulfilled → replace with server response, rejected → remove optimistic
|
||||
- CHAT_MESSAGE WebSocket handler: adds messages via addMessageFromWebSocket
|
||||
- Message deduplication: checks msg_id to prevent duplicates
|
||||
|
||||
**Key context from Phase 8:**
|
||||
- JKSessionChatWindow uses WindowPortal (450×600px)
|
||||
- JKChatMessageList displays messages with auto-scroll
|
||||
- Window layout: Header → MessageList → (empty space for composer)
|
||||
|
||||
**Established test patterns:**
|
||||
- Playwright integration tests with Redux store access
|
||||
- window.__REDUX_STORE__ for testing (dev/test environments)
|
||||
- loginToJamUI and createAndJoinSession test helpers
|
||||
- Mock WebSocket for real-time testing
|
||||
|
||||
**Integration requirements:**
|
||||
- Composer should be at bottom of chat window
|
||||
- Messages should auto-scroll when new message sent
|
||||
- Optimistic update should show message immediately
|
||||
- Error handling should display in composer
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Integrate JKChatComposer into JKSessionChatWindow</name>
|
||||
<files>jam-ui/src/components/client/JKSessionChatWindow.js</files>
|
||||
<action>
|
||||
Add composer to chat window below message list:
|
||||
|
||||
**Import composer:**
|
||||
```javascript
|
||||
import JKChatComposer from './chat/JKChatComposer';
|
||||
```
|
||||
|
||||
**Update window layout:**
|
||||
Current structure has Header → MessageList → (placeholder). Replace placeholder with composer.
|
||||
|
||||
Find the content area (likely after JKChatMessageList) and add:
|
||||
```jsx
|
||||
{/* Message List */}
|
||||
<JKChatMessageList />
|
||||
|
||||
{/* Composer - always at bottom */}
|
||||
<JKChatComposer />
|
||||
```
|
||||
|
||||
**Layout considerations:**
|
||||
- Composer should be fixed at bottom of window
|
||||
- Message list should fill remaining space above composer
|
||||
- Window dimensions: 450×600px total
|
||||
- Suggested layout:
|
||||
- Header: ~50px
|
||||
- Message list: flex-grow (fills available space)
|
||||
- Composer: ~120px (auto-sized based on content)
|
||||
|
||||
**Update container styles if needed:**
|
||||
```jsx
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<JKChatHeader onClose={handleClose} />
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<JKChatMessageList />
|
||||
</div>
|
||||
<JKChatComposer />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Why this layout:**
|
||||
- Header fixed at top
|
||||
- Message list scrollable, fills middle space
|
||||
- Composer fixed at bottom (standard chat pattern)
|
||||
- Flex layout ensures proper space distribution
|
||||
</action>
|
||||
<verify>
|
||||
JKChatComposer integrated into window. Composer visible at bottom of chat window. Layout correct (header/list/composer).
|
||||
</verify>
|
||||
<done>
|
||||
Composer integrated into chat window. Window layout complete with all three sections.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Write integration tests for message sending flow</name>
|
||||
<files>jam-ui/test/chat/send-message.spec.ts</files>
|
||||
<action>
|
||||
Create Playwright integration tests for send message behavior:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginToJamUI, createAndJoinSession } from '../utils/test-helpers';
|
||||
|
||||
test.describe('Send Message', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginToJamUI(page);
|
||||
await createAndJoinSession(page);
|
||||
|
||||
// Open chat window
|
||||
const chatButton = page.locator('img[alt="Chat"]');
|
||||
await chatButton.click();
|
||||
});
|
||||
|
||||
test('sends message when Send button clicked', async ({ page, context }) => {
|
||||
// Wait for popup
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Type message
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Hello from test!');
|
||||
|
||||
// Click send button
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for message to appear in list
|
||||
await popup.waitForTimeout(500);
|
||||
const message = popup.locator('text=Hello from test!');
|
||||
await expect(message).toBeVisible();
|
||||
|
||||
// Verify textarea cleared
|
||||
await expect(textarea).toHaveValue('');
|
||||
});
|
||||
|
||||
test('sends message when Enter key pressed', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Hello via Enter!');
|
||||
|
||||
// Press Enter (not Shift+Enter)
|
||||
await textarea.press('Enter');
|
||||
|
||||
// Message should appear
|
||||
await popup.waitForTimeout(500);
|
||||
const message = popup.locator('text=Hello via Enter!');
|
||||
await expect(message).toBeVisible();
|
||||
|
||||
// Textarea cleared
|
||||
await expect(textarea).toHaveValue('');
|
||||
});
|
||||
|
||||
test('inserts newline when Shift+Enter pressed', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Line 1');
|
||||
|
||||
// Press Shift+Enter
|
||||
await textarea.press('Shift+Enter');
|
||||
|
||||
// Type second line
|
||||
await textarea.type('Line 2');
|
||||
|
||||
// Verify multiline content
|
||||
const value = await textarea.inputValue();
|
||||
expect(value).toContain('\n');
|
||||
expect(value).toContain('Line 1');
|
||||
expect(value).toContain('Line 2');
|
||||
|
||||
// Message NOT sent yet
|
||||
const messages = popup.locator('[data-testid="chat-message"]');
|
||||
expect(await messages.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('disables send button when message empty', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
|
||||
// Initially disabled (empty)
|
||||
await expect(sendButton).toBeDisabled();
|
||||
|
||||
// Type message - becomes enabled
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Test message');
|
||||
await expect(sendButton).toBeEnabled();
|
||||
|
||||
// Clear - becomes disabled again
|
||||
await textarea.fill('');
|
||||
await expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables send button when message over 255 characters', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
|
||||
// Type 256 characters
|
||||
const longMessage = 'a'.repeat(256);
|
||||
await textarea.fill(longMessage);
|
||||
|
||||
// Send button disabled
|
||||
await expect(sendButton).toBeDisabled();
|
||||
|
||||
// Character count shows red and over-limit
|
||||
const charCount = popup.locator('text=/256\\/255/');
|
||||
await expect(charCount).toBeVisible();
|
||||
|
||||
// Validation message appears
|
||||
const validationMsg = popup.locator('text=/characters too long/i');
|
||||
await expect(validationMsg).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error when send fails', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Simulate send failure by rejecting the API call
|
||||
await popup.evaluate(() => {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
// Mock sendMessage to fail
|
||||
// Note: This requires setting up API mocking in test environment
|
||||
});
|
||||
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Test failure');
|
||||
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
await sendButton.click();
|
||||
|
||||
// Error message should appear
|
||||
await popup.waitForTimeout(500);
|
||||
const errorMsg = popup.locator('text=/Failed to send message/i');
|
||||
await expect(errorMsg).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows optimistic update immediately', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('Optimistic test');
|
||||
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
await sendButton.click();
|
||||
|
||||
// Message should appear immediately (optimistic)
|
||||
// Don't wait for server - should show right away
|
||||
const message = popup.locator('text=Optimistic test');
|
||||
await expect(message).toBeVisible({ timeout: 1000 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Test coverage:**
|
||||
- Send via button click
|
||||
- Send via Enter key
|
||||
- Newline via Shift+Enter
|
||||
- Disabled when empty
|
||||
- Disabled when over 255 chars
|
||||
- Error display on send failure
|
||||
- Optimistic update behavior
|
||||
|
||||
**Testing notes:**
|
||||
- Uses Playwright's keyboard simulation for Enter/Shift+Enter
|
||||
- Verifies Redux state updates via optimistic pattern
|
||||
- Tests validation UI feedback
|
||||
- Checks character count display
|
||||
</action>
|
||||
<verify>
|
||||
Integration tests created with 7+ test cases. Tests cover send flows, keyboard handling, validation, and error states.
|
||||
</verify>
|
||||
<done>
|
||||
Integration tests complete for send message feature. All send scenarios covered.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Write integration tests for real-time message receive</name>
|
||||
<files>jam-ui/test/chat/receive-message.spec.ts</files>
|
||||
<action>
|
||||
Create tests for receiving messages via WebSocket:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginToJamUI, createAndJoinSession } from '../utils/test-helpers';
|
||||
|
||||
test.describe('Receive Message', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginToJamUI(page);
|
||||
await createAndJoinSession(page);
|
||||
|
||||
// Open chat window
|
||||
const chatButton = page.locator('img[alt="Chat"]');
|
||||
await chatButton.click();
|
||||
});
|
||||
|
||||
test('displays message received via WebSocket', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Simulate receiving message via WebSocket (dispatch Redux action)
|
||||
await page.evaluate(() => {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
|
||||
// Simulate WebSocket message
|
||||
store.dispatch({
|
||||
type: 'sessionChat/addMessageFromWebSocket',
|
||||
payload: {
|
||||
msg_id: 'test-msg-123',
|
||||
user_id: 'other-user-id',
|
||||
user_name: 'Test User',
|
||||
message: 'Hello from WebSocket!',
|
||||
created_at: new Date().toISOString(),
|
||||
channel: 'test-session-id'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for message to appear in popup
|
||||
await popup.waitForTimeout(500);
|
||||
const message = popup.locator('text=Hello from WebSocket!');
|
||||
await expect(message).toBeVisible();
|
||||
|
||||
// Verify sender name displayed
|
||||
const sender = popup.locator('text=Test User');
|
||||
await expect(sender).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not duplicate sent message when received via WebSocket', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Send message
|
||||
const textarea = popup.locator('textarea[placeholder*="Type a message"]');
|
||||
await textarea.fill('No duplicate test');
|
||||
|
||||
const sendButton = popup.locator('button:has-text("Send")');
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for optimistic update
|
||||
await popup.waitForTimeout(300);
|
||||
|
||||
// Simulate receiving same message via WebSocket (with same msg_id)
|
||||
await page.evaluate(() => {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
const state = store.getState();
|
||||
|
||||
// Get the message that was just sent (will have msg_id from server)
|
||||
const messages = state.sessionChat.messagesByChannel['test-session-id'] || [];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Try to add again via WebSocket (should be deduplicated)
|
||||
if (lastMessage) {
|
||||
store.dispatch({
|
||||
type: 'sessionChat/addMessageFromWebSocket',
|
||||
payload: lastMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await popup.waitForTimeout(300);
|
||||
|
||||
// Should only have ONE instance of the message
|
||||
const messages = popup.locator('text=No duplicate test');
|
||||
expect(await messages.count()).toBe(1);
|
||||
});
|
||||
|
||||
test('auto-scrolls to bottom when new message received', async ({ page, context }) => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Add multiple messages to enable scrolling
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await page.evaluate((num) => {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
store.dispatch({
|
||||
type: 'sessionChat/addMessageFromWebSocket',
|
||||
payload: {
|
||||
msg_id: `msg-${num}`,
|
||||
user_id: 'user-id',
|
||||
user_name: 'Test User',
|
||||
message: `Message ${num}`,
|
||||
created_at: new Date().toISOString(),
|
||||
channel: 'test-session-id'
|
||||
}
|
||||
});
|
||||
}, i);
|
||||
|
||||
await popup.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Last message should be visible (auto-scrolled)
|
||||
const lastMessage = popup.locator('text=Message 10');
|
||||
await expect(lastMessage).toBeVisible();
|
||||
});
|
||||
|
||||
test('increments unread badge when message received and window closed', async ({ page, context }) => {
|
||||
// Open then close chat window
|
||||
const chatButton = page.locator('img[alt="Chat"]');
|
||||
await chatButton.click();
|
||||
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Close window
|
||||
const closeButton = popup.locator('button[aria-label="Close chat"]');
|
||||
await closeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Simulate receiving message while window closed
|
||||
await page.evaluate(() => {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
store.dispatch({
|
||||
type: 'sessionChat/addMessageFromWebSocket',
|
||||
payload: {
|
||||
msg_id: 'msg-closed-window',
|
||||
user_id: 'other-user',
|
||||
user_name: 'Test User',
|
||||
message: 'Message while closed',
|
||||
created_at: new Date().toISOString(),
|
||||
channel: 'test-session-id'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Unread badge should show "1"
|
||||
const badge = page.locator('div:has-text("1")').filter({ hasText: /^1$/ });
|
||||
await expect(badge).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Test coverage:**
|
||||
- Receive message via WebSocket dispatch
|
||||
- Message deduplication (sent message not duplicated on WebSocket receive)
|
||||
- Auto-scroll on new message
|
||||
- Unread count increment when window closed
|
||||
|
||||
**WebSocket simulation:**
|
||||
Tests dispatch Redux actions directly to simulate WebSocket messages. This is valid because:
|
||||
- Phase 7 tested WebSocket handler separately
|
||||
- These tests focus on UI response to Redux state changes
|
||||
- Full WebSocket mocking would be more complex without clear benefit
|
||||
</action>
|
||||
<verify>
|
||||
Integration tests for receive flow created. Tests cover WebSocket message display, deduplication, auto-scroll, and unread tracking.
|
||||
</verify>
|
||||
<done>
|
||||
Receive message tests complete. All real-time scenarios covered. Phase 9 complete.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] JKChatComposer integrated into JKSessionChatWindow
|
||||
- [ ] Composer visible at bottom of chat window
|
||||
- [ ] Window layout correct (header/list/composer)
|
||||
- [ ] Send message integration tests pass (7 tests)
|
||||
- [ ] Receive message integration tests pass (4 tests)
|
||||
- [ ] Message sending works end-to-end
|
||||
- [ ] Real-time message receive works
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] All tests executable (may need CI/test environment to run)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
|
||||
- JKChatComposer integrated into chat window
|
||||
- Window layout complete (header/list/composer)
|
||||
- Integration tests written for send flow (7+ tests)
|
||||
- Integration tests written for receive flow (4+ tests)
|
||||
- Tests cover: send via button/Enter, keyboard handling, validation, errors, WebSocket receive, deduplication, auto-scroll
|
||||
- Phase 9 complete - message composition and sending fully functional
|
||||
- Ready for Phase 10 (Read/Unread Status Management)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/09-message-composition/09-02-SUMMARY.md`:
|
||||
|
||||
# Phase 9 Plan 2: Send Message Integration & Real-Time Delivery Summary
|
||||
|
||||
**Integrated composer into chat window, completed send functionality, and validated with comprehensive integration tests (11 tests)**
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Integrated JKChatComposer into JKSessionChatWindow
|
||||
- Window layout complete: Header → Message List → Composer
|
||||
- Send message flow works end-to-end
|
||||
- Real-time message receive via WebSocket
|
||||
- Message deduplication prevents duplicates
|
||||
- Optimistic updates show messages immediately
|
||||
- Integration tests: 11 total (7 send, 4 receive)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `jam-ui/src/components/client/JKSessionChatWindow.js` - Added composer (MODIFIED)
|
||||
- `jam-ui/test/chat/send-message.spec.ts` - Send flow tests (NEW)
|
||||
- `jam-ui/test/chat/receive-message.spec.ts` - Receive flow tests (NEW)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
[Document any implementation decisions]
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
[Problems and resolutions, or "None"]
|
||||
|
||||
## Phase 9 Complete!
|
||||
|
||||
**Message Composition & Sending fully functional:**
|
||||
- ✅ Composer UI with validation (Plan 9.1)
|
||||
- ✅ Send integration and real-time delivery (Plan 9.2)
|
||||
|
||||
Ready for Phase 10 (Read/Unread Status Management).
|
||||
|
||||
Next phase will add:
|
||||
- Unread message tracking per channel
|
||||
- Badge updates on new messages
|
||||
- Mark-as-read on window open
|
||||
- localStorage persistence for unread counts
|
||||
|
||||
---
|
||||
*Phase: 09-message-composition*
|
||||
*Completed: [date]*
|
||||
</output>
|
||||
Loading…
Reference in New Issue