# 005: Assistant Song Generation (MVP)

## Overview

Implement the first version of Orphy, the AI assistant, with a **single capability: song generation**. Users can mention `@suno` in messages to request song creation. The assistant will use OpenAI for intent classification and the Suno API to generate songs, creating atom records that appear as rows beneath the assistant's response message.

This phase assumes 002 (core features) and 004 (atoms system) are complete.

## Goals

- Detect `@suno` mentions in messages
- Use OpenAI to classify user intent and extract song generation parameters
- Generate 2 songs via Suno API for each request
- Create atom records and attach them to assistant response
- Display songs as rows beneath the assistant message
- Poll Suno API for status updates and update atoms in real-time
- Handle errors gracefully with user-friendly messages
- **Implement double loading states for optimal UX**

## Key UX Consideration: Double Loading States

This implementation features **two distinct loading states** that provide clear feedback to users:

1. **Assistant Thinking** (1-3 seconds): Shows "Orphy is thinking..." while OpenAI processes the user's request and determines what to do.

2. **Songs Generating** (30-90 seconds): Shows individual spinners and status for each of the 2 songs while Suno API creates them.

These states are independent and sequential. The assistant message appears between them, providing immediate feedback that the request was understood before the lengthy song generation begins.

## Architecture

### High-Level Flow

```
User Message (@suno "create a chill lofi beat")
  ↓
Message Handler (detects @suno mention)
  ↓
[LOADING STATE 1: Assistant Thinking] 🤔
  ↓
OpenAI Intent Classifier (extracts: prompt, tags, instrumental)
  ↓
Generate Song Action (calls Suno API, creates 2 atoms)
  ↓
Assistant Response Mutation (creates message with atom references)
  ↓
[LOADING STATE 2: Songs Generating] 🎵
  ↓
Poll Song Status (scheduled function updates atoms periodically)
  ↓
UI Updates (SongAtomRow components re-render with new status)
```

### Double Loading States

There are **two distinct loading states** in this flow:

1. **Assistant Thinking** (`assistantStatus: 'processing'`)
   - Duration: ~1-3 seconds (OpenAI API call)
   - Display: Spinner in assistant message bubble, "Orphy is thinking..."
   - Triggered: When message handler detects @suno mention
   - Ends: When assistant response message is created with atoms attached

2. **Songs Generating** (atom `status: 'pending' | 'processing' | 'streaming'`)
   - Duration: ~30-90 seconds (Suno API generation)
   - Display: Individual loading indicators in each `<SongAtomRow />`
   - Triggered: When Suno API returns song IDs
   - Ends: When each atom reaches `status: 'completed'` or `'failed'`

**Key Distinction:**
- State 1 = Assistant is deciding what to do (OpenAI processing)
- State 2 = Songs are being created (Suno API processing)

Users see the assistant message appear after State 1, then watch the songs generate in State 2.

### Visual Flow Diagram

```
USER TYPES: "@suno create a chill lofi beat"
      ↓
═══════════════════════════════════════════════════════════
LOADING STATE 1: Assistant Thinking 🤔 (~1-3 seconds)
═══════════════════════════════════════════════════════════
UI shows: "Orphy is thinking..." with spinner
      ↓
OpenAI classifies intent → extracts prompt/tags
      ↓
Suno API called → returns 2 song IDs immediately
      ↓
ASSISTANT MESSAGE APPEARS with placeholder content
Message: "🎵 Creating 2 songs: 'a chill lofi beat'. I'll let you know when they're ready!"
      ↓
═══════════════════════════════════════════════════════════
LOADING STATE 2: Songs Generating 🎵 (~30-90 seconds)
═══════════════════════════════════════════════════════════
UI shows: 2 SongAtomRow components beneath assistant message
      ↓
┌─────────────────────────────────────┐
│ [Spinner] Generating...             │  ← Song 1 (status: pending)
│ ⏳ Queued...                         │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ [Spinner] Generating...             │  ← Song 2 (status: pending)
│ ⏳ Queued...                         │
└─────────────────────────────────────┘
      ↓
Polling updates (every 2 seconds)...
      ↓
┌─────────────────────────────────────┐
│ [Spinner] Love Song                 │  ← Song 1 (status: streaming)
│ 📡 Finalizing...                    │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ [Album Art] Chill Vibes             │  ← Song 2 (status: complete)
│ AI Artist · 2:15 [▶️]               │
└─────────────────────────────────────┘
      ↓
Both songs complete
      ↓
┌─────────────────────────────────────┐
│ [Album Art] Love Song               │  ← Song 1 (status: complete)
│ AI Artist · 2:30 [▶️]               │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ [Album Art] Chill Vibes             │  ← Song 2 (status: complete)
│ AI Artist · 2:15 [▶️]               │
└─────────────────────────────────────┘
```

## Schema Extensions

### Messages Table

Add assistant-specific fields:

```typescript
// convex/schema.ts
messages: defineTable({
  // ... existing fields (roomId, userId, content, timestamp, mentions, reactions)

  // Assistant fields
  isAssistantMessage: v.optional(v.boolean()), // true if sent by Orphy
  atomReferences: v.optional(v.array(v.id('atoms'))), // Attached atoms
  assistantStatus: v.optional(
    v.union(
      v.literal('processing'), // Generating songs
      v.literal('completed'),  // Songs generated
      v.literal('failed')      // Generation failed
    )
  ),
})
```

### Atoms Table

Add new table for atoms:

```typescript
// convex/schema.ts
atoms: defineTable({
  type: v.union(
    v.literal('song'),
    v.literal('video'),
    v.literal('image'),
    v.literal('lyrics'),
    v.literal('webview')
  ),
  ownerId: v.id('users'),
  spaceId: v.id('spaces'),
  metadata: v.any(), // Type-specific metadata (JSON)
  status: v.union(
    v.literal('pending'),
    v.literal('processing'),
    v.literal('streaming'),
    v.literal('completed'),
    v.literal('failed')
  ),
  progress: v.number(), // 0-100
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_space', ['spaceId'])
  .index('by_owner', ['ownerId'])
  .index('by_status', ['status'])
  .index('by_space_and_type', ['spaceId', 'type'])
```

**Song Atom Metadata Structure:**

```typescript
{
  sunoClipId: string;        // Suno clip ID (for remixing later)
  title: string;
  artist?: string;
  audioUrl?: string;
  videoUrl?: string;
  albumArtUrl?: string;
  duration?: number;         // Only available when status='complete'
  sunoStatus: string;        // Raw Suno status (submitted/queued/streaming/complete/error)
  tags?: string;             // Genre tags used in generation
  prompt?: string;           // Original prompt
}
```

## Backend Implementation (Convex)

### 1. Message Handler

Detect `@suno` mentions and trigger assistant flow.

**File:** `convex/assistant.ts`

```typescript
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const handleMessage = mutation({
  args: {
    messageId: v.id('messages'),
  },
  handler: async (ctx, args) => {
    const message = await ctx.db.get(args.messageId);
    if (!message) throw new Error('Message not found');

    // Check if message mentions @suno
    const mentionsSuno = message.mentions?.includes('orphy' as any);
    if (!mentionsSuno) return;

    // Get room and space context
    const room = await ctx.db.get(message.roomId);
    if (!room) throw new Error('Room not found');

    // Schedule assistant action (LOADING STATE 1 BEGINS HERE)
    // User will see a thinking indicator until assistant response is created
    await ctx.scheduler.runAfter(0, internal.assistant.processAssistantRequest, {
      messageId: message._id,
      roomId: message.roomId,
      spaceId: room.spaceId,
      userId: message.userId,
      content: message.content,
    });
  },
});
```

### 2. OpenAI Intent Classifier

Use OpenAI to extract song generation parameters from user message.

**File:** `convex/assistant.ts`

```typescript
import { action } from './_generated/server';
import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_KEY,
});

export const classifyIntent = action({
  args: {
    messageContent: v.string(),
  },
  handler: async (ctx, args) => {
    const response = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        {
          role: 'system',
          content: ASSISTANT_SYSTEM_PROMPT,
        },
        {
          role: 'user',
          content: args.messageContent,
        },
      ],
      tools: [GENERATE_SONG_TOOL],
      tool_choice: 'auto',
    });

    const toolCall = response.choices[0].message.tool_calls?.[0];
    if (!toolCall || toolCall.function.name !== 'generate_song') {
      return null;
    }

    return JSON.parse(toolCall.function.arguments);
  },
});

const ASSISTANT_SYSTEM_PROMPT = `You are Orphy, an AI assistant for Suno Spaces. Your job is to help users create music.

When a user mentions @suno, extract their song generation request:
- prompt: The creative description/lyrics for the song
- tags: Music genre/style tags (e.g., "lofi, chill, jazz", "rock, energetic")
- makeInstrumental: Whether to create an instrumental version (default: false)

Be creative but accurate. If the user provides lyrics, use them as the prompt. If they describe a vibe or genre, extract appropriate tags.

Examples:
- "create a chill lofi beat" → prompt: "chill lofi beat", tags: "lofi, chill, instrumental"
- "make a song about summer with these lyrics: [lyrics]" → prompt: "[lyrics]", tags: "pop, summer"
- "instrumental jazz piano" → prompt: "jazz piano", tags: "jazz, piano, instrumental", makeInstrumental: true`;

const GENERATE_SONG_TOOL = {
  type: 'function' as const,
  function: {
    name: 'generate_song',
    description: 'Generate a new song from a text prompt',
    parameters: {
      type: 'object',
      properties: {
        prompt: {
          type: 'string',
          description: 'The creative prompt/lyrics for song generation',
        },
        tags: {
          type: 'string',
          description: 'Comma-separated genre/style tags (e.g., "lofi, chill, jazz")',
        },
        makeInstrumental: {
          type: 'boolean',
          description: 'Whether to create an instrumental version',
          default: false,
        },
      },
      required: ['prompt'],
    },
  },
};
```

### 3. Song Generation Action

Call Suno API and create atom records.

**File:** `convex/atoms.ts`

```typescript
import { action, mutation, internalMutation } from './_generated/server';
import { v } from 'convex/values';
import { internal } from './_generated/api';
import { createSunoClient } from '../lib/sunoClient';

export const generateSong = action({
  args: {
    prompt: v.string(),
    tags: v.optional(v.string()),
    makeInstrumental: v.optional(v.boolean()),
    spaceId: v.id('spaces'),
    userId: v.id('users'),
  },
  handler: async (ctx, args) => {
    const client = createSunoClient();

    try {
      // 1. Generate songs via Suno API (returns 2 song IDs)
      const songIds = await client.generateSongs({
        prompt: args.prompt,
        makeInstrumental: args.makeInstrumental ?? false,
      });

      // 2. Create atom records for both songs
      const atomIds = await Promise.all(
        songIds.map(songId =>
          ctx.runMutation(internal.atoms.createInternal, {
            type: 'song',
            spaceId: args.spaceId,
            ownerId: args.userId,
            metadata: {
              sunoClipId: songId,
              title: 'Generating...',
              prompt: args.prompt,
              tags: args.tags,
            },
            status: 'pending',
            progress: 0,
          })
        )
      );

      // 3. Schedule polling
      await ctx.scheduler.runAfter(0, internal.atoms.pollSongStatus, {
        atomIds,
      });

      return atomIds;
    } catch (error) {
      console.error('Song generation failed:', error);
      throw error;
    }
  },
});

export const createInternal = internalMutation({
  args: {
    type: v.string(),
    spaceId: v.id('spaces'),
    ownerId: v.id('users'),
    metadata: v.any(),
    status: v.string(),
    progress: v.number(),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert('atoms', {
      ...args,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

export const pollSongStatus = internalMutation({
  args: {
    atomIds: v.array(v.id('atoms')),
  },
  handler: async (ctx, args) => {
    // Get atoms
    const atoms = await Promise.all(args.atomIds.map(id => ctx.db.get(id)));

    // Filter active atoms
    const activeAtoms = atoms.filter(
      atom => atom && !['completed', 'failed'].includes(atom.status)
    );

    if (activeAtoms.length === 0) return;

    // Poll Suno API
    const client = createSunoClient();
    const sunoIds = activeAtoms.map(a => a.metadata.sunoClipId);
    const songs = await client.getSongStatus(sunoIds);

    // Update atoms
    for (let i = 0; i < activeAtoms.length; i++) {
      const atom = activeAtoms[i];
      const song = songs[i];

      await ctx.db.patch(atom._id, {
        metadata: {
          ...atom.metadata,
          title: song.title || atom.metadata.title,
          artist: song.artist,
          audioUrl: song.audioUrl,
          videoUrl: song.videoUrl,
          albumArtUrl: song.albumArtUrl,
          duration: song.duration,
          sunoStatus: song.status,
        },
        status: song.status,
        progress:
          song.status === 'completed' ? 100 :
          song.status === 'streaming' ? 75 :
          song.status === 'queued' ? 25 : 50,
        updatedAt: Date.now(),
      });
    }

    // Schedule next poll if needed
    const stillActive = songs.some(
      s => !['complete', 'error', 'failed'].includes(s.status)
    );

    if (stillActive) {
      await ctx.scheduler.runAfter(2000, internal.atoms.pollSongStatus, {
        atomIds: args.atomIds,
      });
    }
  },
});
```

### 4. Assistant Response Creator

Create assistant message with atom references.

**File:** `convex/assistant.ts`

```typescript
export const processAssistantRequest = internalAction({
  args: {
    messageId: v.id('messages'),
    roomId: v.id('rooms'),
    spaceId: v.id('spaces'),
    userId: v.id('users'),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    try {
      // 1. Classify intent
      const params = await ctx.runAction(internal.assistant.classifyIntent, {
        messageContent: args.content,
      });

      if (!params) {
        // Not a song generation request
        await ctx.runMutation(internal.assistant.createAssistantMessage, {
          roomId: args.roomId,
          content: "I can help you create songs! Try: @suno create a chill lofi beat",
          assistantStatus: 'completed',
        });
        return;
      }

      // 2. Create placeholder response (LOADING STATE 1 ENDS HERE)
      const responseMessageId = await ctx.runMutation(
        internal.assistant.createAssistantMessage,
        {
          roomId: args.roomId,
          content: `🎵 Creating 2 songs: "${params.prompt}". I'll let you know when they're ready!`,
          assistantStatus: 'processing',
        }
      );

      // 3. Generate songs
      const atomIds = await ctx.runAction(internal.atoms.generateSong, {
        prompt: params.prompt,
        tags: params.tags,
        makeInstrumental: params.makeInstrumental,
        spaceId: args.spaceId,
        userId: args.userId,
      });

      // 4. Attach atoms to response message (LOADING STATE 2 BEGINS HERE)
      // Assistant status becomes 'completed' but atoms are still 'pending'
      await ctx.runMutation(internal.assistant.updateAssistantMessage, {
        messageId: responseMessageId,
        atomReferences: atomIds,
        assistantStatus: 'completed',
      });

    } catch (error) {
      console.error('Assistant request failed:', error);
      await ctx.runMutation(internal.assistant.createAssistantMessage, {
        roomId: args.roomId,
        content: "😔 Something went wrong with song generation. Let's try again?",
        assistantStatus: 'failed',
      });
    }
  },
});

export const createAssistantMessage = internalMutation({
  args: {
    roomId: v.id('rooms'),
    content: v.string(),
    assistantStatus: v.string(),
    atomReferences: v.optional(v.array(v.id('atoms'))),
  },
  handler: async (ctx, args) => {
    // Create a system user for Orphy if needed
    const ORPHY_USER_ID = 'system_orphy' as any; // TODO: Create proper system user

    return await ctx.db.insert('messages', {
      roomId: args.roomId,
      userId: ORPHY_USER_ID,
      content: args.content,
      timestamp: Date.now(),
      isAssistantMessage: true,
      assistantStatus: args.assistantStatus,
      atomReferences: args.atomReferences,
    });
  },
});

export const updateAssistantMessage = internalMutation({
  args: {
    messageId: v.id('messages'),
    content: v.optional(v.string()),
    atomReferences: v.optional(v.array(v.id('atoms'))),
    assistantStatus: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { messageId, ...updates } = args;
    await ctx.db.patch(messageId, updates);
  },
});
```

## Frontend Implementation

### 1. Message Component Update

Detect assistant messages and render accordingly.

**File:** `components/Message.tsx`

```tsx
import { SongAtomRow } from './SongAtomRow';
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

export function Message({ message }) {
  const atoms = useQuery(
    api.atoms.getByIds,
    message.atomReferences ? { ids: message.atomReferences } : 'skip'
  );

  const isAssistant = message.isAssistantMessage;

  return (
    <div className={`message ${isAssistant ? 'assistant-message' : ''}`}>
      <div className="message-header">
        {isAssistant && <span className="assistant-badge">🎵 Orphy</span>}
        <span className="author">{message.userId}</span>
      </div>

      <div className="message-content">{message.content}</div>

      {/* Render attached atoms */}
      {atoms && atoms.length > 0 && (
        <div className="message-atoms">
          {atoms.map(atom => (
            <SongAtomRow key={atom._id} atom={atom} />
          ))}
        </div>
      )}

      {/* LOADING STATE 1: Show assistant thinking indicator */}
      {message.assistantStatus === 'processing' && (
        <div className="assistant-thinking">
          <LoadingSpinner />
          <span className="thinking-text">Orphy is thinking...</span>
        </div>
      )}
    </div>
  );
}
```

### 2. Song Atom Row Component

Display individual song atoms with status-aware rendering.

**File:** `components/SongAtomRow.tsx`

```tsx
import { useState } from 'react';
import { Play, Pause, Clock, AlertCircle } from 'lucide-react';

export function SongAtomRow({ atom }) {
  const [isPlaying, setIsPlaying] = useState(false);

  const metadata = atom.metadata;
  const isPlayable = atom.status === 'completed' && metadata.audioUrl;
  const isGenerating = ['pending', 'processing', 'streaming'].includes(atom.status);
  const hasFailed = atom.status === 'failed';

  const handlePlayPause = () => {
    if (!isPlayable) return;
    // TODO: Integrate with room playback system
    setIsPlaying(!isPlaying);
  };

  return (
    <div className="song-atom-row">
      {/* Album Art */}
      <div className="album-art">
        {metadata.albumArtUrl ? (
          <img src={metadata.albumArtUrl} alt={metadata.title} />
        ) : (
          <div className="album-art-placeholder">
            {/* LOADING STATE 2: Show song generation spinner */}
            {isGenerating && <LoadingSpinner />}
            {hasFailed && <AlertCircle className="error-icon" />}
          </div>
        )}
      </div>

      {/* Song Info */}
      <div className="song-info">
        <div className="song-title">{metadata.title || 'Generating...'}</div>
        {metadata.artist && (
          <div className="song-artist">{metadata.artist}</div>
        )}
        {/* LOADING STATE 2: Show generation status */}
        {isGenerating && (
          <div className="generation-status">
            {atom.status === 'pending' && '⏳ Queued...'}
            {atom.status === 'processing' && '🎵 Generating...'}
            {atom.status === 'streaming' && '📡 Finalizing...'}
          </div>
        )}
        {hasFailed && (
          <div className="error-message">Generation failed</div>
        )}
      </div>

      {/* Duration */}
      {metadata.duration && (
        <div className="song-duration">
          <Clock size={14} />
          {formatDuration(metadata.duration)}
        </div>
      )}

      {/* Play Button */}
      <button
        className="play-button"
        onClick={handlePlayPause}
        disabled={!isPlayable}
      >
        {isPlaying ? <Pause size={20} /> : <Play size={20} />}
      </button>

      {/* Progress Bar (while generating) */}
      {isGenerating && (
        <div className="progress-bar">
          <div
            className="progress-fill"
            style={{ width: `${atom.progress}%` }}
          />
        </div>
      )}
    </div>
  );
}

function formatDuration(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs.toString().padStart(2, '0')}`;
}
```

### 3. Mention Detection in Message Composer

Detect `@suno` mentions while typing.

**File:** `components/MessageComposer.tsx` (update)

```tsx
// Add to existing MessageComposer
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  const value = e.target.value;
  setMessage(value);

  // Detect @suno mention
  if (value.includes('@suno')) {
    setMentions(['orphy']);
  }
};

// Update sendMessage to include mentions
const sendMessage = async () => {
  if (!message.trim()) return;

  await createMessage({
    roomId: currentRoom._id,
    content: message,
    mentions: mentions, // Pass mentions array
  });

  setMessage('');
  setMentions([]);
};
```

## Suno API Integration Details

### Request Format (Minimal)

```typescript
// POST /api/generate/v2-web
{
  "prompt": "love love love\nlove love love\nlove love love",
  "tags": "neosoul, r&b, groove, funk",
  "mv": "chirp-crow",
  "make_instrumental": false
}
```

**Fields to Use:**
- `prompt`: User's lyrics or creative description
- `tags`: Genre/style tags extracted by OpenAI
- `mv`: Model version (use `"chirp-crow"`)
- `make_instrumental`: Boolean flag

**Fields to Ignore (for now):**
- `negative_tags`, `user_uploaded_images_b64`, `metadata`, `override_fields`, `cover_clip_id`, `persona_id`, `artist_clip_id`, `continue_clip_id`, `transaction_uuid`

### Response Format

```typescript
{
  "clips": [
    { "id": "clip-id-1", "status": "submitted", "created_at": "..." },
    { "id": "clip-id-2", "status": "submitted", "created_at": "..." }
  ]
}
```

**Always returns 2 songs.** Store both `sunoClipId` values in atom metadata.

### Status Polling

```typescript
// GET /api/feed/v3?ids=clip-id-1,clip-id-2
{
  "clips": [
    {
      "id": "clip-id-1",
      "title": "Love Song",
      "display_name": "AI Artist",
      "audio_url": "https://...",
      "video_url": "https://...",
      "image_url": "https://...",
      "duration": 125.5,
      "status": "complete",
      "created_at": "..."
    },
    { "id": "clip-id-2", "status": "streaming", ... }
  ]
}
```

**Status Progression:**
- `submitted` → `queued` → `streaming` → `complete`
- Or: `submitted` → `error`/`failed`

**Polling Strategy:**
- Poll every 5 seconds
- Stop when all songs reach terminal state (`complete`, `error`, `failed`)
- Song is playable when status > `streaming`
- Duration only available when `complete`

## Environment Setup

### Convex Environment Variables

Set in Convex dashboard (https://dashboard.convex.dev):

```
SUNO_BASE_URL=https://studio-api.suno.ai
SUNO_SESSION_TOKEN=<your-session-token>
OPENAI_KEY=<your-openai-key>
```

### Local Development

For local development, create `.env.local` (note: user mentioned OpenAI key is in `.env.local` under `OPENAI_KEY`):

```
OPENAI_KEY=<your-openai-key>
```

## Error Handling

### Common Errors

1. **Suno API Rate Limiting**
   - Response: "⏰ I'm a bit overwhelmed right now. Can you try again in a few minutes?"
   - Status: `failed`

2. **Invalid Prompt (Content Guidelines)**
   - Response: "❌ I couldn't process that prompt. Can you try rephrasing?"
   - Status: `failed`

3. **OpenAI API Error**
   - Response: "🤔 I didn't quite understand that. Can you try again?"
   - Status: `failed`

4. **Song Generation Timeout**
   - After 5 minutes of polling, mark atoms as `failed`
   - Response: "⏱️ Song generation timed out. Let's try again?"

### Error Logging

Log all errors to console for debugging:

```typescript
console.error('Assistant error:', {
  messageId: args.messageId,
  error: error.message,
  stack: error.stack,
});
```

## Acceptance Criteria

### Loading States
- [ ] **LOADING STATE 1**: "Orphy is thinking..." appears immediately after @suno mention
- [ ] Loading State 1 shows spinner in assistant message area (~1-3 seconds)
- [ ] Assistant message appears with content after OpenAI processes intent
- [ ] **LOADING STATE 2**: Song atom rows appear beneath assistant message with "Generating..." state
- [ ] Each song row shows individual spinner and status (pending/processing/streaming)
- [ ] Loading State 2 persists until songs reach complete/failed status (~30-90 seconds)

### Core Functionality
- [ ] User can type `@suno create a chill lofi beat` and Orphy responds
- [ ] OpenAI extracts prompt and tags correctly
- [ ] Suno API generates 2 songs
- [ ] 2 atom records created with `sunoClipId` stored
- [ ] Atoms appear as rows beneath assistant message
- [ ] Songs show status progression (pending → streaming → complete)
- [ ] Duration appears when song is complete
- [ ] Play button is disabled until song is playable
- [ ] Error states display user-friendly messages
- [ ] Polling stops when both songs are complete/failed
- [ ] Component is reusable for future variations (mobile, canvas)

## Future Enhancements (Out of Scope)

- [ ] Queue management (add songs to room queue)
- [ ] Playback control (play/pause/skip)
- [ ] Music suggestions
- [ ] Production assistance (Q&A)
- [ ] Remix/sampling using `sunoClipId`
- [ ] Lyrics generation
- [ ] Video generation
- [ ] Album art generation
- [ ] Custom Orphy personalities
- [ ] Voice commands

## Technical Notes

### Why OpenAI for Intent Classification?

- GPT-4o-mini is fast (~1s response) and cost-effective (~$0.0001/request)
- Function calling provides structured output (prompt, tags, instrumental flag)
- Handles natural language variations (lyrics vs. descriptions)
- Easy to extend with more tools later

### Why Scheduled Functions for Polling?

- Convex scheduled functions are reliable and persistent
- Avoids long-running actions (Convex has 10-minute timeout)
- Client automatically re-renders when atoms update (via subscriptions)
- No need for WebSocket polling from frontend

### Component Architecture

`<SongAtomRow />` is intentionally isolated for:
- Easy iteration on design
- Mobile web variations (compact view)
- Canvas view variations (visual waveform)
- Playlist/queue views

Keep component simple and data-driven (status-based rendering).

## Documentation Location

Document the Suno API status behavior and polling strategy in:

**File:** `lib/README.md` (create if not exists)

```markdown
# Suno API Integration

## Song Generation Flow

1. POST /api/generate/v2-web → Returns 2 song IDs
2. Poll GET /api/feed/v3?ids=... every 5s
3. Status: submitted → queued → streaming → complete
4. Duration only available when complete
5. Song playable when status > streaming

## Important Notes

- Always generates 2 songs per request
- Store sunoClipId for remixing later
- Poll until all songs reach terminal state (complete/error/failed)
```

## Implementation Checklist

- [ ] **Schema**: Add `atoms` table and update `messages` table
- [ ] **Backend**: Create `convex/assistant.ts` with message handler, intent classifier, response creator
- [ ] **Backend**: Create `convex/atoms.ts` with song generation action and polling
- [ ] **Suno Client**: Update `lib/sunoClient.ts` if needed (already exists)
- [ ] **Frontend**: Update `components/Message.tsx` to render assistant messages and atoms
- [ ] **Frontend**: Create `components/SongAtomRow.tsx` for song display
- [ ] **Frontend**: Update `components/MessageComposer.tsx` for `@suno` mention detection
- [ ] **Environment**: Set `SUNO_BASE_URL`, `SUNO_SESSION_TOKEN`, `OPENAI_KEY` in Convex dashboard
- [ ] **Testing**: Test end-to-end flow with various prompts
- [ ] **Documentation**: Document Suno API behavior in `lib/README.md`
