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:
Nuwan 2026-01-27 15:14:32 +05:30
parent c4acebcdeb
commit 619e37f505
2 changed files with 1017 additions and 0 deletions

View File

@ -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>

View File

@ -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>