feat(06-02): design session chat component architecture
Create comprehensive component design for session chat following JamTrack
and Backing Track patterns:
- 8 components: JKSessionChatButton, JKSessionChatWindow, 6 sub-components
- WindowPortal integration for modeless dialog (reuse existing)
- Props vs Redux decision matrix (heavy Redux, minimal props)
- Auto-scroll logic for message list
- Enter-to-send keyboard handling for composer
- Integration plan with JKSessionScreen
- Component lifecycle patterns documented
- Testing strategy defined (unit, integration, E2E)
Component hierarchy:
- JKSessionChatButton (top nav with unread badge)
- JKSessionChatWindow (WindowPortal wrapper)
- JKChatHeader
- JKChatMessageList (with auto-scroll)
- JKChatMessage (avatar, name, text, timestamp)
- JKChatLoadingSpinner
- JKChatEmptyState
- JKChatComposer (textarea + send button)
File structure: jam-ui/src/components/client/chat/
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d839be3ca4
commit
192703f592
|
|
@ -0,0 +1,954 @@
|
|||
# Session Chat Component Architecture Design
|
||||
|
||||
**Purpose:** Design React component hierarchy for session chat, following established jam-ui patterns from Backing Track and JamTrack.
|
||||
|
||||
**Date:** 2026-01-26
|
||||
**Phase:** 06-session-chat-research-design
|
||||
**Plan:** 02
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document designs the component architecture for session chat in jam-ui, reusing existing patterns from Phase 3 (Backing Track) and Phase 5 (JamTrack), with WindowPortal for modeless dialog support.
|
||||
|
||||
**Key Design Principles:**
|
||||
- Follow JamTrack player pattern: Main component with specialized sub-components
|
||||
- Use WindowPortal for modeless chat window (already proven for Metronome, JamTrack)
|
||||
- Heavy Redux integration for state management (minimal props)
|
||||
- Clear separation: UI components vs data/behavior (Redux)
|
||||
|
||||
---
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
JKSessionChatButton (in session screen top nav)
|
||||
├── Badge (unread count indicator)
|
||||
└── onClick → dispatch(openChatWindow)
|
||||
|
||||
JKSessionChatWindow (WindowPortal wrapper)
|
||||
├── WindowPortal (from jam-ui/src/components/common/WindowPortal.js)
|
||||
│ └── Chat content (rendered inside popup window)
|
||||
│ ├── JKChatHeader
|
||||
│ │ ├── Title ("Session Chat")
|
||||
│ │ └── Close button
|
||||
│ ├── JKChatMessageList (scrollable container)
|
||||
│ │ ├── JKChatLoadingSpinner (conditional: fetchStatus === 'loading')
|
||||
│ │ ├── JKChatEmptyState (conditional: messages.length === 0)
|
||||
│ │ └── JKChatMessage[] (repeated for each message)
|
||||
│ │ ├── Avatar (user photo)
|
||||
│ │ ├── Sender name
|
||||
│ │ ├── Message text
|
||||
│ │ └── Timestamp
|
||||
│ └── JKChatComposer
|
||||
│ ├── Textarea (message input)
|
||||
│ ├── Character count (255 max)
|
||||
│ └── Send button
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### 1. JKSessionChatButton
|
||||
|
||||
**File:** `jam-ui/src/components/client/JKSessionChatButton.js`
|
||||
|
||||
**Purpose:** Chat button in session screen top nav, shows unread badge.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props - all state from Redux
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
const unreadCount = useSelector(selectUnreadCount(sessionId));
|
||||
const isWindowOpen = useSelector(state => state.sessionChat.isWindowOpen);
|
||||
```
|
||||
|
||||
**Redux Actions:**
|
||||
```javascript
|
||||
dispatch(openChatWindow());
|
||||
dispatch(closeChatWindow());
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<button
|
||||
className="chat-button"
|
||||
onClick={handleToggleChatWindow}
|
||||
data-testid="chat-button"
|
||||
>
|
||||
<span className="chat-icon">💬</span>
|
||||
<span className="chat-label">Chat</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge" data-testid="chat-badge">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ✅ Redux: unreadCount, isWindowOpen
|
||||
- ❌ Props: None
|
||||
- ✅ Local: None (purely presentational)
|
||||
|
||||
**Integration Point:**
|
||||
- Add to `JKSessionScreen` top navigation area (near mixer, metronome buttons)
|
||||
- Position: Absolute or flex layout (TBD in implementation)
|
||||
|
||||
---
|
||||
|
||||
### 2. JKSessionChatWindow
|
||||
|
||||
**File:** `jam-ui/src/components/client/JKSessionChatWindow.js`
|
||||
|
||||
**Purpose:** Main chat window component, wraps WindowPortal for modeless dialog.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props - all state from Redux
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
const isWindowOpen = useSelector(state => state.sessionChat.isWindowOpen);
|
||||
const sessionId = useSelector(state => state.activeSession.sessionId);
|
||||
const messages = useSelector(selectChatMessages(sessionId));
|
||||
const fetchStatus = useSelector(state => state.sessionChat.fetchStatus[sessionId]);
|
||||
const currentUser = useSelector(state => state.auth.user);
|
||||
```
|
||||
|
||||
**Redux Actions:**
|
||||
```javascript
|
||||
dispatch(closeChatWindow());
|
||||
dispatch(fetchChatHistory({ channel: sessionId, channelType: 'session', sessionId }));
|
||||
dispatch(markAsRead({ channel: sessionId }));
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
if (isWindowOpen && sessionId) {
|
||||
// Fetch message history on window open
|
||||
dispatch(fetchChatHistory({ channel: sessionId, channelType: 'session', sessionId }));
|
||||
|
||||
// Mark messages as read
|
||||
dispatch(markAsRead({ channel: sessionId }));
|
||||
}
|
||||
}, [isWindowOpen, sessionId, dispatch]);
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
{isWindowOpen && (
|
||||
<WindowPortal
|
||||
windowFeatures="width=400,height=600,left=200,top=200,menubar=no,toolbar=no"
|
||||
title="Session Chat"
|
||||
onClose={handleClose}
|
||||
windowId="session-chat"
|
||||
>
|
||||
<div className="chat-window-content">
|
||||
<JKChatHeader />
|
||||
<JKChatMessageList />
|
||||
<JKChatComposer />
|
||||
</div>
|
||||
</WindowPortal>
|
||||
)}
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ✅ Redux: isWindowOpen, messages, fetchStatus
|
||||
- ❌ Props: None
|
||||
- ✅ Local: None (delegates to sub-components)
|
||||
|
||||
**Window Management:**
|
||||
- Uses WindowPortal (no modifications needed)
|
||||
- Dimensions: 400x600px (similar to Metronome popup)
|
||||
- Position: User-configurable (WindowPortal handles)
|
||||
- Auto-cleanup: WindowPortal handles on close
|
||||
|
||||
---
|
||||
|
||||
### 3. JKChatHeader
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatHeader.js`
|
||||
|
||||
**Purpose:** Chat window title bar with close button.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
onClose: () => void // Callback to close window
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
// None - purely presentational
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div className="chat-header">
|
||||
<h3 className="chat-title">Session Chat</h3>
|
||||
<button
|
||||
className="chat-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close chat"
|
||||
data-testid="chat-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ❌ Redux: None
|
||||
- ✅ Props: onClose callback
|
||||
- ✅ Local: None
|
||||
|
||||
**Styling:**
|
||||
- Fixed height (e.g., 48px)
|
||||
- Flex layout: title left, close button right
|
||||
- Border-bottom separator
|
||||
|
||||
---
|
||||
|
||||
### 4. JKChatMessageList
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatMessageList.js`
|
||||
|
||||
**Purpose:** Scrollable message container with auto-scroll to bottom.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props - all state from Redux
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
const sessionId = useSelector(state => state.activeSession.sessionId);
|
||||
const messages = useSelector(selectChatMessages(sessionId)); // Sorted by created_at ASC
|
||||
const fetchStatus = useSelector(state => state.sessionChat.fetchStatus[sessionId]);
|
||||
const currentUser = useSelector(state => state.auth.user);
|
||||
```
|
||||
|
||||
**Local State:**
|
||||
```javascript
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const scrollContainerRef = useRef(null);
|
||||
```
|
||||
|
||||
**Auto-Scroll Logic:**
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
if (!isUserScrolling && scrollContainerRef.current) {
|
||||
// Auto-scroll to bottom on new messages
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [messages.length, isUserScrolling]);
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
};
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div
|
||||
className="chat-message-list"
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
data-testid="chat-message-list"
|
||||
>
|
||||
{fetchStatus === 'loading' && <JKChatLoadingSpinner />}
|
||||
|
||||
{fetchStatus === 'succeeded' && messages.length === 0 && (
|
||||
<JKChatEmptyState />
|
||||
)}
|
||||
|
||||
{messages.map(message => (
|
||||
<JKChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isCurrentUser={message.senderId === currentUser.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ✅ Redux: messages, fetchStatus, currentUser
|
||||
- ❌ Props: None
|
||||
- ✅ Local: isUserScrolling, scrollContainerRef
|
||||
|
||||
**Styling:**
|
||||
- Flex-grow: 1 (fills space between header and composer)
|
||||
- Overflow-y: auto (scrollable)
|
||||
- Padding: 16px
|
||||
- Background: Light gray (#f5f5f5)
|
||||
|
||||
---
|
||||
|
||||
### 5. JKChatMessage
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatMessage.js`
|
||||
|
||||
**Purpose:** Single message item (avatar, name, text, timestamp).
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
message: {
|
||||
id: string,
|
||||
senderId: string,
|
||||
senderName: string,
|
||||
message: string,
|
||||
createdAt: string, // ISO 8601 timestamp
|
||||
channel: string
|
||||
},
|
||||
isCurrentUser: boolean // True if message from current user
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
// None - all data passed via props
|
||||
```
|
||||
|
||||
**Helper Functions:**
|
||||
```javascript
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const formatTimestamp = (isoTimestamp) => {
|
||||
return dayjs(isoTimestamp).fromNow(); // "5 minutes ago"
|
||||
};
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div
|
||||
className={`chat-message ${isCurrentUser ? 'current-user' : 'other-user'}`}
|
||||
data-testid="chat-message"
|
||||
>
|
||||
{!isCurrentUser && (
|
||||
<div className="message-avatar">
|
||||
{/* Placeholder: Use first letter of name */}
|
||||
<span className="avatar-initial">
|
||||
{message.senderName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="sender-name">
|
||||
{isCurrentUser ? 'You' : message.senderName}
|
||||
</span>
|
||||
<span className="message-timestamp">
|
||||
{formatTimestamp(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="message-text">
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCurrentUser && (
|
||||
<div className="message-avatar message-avatar-right">
|
||||
<span className="avatar-initial">
|
||||
{message.senderName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ❌ Redux: None
|
||||
- ✅ Props: message, isCurrentUser
|
||||
- ✅ Local: None (purely presentational)
|
||||
|
||||
**Styling:**
|
||||
- Layout: Flex row
|
||||
- Current user messages: Avatar on right, text bubble right-aligned (blue)
|
||||
- Other user messages: Avatar on left, text bubble left-aligned (gray)
|
||||
- Text bubble: Rounded corners, padding, max-width 70%
|
||||
- Timestamp: Small, gray, relative time format
|
||||
|
||||
**Future Enhancement (Phase 11):**
|
||||
- Replace avatar placeholder with actual user photo from API
|
||||
- Add attachment support (music_notation, claimed_recording)
|
||||
|
||||
---
|
||||
|
||||
### 6. JKChatComposer
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatComposer.js`
|
||||
|
||||
**Purpose:** Message composition area (textarea + send button).
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props - all state from Redux
|
||||
}
|
||||
```
|
||||
|
||||
**Redux Selectors:**
|
||||
```javascript
|
||||
const sessionId = useSelector(state => state.activeSession.sessionId);
|
||||
const sendStatus = useSelector(state => state.sessionChat.sendStatus);
|
||||
const sendError = useSelector(state => state.sessionChat.sendError);
|
||||
const clientId = useSelector(state => state.auth.clientId); // For WebSocket routing
|
||||
const isConnected = useSelector(state => state.jamServer.isConnected);
|
||||
```
|
||||
|
||||
**Redux Actions:**
|
||||
```javascript
|
||||
dispatch(sendMessage({
|
||||
message,
|
||||
channel: sessionId,
|
||||
channelType: 'session',
|
||||
sessionId,
|
||||
clientId
|
||||
}));
|
||||
```
|
||||
|
||||
**Local State:**
|
||||
```javascript
|
||||
const [message, setMessage] = useState('');
|
||||
const textareaRef = useRef(null);
|
||||
```
|
||||
|
||||
**Keyboard Handling:**
|
||||
```javascript
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault(); // Prevent newline
|
||||
handleSend();
|
||||
}
|
||||
// Shift+Enter allows newline
|
||||
};
|
||||
```
|
||||
|
||||
**Send Handler:**
|
||||
```javascript
|
||||
const handleSend = () => {
|
||||
const trimmed = message.trim();
|
||||
|
||||
// Validation
|
||||
if (!trimmed) return;
|
||||
if (trimmed.length > 255) {
|
||||
alert('Message too long (max 255 characters)');
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
alert('Cannot send message: disconnected from server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send message
|
||||
dispatch(sendMessage({
|
||||
message: trimmed,
|
||||
channel: sessionId,
|
||||
channelType: 'session',
|
||||
sessionId,
|
||||
clientId
|
||||
}));
|
||||
|
||||
// Clear input
|
||||
setMessage('');
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div className="chat-composer">
|
||||
{sendError && (
|
||||
<div className="chat-error" data-testid="chat-error">
|
||||
Error: {sendError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="composer-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-input"
|
||||
placeholder="enter message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={255}
|
||||
disabled={sendStatus === 'loading' || !isConnected}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
|
||||
<div className="composer-footer">
|
||||
<span className="character-count">
|
||||
{message.length}/255
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="button-orange chat-send-button"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || sendStatus === 'loading' || !isConnected}
|
||||
data-testid="chat-send"
|
||||
>
|
||||
{sendStatus === 'loading' ? 'SENDING...' : 'SEND'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ✅ Redux: sendStatus, sendError, sessionId, clientId, isConnected
|
||||
- ❌ Props: None
|
||||
- ✅ Local: message, textareaRef
|
||||
|
||||
**Styling:**
|
||||
- Fixed height textarea (e.g., 80px)
|
||||
- Auto-resize (optional, Phase 11)
|
||||
- Send button: Orange (primary action)
|
||||
- Character count: Right-aligned, small font
|
||||
- Error message: Red background, above input
|
||||
|
||||
---
|
||||
|
||||
### 7. JKChatLoadingSpinner
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatLoadingSpinner.js`
|
||||
|
||||
**Purpose:** Loading indicator while fetching message history.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props
|
||||
}
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div className="chat-loading-spinner" data-testid="chat-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ❌ Redux: None
|
||||
- ❌ Props: None
|
||||
- ✅ Local: None (purely presentational)
|
||||
|
||||
**Styling:**
|
||||
- Centered in message list
|
||||
- CSS spinner animation (existing jam-ui spinner class?)
|
||||
- Gray text
|
||||
|
||||
---
|
||||
|
||||
### 8. JKChatEmptyState
|
||||
|
||||
**File:** `jam-ui/src/components/client/chat/JKChatEmptyState.js`
|
||||
|
||||
**Purpose:** Empty state when no messages in session.
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
// No props
|
||||
}
|
||||
```
|
||||
|
||||
**Render:**
|
||||
```jsx
|
||||
<div className="chat-empty-state" data-testid="chat-empty">
|
||||
<div className="empty-icon">💬</div>
|
||||
<p className="empty-title">No messages yet</p>
|
||||
<p className="empty-subtitle">
|
||||
Start the conversation by sending a message below
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**State Ownership:**
|
||||
- ❌ Redux: None
|
||||
- ❌ Props: None
|
||||
- ✅ Local: None (purely presentational)
|
||||
|
||||
**Styling:**
|
||||
- Centered in message list
|
||||
- Large icon
|
||||
- Gray text
|
||||
- Friendly copy
|
||||
|
||||
---
|
||||
|
||||
## Props vs Redux Decision Matrix
|
||||
|
||||
| Data/State | Props | Redux | Rationale |
|
||||
|------------|-------|-------|-----------|
|
||||
| **Messages** | ❌ | ✅ | Shared across WebSocket handler, needs persistence |
|
||||
| **Unread count** | ❌ | ✅ | Displayed in button, updated by WebSocket |
|
||||
| **Window open/closed** | ❌ | ✅ | Persisted across re-renders |
|
||||
| **Fetch status** | ❌ | ✅ | Shared loading state |
|
||||
| **Send status** | ❌ | ✅ | Shared send state |
|
||||
| **Current user** | ❌ | ✅ | Available from auth slice |
|
||||
| **Session ID** | ❌ | ✅ | Available from activeSession slice |
|
||||
| **Client ID** | ❌ | ✅ | Available from auth slice |
|
||||
| **Connection status** | ❌ | ✅ | Available from jamServer context |
|
||||
| **Message input text** | ❌ | ❌ | ✅ Local (ephemeral, no need to persist) |
|
||||
| **Scroll position** | ❌ | ❌ | ✅ Local (managed by ref) |
|
||||
| **User scrolling flag** | ❌ | ❌ | ✅ Local (auto-scroll logic) |
|
||||
| **onClose callback** | ✅ | ❌ | Passed to sub-components |
|
||||
|
||||
**Summary:**
|
||||
- **Heavy Redux usage** for data and state management
|
||||
- **Minimal props** (only callbacks like onClose)
|
||||
- **Local state** only for ephemeral UI state (input text, scroll position)
|
||||
|
||||
---
|
||||
|
||||
## Reusable Components
|
||||
|
||||
### Available from jam-ui
|
||||
|
||||
**1. WindowPortal**
|
||||
- ✅ **File:** `jam-ui/src/components/common/WindowPortal.js`
|
||||
- ✅ **Usage:** Wrap chat content for modeless dialog
|
||||
- ✅ **No modifications needed**
|
||||
|
||||
**2. Button Classes**
|
||||
- ✅ **CSS Classes:** `.button-orange` (primary), `.button-grey` (secondary)
|
||||
- ✅ **Usage:** Apply to `<button>` elements
|
||||
- ✅ **Already styled** in jam-ui
|
||||
|
||||
**3. Input/Textarea Patterns**
|
||||
- ✅ **Pattern:** Standard HTML `<textarea>` with controlled value
|
||||
- ✅ **Usage:** Follow existing input patterns in jam-ui
|
||||
|
||||
### Not Available (Need to Create)
|
||||
|
||||
**1. JKAvatar Component**
|
||||
- ❌ **Not found** in jam-ui codebase
|
||||
- **For Phase 8:** Use placeholder (first letter of name)
|
||||
- **For Phase 11:** Create reusable JKAvatar component with photo support
|
||||
|
||||
**2. JKTimeDisplay Component**
|
||||
- ❌ **Not found** in jam-ui codebase
|
||||
- **For Phase 8:** Use inline `dayjs.fromNow()` formatting
|
||||
- **For Phase 11:** Create reusable JKTimeDisplay component (if needed elsewhere)
|
||||
|
||||
**3. JKBadge Component**
|
||||
- ❌ **Not found** in jam-ui codebase
|
||||
- **For Phase 8:** Use inline `<span className="badge">`
|
||||
- **For Phase 11:** Create reusable JKBadge component (if needed elsewhere)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
jam-ui/src/components/client/
|
||||
├── JKSessionScreen.js (existing - add chat button)
|
||||
├── JKSessionChatButton.js (new)
|
||||
├── JKSessionChatWindow.js (new)
|
||||
└── chat/ (new directory)
|
||||
├── JKChatHeader.js
|
||||
├── JKChatMessageList.js
|
||||
├── JKChatMessage.js
|
||||
├── JKChatComposer.js
|
||||
├── JKChatLoadingSpinner.js
|
||||
└── JKChatEmptyState.js
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Main components at `client/` level (JKSessionChatButton, JKSessionChatWindow)
|
||||
- Sub-components in dedicated `chat/` directory (cleaner organization)
|
||||
- Matches existing jam-ui structure (e.g., `media/` directory for media components)
|
||||
|
||||
---
|
||||
|
||||
## Integration with Session Screen
|
||||
|
||||
### Current JKSessionScreen Structure (Reference)
|
||||
|
||||
```jsx
|
||||
export const JKSessionScreen = () => {
|
||||
const { sessionId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
dispatch(loadSession(sessionId));
|
||||
}
|
||||
}, [sessionId, dispatch]);
|
||||
|
||||
useSessionWebSocket(sessionId);
|
||||
|
||||
return (
|
||||
<div className="session-screen">
|
||||
{/* Existing components */}
|
||||
<JKSessionMixer />
|
||||
<JKSessionTracks />
|
||||
<JKSessionJamTrackPlayer />
|
||||
<JKSessionBackingTrackPlayer />
|
||||
<JKSessionMetronomePlayer />
|
||||
|
||||
{/* NEW: Chat components */}
|
||||
<JKSessionChatButton />
|
||||
<JKSessionChatWindow />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Chat Button Placement
|
||||
|
||||
**Option A: Top Navigation Bar (Recommended)**
|
||||
- Position: Absolute or flex layout in top-right area
|
||||
- Near: Metronome button, settings button
|
||||
- Visibility: Always visible during session
|
||||
|
||||
**Option B: Sidebar Panel**
|
||||
- Position: Left sidebar (like legacy chatPanel.js)
|
||||
- Collapsible panel
|
||||
- Less intrusive but harder to discover
|
||||
|
||||
**Recommendation:** Option A - Top navigation bar (better discoverability)
|
||||
|
||||
### Chat Window Rendering
|
||||
|
||||
**Conditional Rendering:**
|
||||
- Only renders when `isWindowOpen === true`
|
||||
- WindowPortal handles actual popup creation
|
||||
- No DOM footprint when closed (cleanup automatic)
|
||||
|
||||
---
|
||||
|
||||
## Component Lifecycle Patterns
|
||||
|
||||
### JKSessionChatWindow Lifecycle
|
||||
|
||||
**Mount:**
|
||||
1. Component mounts (window opens)
|
||||
2. `useEffect` triggers
|
||||
3. Fetch message history: `dispatch(fetchChatHistory(...))`
|
||||
4. Mark messages as read: `dispatch(markAsRead(...))`
|
||||
5. Subscribe to WebSocket (via useSessionWebSocket - already active)
|
||||
|
||||
**Update:**
|
||||
1. New message received via WebSocket
|
||||
2. Redux state updated: `addMessageFromWebSocket()`
|
||||
3. Component re-renders (messages array changes)
|
||||
4. Auto-scroll to bottom (if not manually scrolling)
|
||||
|
||||
**Unmount:**
|
||||
1. User closes window
|
||||
2. WindowPortal triggers `onClose` callback
|
||||
3. Dispatch `closeChatWindow()` action
|
||||
4. Redux state updated: `isWindowOpen = false`
|
||||
5. Component unmounts (cleanup automatic)
|
||||
|
||||
**No explicit cleanup needed:**
|
||||
- WebSocket subscription lives at session level (not per-component)
|
||||
- No manual event listeners (all via React)
|
||||
- WindowPortal handles popup cleanup
|
||||
|
||||
---
|
||||
|
||||
### JKChatMessageList Auto-Scroll Pattern
|
||||
|
||||
**Problem:** Auto-scroll on new messages, but respect user manual scrolling.
|
||||
|
||||
**Solution:**
|
||||
1. Track `isUserScrolling` state (true if user scrolled up)
|
||||
2. On scroll event: Check if at bottom (`scrollHeight - scrollTop - clientHeight < 50`)
|
||||
3. If at bottom: `isUserScrolling = false` (enable auto-scroll)
|
||||
4. If not at bottom: `isUserScrolling = true` (disable auto-scroll)
|
||||
5. On new message: If `!isUserScrolling`, scroll to bottom
|
||||
|
||||
**Reference:** Legacy pattern from `ChatWindow.componentDidUpdate()`
|
||||
|
||||
---
|
||||
|
||||
## Comparison to Backing Track / JamTrack Players
|
||||
|
||||
### Similarities
|
||||
|
||||
**1. Component Structure:**
|
||||
- Main component (JKSessionChatWindow) with sub-components
|
||||
- Uses WindowPortal for popup mode
|
||||
- Heavy Redux integration
|
||||
|
||||
**2. Redux Patterns:**
|
||||
- State in Redux slices
|
||||
- Async thunks for API calls
|
||||
- Selectors for derived data
|
||||
|
||||
**3. Lifecycle:**
|
||||
- Mount → fetch data
|
||||
- Update → handle WebSocket messages
|
||||
- Unmount → cleanup (automatic)
|
||||
|
||||
### Differences
|
||||
|
||||
**1. No jamClient Integration:**
|
||||
- Chat is purely web-based (no native client calls)
|
||||
- Backing Track / JamTrack use jamClient.* methods
|
||||
- Chat uses REST API + WebSocket only
|
||||
|
||||
**2. Simpler State Machine:**
|
||||
- Chat: 3 states (idle, loading, succeeded)
|
||||
- JamTrack: 6-state download/sync machine
|
||||
- Chat has no download/packaging complexity
|
||||
|
||||
**3. Real-Time Emphasis:**
|
||||
- Chat: WebSocket-first (messages arrive instantly)
|
||||
- Backing Track: Polling for playback position
|
||||
- JamTrack: Hybrid (polling + WebSocket)
|
||||
|
||||
**4. User Input:**
|
||||
- Chat: Text input (textarea with validation)
|
||||
- Players: Buttons/sliders (no text input)
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
**Phase 11 (Polish):**
|
||||
- Add `aria-label` to buttons
|
||||
- Add `role="log"` to message list (for screen readers)
|
||||
- Add `aria-live="polite"` for new message announcements
|
||||
- Keyboard navigation (Tab, Enter, Esc)
|
||||
- Focus management (auto-focus input on window open)
|
||||
|
||||
**Not Phase 8 Priority:**
|
||||
- Basic functionality first, accessibility in polish phase
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Phase 11 (Optimization):**
|
||||
- Virtualize message list (react-window) if >100 messages
|
||||
- Debounce scroll event handler
|
||||
- Memoize message components (React.memo)
|
||||
- Optimize selector performance (Reselect)
|
||||
|
||||
**Not Phase 8 Priority:**
|
||||
- Build basic functionality first, optimize later if needed
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Jest)
|
||||
|
||||
**JKChatMessage:**
|
||||
- Renders sender name correctly
|
||||
- Formats timestamp using dayjs.fromNow()
|
||||
- Applies 'current-user' class when isCurrentUser=true
|
||||
- Shows avatar on correct side
|
||||
|
||||
**JKChatComposer:**
|
||||
- Sends message on Enter key
|
||||
- Allows newline on Shift+Enter
|
||||
- Validates message length (max 255)
|
||||
- Disables send button when disconnected
|
||||
- Clears input after send
|
||||
|
||||
**JKChatMessageList:**
|
||||
- Auto-scrolls to bottom on new message
|
||||
- Stops auto-scroll when user scrolls up
|
||||
- Resumes auto-scroll when user scrolls to bottom
|
||||
- Shows loading spinner when fetchStatus='loading'
|
||||
- Shows empty state when messages.length=0
|
||||
|
||||
### Integration Tests (Playwright)
|
||||
|
||||
**Send Message Flow:**
|
||||
1. Open chat window
|
||||
2. Type message
|
||||
3. Click SEND (or press Enter)
|
||||
4. Verify API call (POST /api/chat)
|
||||
5. Verify message appears in UI
|
||||
6. Verify input cleared
|
||||
|
||||
**Receive Message Flow:**
|
||||
1. Open chat window
|
||||
2. Simulate WebSocket message (mock)
|
||||
3. Verify message appears in UI
|
||||
4. Verify auto-scroll to bottom
|
||||
|
||||
**Unread Badge Flow:**
|
||||
1. Close chat window
|
||||
2. Simulate incoming WebSocket message
|
||||
3. Verify badge appears with count=1
|
||||
4. Open chat window
|
||||
5. Verify badge disappears (count=0)
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
**Complete Chat Workflow:**
|
||||
1. Login → Create session → Join session
|
||||
2. Open chat window
|
||||
3. Send message
|
||||
4. Verify message sent
|
||||
5. Close chat window
|
||||
6. Simulate reply from other user
|
||||
7. Verify unread badge
|
||||
8. Open chat window
|
||||
9. Verify badge cleared
|
||||
10. Verify reply visible
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Component Architecture Designed:**
|
||||
- ✅ 8 components specified (JKSessionChatButton, JKSessionChatWindow, 6 sub-components)
|
||||
- ✅ Props vs Redux decision matrix defined
|
||||
- ✅ Integration with session screen planned
|
||||
- ✅ Reusable components identified (WindowPortal, button classes)
|
||||
- ✅ Component hierarchy clear and modular
|
||||
- ✅ File structure follows jam-ui conventions
|
||||
|
||||
**Key Patterns Applied:**
|
||||
- Heavy Redux integration (minimal props)
|
||||
- WindowPortal for modeless dialog (proven pattern)
|
||||
- Sub-component modularity (like JamTrack player)
|
||||
- Auto-scroll logic (from legacy ChatWindow)
|
||||
- Enter-to-send keyboard handling (legacy pattern)
|
||||
|
||||
**Ready for Phase 7-8 Implementation:**
|
||||
- Component specs ready for TDD implementation
|
||||
- Clear integration points identified
|
||||
- Testing strategy defined
|
||||
- Follows established jam-ui patterns
|
||||
|
||||
---
|
||||
|
||||
**Next Document:** CHAT_REDUX_DESIGN.md (Redux state structure and WebSocket integration)
|
||||
Loading…
Reference in New Issue