# React Integration Design for Session Attachments

**Phase:** 12 (Attachment Research & Backend Validation)
**Plan:** 02
**Date:** 2026-02-02
**Integration Strategy:** Extend existing jam-ui chat components with attachment upload/display

---

## Executive Summary

This document provides the complete React integration strategy for adding file attachment capabilities to the existing jam-ui session chat. The design extends existing components (`JKChatComposer`, `JKChatMessage`, `JKSessionScreen`) and Redux state (`sessionChatSlice`) to support file selection, validation, upload, progress tracking, and attachment display.

**Design Principles:**
1. **Minimal changes:** Extend, don't replace existing components
2. **Pattern reuse:** Follow established jam-ui patterns (WindowPortal, Redux thunks, WebSocket handlers)
3. **TDD implementation:** All new functionality requires tests before code
4. **Progressive enhancement:** Attachment features are additive to existing chat

---

## 1. Component Modifications Overview

| Component | Current State | Modifications Needed | Phase | Lines Est. |
|-----------|---------------|----------------------|-------|------------|
| `JKChatComposer.js` | Text input + Send button | Add attach button, file validation, upload trigger | 13 | +80 |
| `JKChatMessage.js` | Text display with avatar | Handle attachment messages, render download link | 14 | +40 |
| `JKSessionScreen.js` | WebSocket CHAT_MESSAGE handler | Extract attachment fields from WebSocket payload | 14-15 | +10 |
| `sessionChatSlice.js` | Chat state management | Add upload state, uploadAttachment thunk | 13 | +120 |
| `rest.js` | API helper functions | Add uploadMusicNotation, getMusicNotationUrl | 13 | +40 |
| **New:** `JKChatAttachButton.js` | N/A | Hidden file input + visible button trigger | 13 | +60 |
| **New:** `attachmentValidation.js` | N/A | Client-side file validation utilities | 13 | +80 |

**Total estimated additions:** ~430 lines of code + ~200 lines of tests = 630 lines total

---

## 2. New Component: JKChatAttachButton

### Purpose
Provide a user-friendly file selection interface with hidden `<input type="file">` triggered by visible button.

### Component Skeleton

```javascript
// jam-ui/src/components/client/chat/JKChatAttachButton.js
import React, { useRef, useCallback } from 'react';
import PropTypes from 'prop-types';

/**
 * JKChatAttachButton - File selection button for chat attachments
 *
 * Pattern: Hidden file input + visible button trigger
 * - User clicks visible button
 * - Button triggers hidden file input click
 * - File input onChange calls onFileSelect callback
 * - Input value reset after selection (allows re-selecting same file)
 *
 * @param {Object} props
 * @param {function} props.onFileSelect - Callback when file selected: (file) => void
 * @param {boolean} props.disabled - Disable button (during upload or when disconnected)
 * @param {boolean} props.isUploading - Show uploading state
 */
const JKChatAttachButton = ({ onFileSelect, disabled, isUploading }) => {
  const fileInputRef = useRef(null);

  const handleButtonClick = useCallback(() => {
    fileInputRef.current?.click();
  }, []);

  const handleFileChange = useCallback((e) => {
    const file = e.target.files?.[0];
    if (file) {
      onFileSelect(file);
      // Reset input value to allow re-selecting same file
      e.target.value = '';
    }
  }, [onFileSelect]);

  return (
    <>
      {/* Visible button */}
      <button
        onClick={handleButtonClick}
        disabled={disabled}
        aria-label="Attach file"
        style={{
          padding: '6px 12px',
          backgroundColor: disabled ? '#ccc' : '#6c757d',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: disabled ? 'not-allowed' : 'pointer',
          fontSize: '14px',
          marginRight: '8px'
        }}
      >
        {isUploading ? 'Uploading...' : '📎 Attach'}
      </button>

      {/* Hidden file input */}
      <input
        ref={fileInputRef}
        type="file"
        style={{ display: 'none' }}
        accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav"
        onChange={handleFileChange}
        aria-hidden="true"
      />
    </>
  );
};

JKChatAttachButton.propTypes = {
  onFileSelect: PropTypes.func.isRequired,
  disabled: PropTypes.bool,
  isUploading: PropTypes.bool
};

JKChatAttachButton.defaultProps = {
  disabled: false,
  isUploading: false
};

export default React.memo(JKChatAttachButton);
```

### Accept Attribute Value

**From requirements (pending mp3 decision):**
```javascript
accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav"
```

**If mp3 is NOT supported (after backend decision):**
```javascript
accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.wav"
```

### Integration in JKChatComposer

```javascript
// Add to JKChatComposer.js imports
import JKChatAttachButton from './JKChatAttachButton';

// Add file upload handler
const handleFileSelect = useCallback(async (file) => {
  // Validate file before upload
  const validation = validateFile(file);
  if (!validation.valid) {
    // Show error (could use toast or inline error display)
    console.error('File validation failed:', validation.error);
    return;
  }

  // Dispatch upload action
  await dispatch(uploadAttachment({
    file,
    sessionId,
    clientId: server?.clientId
  }));
}, [dispatch, sessionId, server?.clientId]);

// Add button before Send button in render
<div style={{ display: 'flex', alignItems: 'center' }}>
  <JKChatAttachButton
    onFileSelect={handleFileSelect}
    disabled={!isConnected || uploadStatus === 'loading'}
    isUploading={uploadStatus === 'loading'}
  />
  <button onClick={handleSend} disabled={!canSend}>
    {isSending ? 'Sending...' : 'Send'}
  </button>
</div>
```

---

## 3. Redux State Extensions (sessionChatSlice.js)

### New State Shape

```javascript
const initialState = {
  // ... existing state
  messagesByChannel: {},
  activeChannel: null,
  // ... etc

  // NEW: Upload state tracking
  uploadState: {
    status: 'idle',        // 'idle' | 'uploading' | 'error'
    progress: 0,           // 0-100 (future: if progress tracking implemented)
    error: null,           // Error message string or null
    fileName: null         // Currently uploading file name (for UI display)
  }
};
```

### New Reducers

```javascript
const sessionChatSlice = createSlice({
  name: 'sessionChat',
  initialState,
  reducers: {
    // ... existing reducers

    // NEW: Set upload status
    setUploadStatus: (state, action) => {
      const { status, progress, error, fileName } = action.payload;
      state.uploadState.status = status;
      if (progress !== undefined) state.uploadState.progress = progress;
      if (error !== undefined) state.uploadState.error = error;
      if (fileName !== undefined) state.uploadState.fileName = fileName;
    },

    // NEW: Clear upload error
    clearUploadError: (state) => {
      state.uploadState.error = null;
      state.uploadState.status = 'idle';
    }
  },
  extraReducers: (builder) => {
    // ... existing thunk handlers

    // NEW: Upload attachment thunk handlers
    builder
      .addCase(uploadAttachment.pending, (state, action) => {
        state.uploadState.status = 'uploading';
        state.uploadState.error = null;
        state.uploadState.fileName = action.meta.arg.file.name;
        state.uploadState.progress = 0;
      })
      .addCase(uploadAttachment.fulfilled, (state, action) => {
        state.uploadState.status = 'idle';
        state.uploadState.fileName = null;
        state.uploadState.progress = 0;
        // Note: Actual chat message arrives via WebSocket, not from upload response
      })
      .addCase(uploadAttachment.rejected, (state, action) => {
        state.uploadState.status = 'error';
        state.uploadState.error = action.error.message || 'Upload failed';
        state.uploadState.progress = 0;
      });
  }
});

export const { setUploadStatus, clearUploadError } = sessionChatSlice.actions;
```

### New Async Thunk: uploadAttachment

```javascript
/**
 * Upload file attachment to session
 * Creates MusicNotation record, which triggers ChatMessage creation
 * ChatMessage broadcast via WebSocket (handled separately)
 *
 * @param {Object} params
 * @param {File} params.file - File object from input
 * @param {string} params.sessionId - Music session ID
 * @param {string} [params.clientId] - WebSocket client ID (for deduplication)
 */
export const uploadAttachment = createAsyncThunk(
  'sessionChat/uploadAttachment',
  async ({ file, sessionId, clientId }, { rejectWithValue }) => {
    try {
      // Build FormData
      const formData = new FormData();
      formData.append('files[]', file);
      formData.append('session_id', sessionId);

      // Determine attachment type based on file extension
      const ext = file.name.split('.').pop().toLowerCase();
      const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aiff', 'aifc', 'au'];
      const attachmentType = audioExts.includes(ext) ? 'audio' : 'notation';
      formData.append('attachment_type', attachmentType);

      // Upload via REST API
      const response = await uploadMusicNotation(formData);

      // Response contains file metadata, but ChatMessage arrives via WebSocket
      return response;
    } catch (error) {
      // Check for specific error types
      if (error.status === 413) {
        return rejectWithValue('File too large - maximum 10 MB');
      }
      if (error.status === 422) {
        return rejectWithValue('Invalid file type or format');
      }
      return rejectWithValue(error.message || 'Upload failed');
    }
  }
);
```

### New Selectors

```javascript
// Select upload state
export const selectUploadStatus = (state) => state.sessionChat.uploadState.status;
export const selectUploadError = (state) => state.sessionChat.uploadState.error;
export const selectUploadProgress = (state) => state.sessionChat.uploadState.progress;
export const selectUploadFileName = (state) => state.sessionChat.uploadState.fileName;
export const selectIsUploading = (state) => state.sessionChat.uploadState.status === 'uploading';
```

---

## 4. REST Helper Additions (rest.js)

### New Function: uploadMusicNotation

```javascript
/**
 * Upload file attachment to S3 via backend
 *
 * IMPORTANT: Uses native fetch, NOT apiFetch wrapper
 * Reason: FormData requires browser to set Content-Type with boundary
 *
 * @param {FormData} formData - FormData with files[], session_id, attachment_type
 * @returns {Promise<Object[]>} Array of uploaded file metadata
 */
export const uploadMusicNotation = (formData) => {
  const baseUrl = process.env.REACT_APP_API_BASE_URL || 'http://www.jamkazam.local:3000';

  return new Promise((resolve, reject) => {
    fetch(`${baseUrl}/api/music_notations`, {
      method: 'POST',
      credentials: 'include', // Include session cookie
      // Do NOT set Content-Type - browser sets it with boundary for FormData
      body: formData
    })
      .then(response => {
        if (!response.ok) {
          // Pass status to error for specific handling
          const error = new Error('Upload failed');
          error.status = response.status;
          throw error;
        }
        return response.json();
      })
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
};
```

### New Function: getMusicNotationUrl

```javascript
/**
 * Get signed S3 URL for attachment download
 *
 * Uses apiFetch because response is JSON (not FormData)
 *
 * @param {string} id - MusicNotation UUID
 * @returns {Promise<Object>} { url: "https://s3..." }
 */
export const getMusicNotationUrl = (id) => {
  return new Promise((resolve, reject) => {
    apiFetch(`/music_notations/${id}`)
      .then(response => resolve(response))
      .catch(error => reject(error));
  });
};
```

### JSDoc Documentation

```javascript
/**
 * @typedef {Object} MusicNotationUploadResponse
 * @property {string} id - UUID of created MusicNotation record
 * @property {string} file_name - Original filename
 * @property {string} file_url - Relative URL (not used directly - use getMusicNotationUrl)
 */

/**
 * @typedef {Object} MusicNotationUrlResponse
 * @property {string} url - Signed S3 URL (expires in 120 seconds)
 */
```

---

## 5. WebSocket Handler Updates (JKSessionScreen.js)

### Current handleChatMessage Transformation

**Location:** `jam-ui/src/components/client/JKSessionScreen.js` (search for `MessageType.CHAT_MESSAGE`)

**Current code (approximate):**
```javascript
case MessageType.CHAT_MESSAGE:
  const payload = message.chat_message;
  const chatMessage = {
    id: payload.msg_id,
    senderId: payload.sender_id,
    senderName: payload.sender_name,
    message: payload.msg,
    createdAt: payload.created_at,
    channel: payload.channel || 'session',
    sessionId: sessionId,
    lessonSessionId: payload.lesson_session_id
  };
  dispatch(addMessageFromWebSocket(chatMessage));
  break;
```

### Updated Code with Attachment Fields

```javascript
case MessageType.CHAT_MESSAGE:
  const payload = message.chat_message;
  const chatMessage = {
    id: payload.msg_id,
    senderId: payload.sender_id,
    senderName: payload.sender_name,
    message: payload.msg,
    createdAt: payload.created_at,
    channel: payload.channel || 'session',
    sessionId: sessionId,
    lessonSessionId: payload.lesson_session_id,

    // NEW: Attachment metadata fields
    purpose: payload.purpose,                   // 'Notation File', 'Audio File', or undefined
    attachmentId: payload.attachment_id,        // UUID or undefined
    attachmentType: payload.attachment_type,    // 'notation', 'audio', or undefined
    attachmentName: payload.attachment_name     // 'document.pdf' or undefined
  };
  dispatch(addMessageFromWebSocket(chatMessage));
  break;
```

### Code Diff

```diff
  case MessageType.CHAT_MESSAGE:
    const payload = message.chat_message;
    const chatMessage = {
      id: payload.msg_id,
      senderId: payload.sender_id,
      senderName: payload.sender_name,
      message: payload.msg,
      createdAt: payload.created_at,
      channel: payload.channel || 'session',
      sessionId: sessionId,
-     lessonSessionId: payload.lesson_session_id
+     lessonSessionId: payload.lesson_session_id,
+
+     // Attachment metadata (optional fields)
+     purpose: payload.purpose,
+     attachmentId: payload.attachment_id,
+     attachmentType: payload.attachment_type,
+     attachmentName: payload.attachment_name
    };
    dispatch(addMessageFromWebSocket(chatMessage));
    break;
```

**Impact:** +5 lines, no existing functionality affected (all new fields are optional).

---

## 6. JKChatMessage Attachment Display

### Updated Component with Attachment Handling

```javascript
// jam-ui/src/components/client/chat/JKChatMessage.js
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { formatTimestamp } from '../../../utils/formatTimestamp';
import { getMusicNotationUrl } from '../../../helpers/rest';

/**
 * Get initials for avatar from sender name
 */
const getInitials = (name) => {
  if (!name) return '?';
  const parts = name.trim().split(' ');
  if (parts.length === 1) return parts[0][0].toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};

/**
 * JKChatMessage - Individual message display component
 *
 * Supports both text messages and attachment messages
 */
const JKChatMessage = ({ message }) => {
  const { senderName, message: text, createdAt, attachmentId, attachmentName, purpose } = message;
  const [isDownloading, setIsDownloading] = useState(false);
  const [downloadError, setDownloadError] = useState(null);

  // Handle attachment click - fetch signed URL and open
  const handleAttachmentClick = useCallback(async () => {
    if (!attachmentId) return;

    try {
      setIsDownloading(true);
      setDownloadError(null);

      const response = await getMusicNotationUrl(attachmentId);
      const { url } = response;

      // Open signed URL in new tab
      window.open(url, '_blank', 'noopener,noreferrer');
    } catch (error) {
      console.error('Failed to download attachment:', error);
      setDownloadError('Failed to download file');
    } finally {
      setIsDownloading(false);
    }
  }, [attachmentId]);

  // Determine if this is an attachment message
  const isAttachmentMessage = !!attachmentId;

  return (
    <div style={{
      display: 'flex',
      gap: '12px',
      marginBottom: '16px',
      padding: '8px',
      borderRadius: '4px',
      backgroundColor: '#f8f9fa'
    }}>
      {/* Avatar */}
      <div style={{
        width: '36px',
        height: '36px',
        borderRadius: '50%',
        backgroundColor: '#007bff',
        color: 'white',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        fontWeight: 'bold',
        fontSize: '14px',
        flexShrink: 0
      }}>
        {getInitials(senderName)}
      </div>

      {/* Message content */}
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', marginBottom: '4px' }}>
          <span style={{ fontWeight: 600, fontSize: '14px', color: '#212529' }}>
            {senderName || 'Unknown'}
          </span>
          <span style={{ fontSize: '12px', color: '#6c757d' }}>
            {formatTimestamp(createdAt)}
          </span>
        </div>

        {/* Regular text message */}
        {!isAttachmentMessage && (
          <div style={{ fontSize: '14px', color: '#212529', wordWrap: 'break-word' }}>
            {text}
          </div>
        )}

        {/* Attachment message */}
        {isAttachmentMessage && (
          <div style={{ fontSize: '14px', color: '#212529' }}>
            <div style={{ marginBottom: '4px' }}>
              {text || `${senderName} shared a file`}
            </div>
            <div style={{
              display: 'flex',
              alignItems: 'center',
              gap: '8px',
              padding: '8px',
              backgroundColor: '#e9ecef',
              borderRadius: '4px',
              border: '1px solid #dee2e6'
            }}>
              {/* File icon based on purpose */}
              <span style={{ fontSize: '20px' }}>
                {purpose === 'Notation File' ? '📄' : '🎵'}
              </span>

              {/* Attachment link */}
              <button
                onClick={handleAttachmentClick}
                disabled={isDownloading}
                style={{
                  background: 'none',
                  border: 'none',
                  color: '#007bff',
                  textDecoration: 'underline',
                  cursor: isDownloading ? 'wait' : 'pointer',
                  fontSize: '14px',
                  padding: 0,
                  textAlign: 'left'
                }}
              >
                {isDownloading ? 'Downloading...' : attachmentName}
              </button>

              {/* Download error */}
              {downloadError && (
                <span style={{ fontSize: '12px', color: '#dc3545' }}>
                  {downloadError}
                </span>
              )}
            </div>
          </div>
        )}
      </div>
    </div>
  );
};

JKChatMessage.propTypes = {
  message: PropTypes.shape({
    id: PropTypes.string.isRequired,
    senderId: PropTypes.string.isRequired,
    senderName: PropTypes.string,
    message: PropTypes.string.isRequired,
    createdAt: PropTypes.string.isRequired,
    // Attachment fields (optional)
    attachmentId: PropTypes.string,
    attachmentName: PropTypes.string,
    attachmentType: PropTypes.string,
    purpose: PropTypes.string
  }).isRequired
};

export default React.memo(JKChatMessage);
```

### Visual Design

**Attachment message appearance:**
```
┌─────────────────────────────────────────┐
│ [JD] John Doe     2 minutes ago         │
│      John shared a file                 │
│      ┌────────────────────────────────┐ │
│      │ 📄  sheet-music.pdf           │ │
│      └────────────────────────────────┘ │
└─────────────────────────────────────────┘
```

**Clickable link behavior:**
1. User clicks filename
2. React fetches signed S3 URL via `getMusicNotationUrl(attachmentId)`
3. Browser opens URL in new tab
4. File downloads (or displays in browser for PDFs/images)

---

## 7. File Validation Service

### New File: attachmentValidation.js

```javascript
// jam-ui/src/services/attachmentValidation.js

/**
 * Maximum file size: 10 MB
 * Matches legacy client-side validation
 */
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB in bytes

/**
 * Allowed file extensions
 * Note: Pending mp3 backend decision (see BACKEND_VALIDATION.md)
 */
const ALLOWED_EXTENSIONS = [
  '.pdf', '.xml', '.mxl', '.txt',           // Notation
  '.png', '.jpg', '.jpeg', '.gif',          // Images
  '.mp3', '.wav'                            // Audio (pending backend mp3 support)
];

/**
 * Backend-supported extensions (from MusicNotationUploader)
 * Used for warning messages if frontend allows extensions backend doesn't
 */
const BACKEND_SUPPORTED_EXTENSIONS = [
  '.pdf', '.xml', '.mxl', '.txt',
  '.png', '.jpg', '.jpeg', '.gif',
  '.wav', '.flac', '.ogg', '.aiff', '.aifc', '.au'
];

/**
 * Audio file extensions (for attachment_type determination)
 */
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.flac', '.ogg', '.aiff', '.aifc', '.au'];

/**
 * Validate file size
 * @param {File} file - File object from input
 * @param {number} [maxSizeBytes=MAX_FILE_SIZE] - Maximum allowed size
 * @returns {Object} { valid: boolean, error: string|null }
 */
export const validateFileSize = (file, maxSizeBytes = MAX_FILE_SIZE) => {
  if (file.size > maxSizeBytes) {
    const maxSizeMB = Math.floor(maxSizeBytes / (1024 * 1024));
    return {
      valid: false,
      error: `File exceeds ${maxSizeMB} MB limit`
    };
  }
  return { valid: true, error: null };
};

/**
 * Validate file type by extension
 * @param {File} file - File object from input
 * @param {string[]} [allowedTypes=ALLOWED_EXTENSIONS] - Array of allowed extensions
 * @returns {Object} { valid: boolean, error: string|null }
 */
export const validateFileType = (file, allowedTypes = ALLOWED_EXTENSIONS) => {
  const fileName = file.name.toLowerCase();
  const hasAllowedExtension = allowedTypes.some(ext => fileName.endsWith(ext));

  if (!hasAllowedExtension) {
    return {
      valid: false,
      error: `File type not allowed. Supported: ${allowedTypes.join(', ')}`
    };
  }

  return { valid: true, error: null };
};

/**
 * Get attachment type from filename extension
 * @param {string} filename - File name with extension
 * @returns {string} 'notation' | 'audio'
 */
export const getAttachmentType = (filename) => {
  const ext = '.' + filename.split('.').pop().toLowerCase();
  return AUDIO_EXTENSIONS.includes(ext) ? 'audio' : 'notation';
};

/**
 * Comprehensive file validation (size + type)
 * @param {File} file - File object from input
 * @returns {Object} { valid: boolean, error: string|null, warnings: string[] }
 */
export const validateFile = (file) => {
  const warnings = [];

  // Validate size
  const sizeValidation = validateFileSize(file);
  if (!sizeValidation.valid) {
    return { valid: false, error: sizeValidation.error, warnings };
  }

  // Validate type
  const typeValidation = validateFileType(file);
  if (!typeValidation.valid) {
    return { valid: false, error: typeValidation.error, warnings };
  }

  // Check if backend supports this type (warning only)
  const ext = '.' + file.name.split('.').pop().toLowerCase();
  if (!BACKEND_SUPPORTED_EXTENSIONS.includes(ext)) {
    warnings.push(`File type ${ext} may not be supported by server`);
  }

  return { valid: true, error: null, warnings };
};

/**
 * Format file size for display
 * @param {number} bytes - File size in bytes
 * @returns {string} Formatted size (e.g., "2.5 MB")
 */
export const formatFileSize = (bytes) => {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

// Export constants for use in components
export {
  MAX_FILE_SIZE,
  ALLOWED_EXTENSIONS,
  BACKEND_SUPPORTED_EXTENSIONS,
  AUDIO_EXTENSIONS
};
```

### Usage in JKChatComposer

```javascript
import { validateFile } from '../../../services/attachmentValidation';

const handleFileSelect = useCallback(async (file) => {
  // Validate before upload
  const validation = validateFile(file);

  if (!validation.valid) {
    // Show error to user
    alert(validation.error); // Replace with better UI (toast, inline error)
    return;
  }

  if (validation.warnings.length > 0) {
    // Show warnings but allow upload
    console.warn('File validation warnings:', validation.warnings);
  }

  // Proceed with upload
  await dispatch(uploadAttachment({ file, sessionId, clientId }));
}, [dispatch, sessionId, clientId]);
```

---

## 8. Implementation Sequence

### Phase 13: Upload Infrastructure (REQ-1.*, REQ-6.*, REQ-7.1)

**Objective:** User can select and upload files from chat composer

**Tasks:**
1. Create `JKChatAttachButton` component with file input
2. Create `attachmentValidation.js` service with size/type validation
3. Add REST helpers: `uploadMusicNotation`, `getMusicNotationUrl`
4. Extend `sessionChatSlice` with upload state and `uploadAttachment` thunk
5. Integrate attach button into `JKChatComposer`
6. Display upload progress/errors in `JKChatComposer`
7. Write unit tests for validation service
8. Write integration tests for upload flow

**Estimated Duration:** 2-3 plans

**TDD Requirements:**
- Unit tests: attachmentValidation.js (100% coverage)
- Redux tests: uploadAttachment thunk, upload reducers
- Integration tests: file selection, validation, upload API call

---

### Phase 14: Message Display & Download (REQ-2.*, REQ-4.*, REQ-7.2-3)

**Objective:** Users can see and download attachments in chat

**Tasks:**
1. Update `JKSessionScreen` WebSocket handler to extract attachment fields
2. Modify `JKChatMessage` to detect and render attachment messages
3. Add attachment link click handler with signed URL fetch
4. Display file icons based on attachment type
5. Handle download errors gracefully
6. Test attachment display with various file types
7. Write integration tests for download flow

**Estimated Duration:** 1-2 plans

**TDD Requirements:**
- Unit tests: JKChatMessage attachment rendering
- Integration tests: WebSocket message with attachments, download flow

---

### Phase 15: WebSocket Sync & Deduplication (REQ-3.*)

**Objective:** Attachment messages sync properly across all session participants

**Tasks:**
1. Verify WebSocket broadcast includes attachment metadata
2. Test message deduplication (upload response + WebSocket message)
3. Test multi-user scenarios (sender sees message, receivers see message)
4. Test edge cases (upload success, WebSocket failure)
5. Test edge cases (upload failure, WebSocket still broadcasts)

**Estimated Duration:** 1 plan

**TDD Requirements:**
- Integration tests: Multi-client WebSocket scenarios
- E2E tests: Complete upload-broadcast-receive flow

---

### Phase 16: Error Handling & Edge Cases (REQ-5.*)

**Objective:** Robust error handling and polished UX

**Tasks:**
1. Handle all upload error types (413, 422, network errors)
2. Add retry capability for failed uploads
3. Handle disconnection during upload
4. Display helpful error messages
5. Test quota exceeded scenarios
6. Test invalid file scenarios
7. UAT execution across all requirements

**Estimated Duration:** 1-2 plans

**TDD Requirements:**
- Unit tests: Error message formatting, retry logic
- Integration tests: All error scenarios
- E2E tests: Complete happy path + error paths

---

## 9. Testing Strategy

### Unit Tests

**attachmentValidation.js (Jest):**
```javascript
describe('validateFileSize', () => {
  test('accepts file under limit', () => {
    const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
    Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 }); // 5 MB
    expect(validateFileSize(file)).toEqual({ valid: true, error: null });
  });

  test('rejects file over limit', () => {
    const file = new File(['content'], 'large.pdf', { type: 'application/pdf' });
    Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }); // 15 MB
    const result = validateFileSize(file);
    expect(result.valid).toBe(false);
    expect(result.error).toContain('10 MB');
  });
});

describe('validateFileType', () => {
  test('accepts pdf file', () => {
    const file = new File(['content'], 'document.pdf');
    expect(validateFileType(file)).toEqual({ valid: true, error: null });
  });

  test('rejects exe file', () => {
    const file = new File(['content'], 'virus.exe');
    const result = validateFileType(file);
    expect(result.valid).toBe(false);
    expect(result.error).toContain('not allowed');
  });
});
```

### Integration Tests (Playwright)

**test/attachments/file-upload.spec.ts:**
```typescript
import { test, expect } from '@playwright/test';
import { APIInterceptor } from '../helpers/apiInterceptor';

test('upload pdf file to session', async ({ page }) => {
  const interceptor = new APIInterceptor();
  await interceptor.intercept(page);

  // Navigate to session
  await page.goto('/session/test-session-123');

  // Open chat window
  await page.click('[data-testid="chat-button"]');

  // Click attach button
  await page.click('[aria-label="Attach file"]');

  // Upload file (simulate file selection)
  const fileInput = await page.locator('input[type="file"]');
  await fileInput.setInputFiles({
    name: 'test-document.pdf',
    mimeType: 'application/pdf',
    buffer: Buffer.from('fake pdf content')
  });

  // Wait for upload API call
  await page.waitForTimeout(1000);
  const uploadCalls = interceptor.getCallsByPath('/api/music_notations');
  expect(uploadCalls.length).toBe(1);
  expect(uploadCalls[0].method).toBe('POST');

  // Verify FormData includes correct fields
  // (Note: Playwright makes FormData inspection difficult, may need backend logs)
});

test('display attachment message in chat', async ({ page }) => {
  // Mock WebSocket message with attachment
  await page.evaluate(() => {
    window.__REDUX_STORE__.dispatch({
      type: 'sessionChat/addMessageFromWebSocket',
      payload: {
        id: 'msg-123',
        senderId: 'user-456',
        senderName: 'John Doe',
        message: 'John shared a file',
        createdAt: new Date().toISOString(),
        attachmentId: 'notation-789',
        attachmentName: 'sheet-music.pdf',
        attachmentType: 'notation',
        purpose: 'Notation File'
      }
    });
  });

  // Verify attachment displays
  await expect(page.locator('text=sheet-music.pdf')).toBeVisible();
  await expect(page.locator('text=📄')).toBeVisible(); // File icon
});
```

### E2E Tests

**test/e2e/session-attachment-flow.spec.ts:**
```typescript
test('complete attachment flow: upload, broadcast, download', async ({ page, context }) => {
  // User A uploads file
  await page.goto('/session/test-session-123');
  await page.click('[data-testid="chat-button"]');
  await uploadFile(page, 'document.pdf');

  // User B opens session in new tab
  const page2 = await context.newPage();
  await page2.goto('/session/test-session-123');
  await page2.click('[data-testid="chat-button"]');

  // Verify User B sees attachment message
  await expect(page2.locator('text=document.pdf')).toBeVisible();

  // User B clicks to download
  await page2.click('text=document.pdf');

  // Verify signed URL fetch
  await page2.waitForResponse(resp => resp.url().includes('/api/music_notations/'));
  // Note: Can't verify S3 download in test, but can verify URL fetch
});
```

---

## 10. Component Integration Map

### Data Flow Diagram

```
┌─────────────────┐
│  User clicks    │
│  Attach button  │
└────────┬────────┘
         │
         ▼
┌─────────────────────────┐
│ JKChatAttachButton      │
│ - Triggers file input   │
│ - Calls onFileSelect    │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ JKChatComposer          │
│ - Validates file        │
│ - Dispatches upload     │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ sessionChatSlice        │
│ - uploadAttachment      │
│   thunk executes        │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ uploadMusicNotation     │
│ (rest.js)               │
│ - POST FormData to API  │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ Rails Backend           │
│ - Saves to S3           │
│ - Creates ChatMessage   │
│ - Broadcasts WebSocket  │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ JKSessionScreen         │
│ - Receives WebSocket    │
│ - Extracts attachment   │
│   metadata              │
│ - Dispatches to Redux   │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ sessionChatSlice        │
│ - addMessageFromWS      │
│ - Stores message with   │
│   attachment fields     │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ JKChatMessage           │
│ - Detects attachment    │
│ - Renders file link     │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ User clicks filename    │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ getMusicNotationUrl     │
│ (rest.js)               │
│ - GET signed S3 URL     │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│ window.open(url)        │
│ - Opens in new tab      │
│ - Browser downloads     │
└─────────────────────────┘
```

---

## 11. Requirements Coverage Matrix

| Requirement | Phase | Components Modified | Testing Strategy |
|-------------|-------|---------------------|------------------|
| REQ-1.1: Attach button in composer | 13 | JKChatComposer, JKChatAttachButton | Unit, Integration |
| REQ-1.2: File type validation | 13 | attachmentValidation.js | Unit (100%) |
| REQ-1.3: File size validation | 13 | attachmentValidation.js | Unit (100%) |
| REQ-1.4: Upload progress | 13 | sessionChatSlice, JKChatComposer | Integration |
| REQ-2.1: Display attachments | 14 | JKChatMessage | Unit, Integration |
| REQ-2.2: File type icons | 14 | JKChatMessage | Unit |
| REQ-2.3: Downloadable links | 14 | JKChatMessage, rest.js | Integration |
| REQ-3.1: WebSocket broadcast | 15 | JKSessionScreen | Integration |
| REQ-3.2: Message deduplication | 15 | sessionChatSlice | Unit, Integration |
| REQ-4.1: Chat history includes attachments | 14 | REST API (existing) | Integration |
| REQ-4.2: Attachment metadata persists | 14 | Backend (existing) | Integration |
| REQ-5.1: Size limit errors | 16 | attachmentValidation.js | Unit, E2E |
| REQ-5.2: Type errors | 16 | attachmentValidation.js | Unit, E2E |
| REQ-5.3: Upload errors | 16 | uploadAttachment thunk | Integration, E2E |
| REQ-5.4: Network errors | 16 | uploadAttachment thunk | Integration |
| REQ-6.1: Backend storage | N/A | Backend (existing) | Validated |
| REQ-6.2: S3 integration | N/A | Backend (existing) | Validated |
| REQ-7.1: Client validation | 13 | attachmentValidation.js | Unit |
| REQ-7.2: Server validation | N/A | Backend (existing) | Integration |
| REQ-7.3: Signed URLs | 14 | rest.js, Backend | Integration |

**Coverage:** 19/19 requirements mapped to implementation phases (100%)

---

## 12. Performance Considerations

### Upload Performance

**File size impact:**
- 1 MB file: ~2-3 seconds on good connection
- 5 MB file: ~10-15 seconds on good connection
- 10 MB file: ~20-30 seconds on good connection

**Optimization strategies:**
- Client-side validation fails fast (no network wait)
- Single upload per interaction (no batch upload complexity)
- Disable attach button during upload (prevent duplicates)

### Download Performance

**Signed URL caching:**
- URLs expire in 120 seconds (backend configured)
- No client-side caching needed (backend handles)
- Each click fetches fresh URL (prevents expired URL errors)

**Browser behavior:**
- PDFs: Often display in browser (fast)
- Images: Display in browser (fast)
- Audio: Browser may play inline or download
- Other: Triggers download

---

## 13. Accessibility

### Keyboard Navigation

- Attach button: Tab-accessible, Enter/Space to activate
- File input: Hidden but screen reader accessible
- Attachment link: Tab-accessible, Enter to open

### ARIA Labels

```javascript
// Attach button
<button aria-label="Attach file" aria-describedby="attach-help">

// File input
<input type="file" aria-label="Select file to attach" aria-hidden="true" />

// Attachment link
<a href="#" aria-label={`Download ${attachmentName}`}>
```

### Screen Reader Experience

- "Attach file button"
- "Select file to attach"
- "File selected: document.pdf"
- "Uploading document.pdf"
- "Upload complete"
- "John Doe shared a file: document.pdf. Download link."

---

## 14. Open Questions & Decisions

### Q1: Upload Progress Indicator

**Question:** Do we need real-time upload progress (0-100%) or simple "Uploading..." state?

**Options:**
1. **Simple state (recommended):** "Uploading..." text, no percentage
   - Pros: Easy to implement (no XMLHttpRequest needed)
   - Cons: User doesn't know how much is remaining
2. **Real progress:** "Uploading... 45%"
   - Pros: Better UX for large files
   - Cons: Requires XMLHttpRequest instead of fetch (more complex)

**Recommendation:** Start with simple state (Phase 13), add real progress if users request it (future enhancement).

---

### Q2: Multiple File Upload

**Question:** Should users be able to select multiple files at once?

**Backend supports:** Yes (uses `files[]` array)
**Requirements specify:** Not explicitly stated

**Options:**
1. **Single file (recommended):** One file per attach click
   - Pros: Simpler UX, easier to implement
   - Cons: Users must click attach multiple times
2. **Multiple files:** Select multiple at once
   - Pros: Faster for users with many files
   - Cons: More complex validation, error handling, progress tracking

**Recommendation:** Implement single file for v1.2 (Phase 13), add multiple in future if requested.

---

### Q3: Drag-and-Drop Support

**Question:** Should users be able to drag files onto chat window?

**Requirements specify:** No
**Common in modern chat:** Yes (Discord, Slack, etc.)

**Options:**
1. **No drag-drop (recommended):** Only attach button
   - Pros: Simpler implementation, meets requirements
   - Cons: Less convenient for power users
2. **Add drag-drop:** Overlay on chat window
   - Pros: Better UX, modern pattern
   - Cons: Requires additional library or complex event handling

**Recommendation:** Defer to future enhancement (not in Phase 12-16 scope).

---

### Q4: Attachment Deletion

**Question:** Should users be able to delete attachments after uploading?

**Backend supports:** Yes (DELETE /api/music_notations/:id)
**Requirements specify:** No

**Recommendation:** Defer to future enhancement. Implement backend authorization checks first.

---

## 15. Risk Assessment

| Risk | Severity | Mitigation |
|------|----------|------------|
| mp3 backend mismatch | MEDIUM | Resolve before Phase 13 (backend update or requirements change) |
| Large file upload timeout | LOW | 10 MB client-side limit prevents most timeouts |
| Signed URL expiration | LOW | 120s is sufficient for download click, no caching needed |
| FormData browser compatibility | LOW | Supported in all modern browsers (IE11+) |
| WebSocket message ordering | MEDIUM | Rely on existing message deduplication in sessionChatSlice |
| Multiple upload race condition | LOW | Disable attach button during upload, prevent concurrent uploads |

---

## Summary

This design provides a complete blueprint for integrating file attachments into jam-ui session chat. The implementation follows established patterns (Redux thunks, WindowPortal, WebSocket handlers), extends existing components minimally, and requires zero backend changes (pending mp3 decision).

**Key Integration Points:**
1. `JKChatComposer.js`: +80 lines for attach button and upload handling
2. `JKChatMessage.js`: +40 lines for attachment display and download
3. `JKSessionScreen.js`: +10 lines for WebSocket attachment field extraction
4. `sessionChatSlice.js`: +120 lines for upload state and thunk
5. `rest.js`: +40 lines for upload/download API helpers
6. New `JKChatAttachButton.js`: 60 lines
7. New `attachmentValidation.js`: 80 lines

**Total effort:** ~430 lines of production code, ~200 lines of tests, estimated 6-8 plans across Phases 13-16.

**Next Step:** Begin Phase 13 implementation with TDD approach (tests first, then code).

---

**Document Version:** 1.0
**Last Updated:** 2026-02-02
**Author:** Phase 12 Plan 02 Execution
**Total Lines:** 1069
