Conversation Branching and Keyboard Shortcuts Implementation Plan
Date: 2025-11-23 Status: In Progress Epic: Advanced Chat Features
🎯 Overview
This document tracks the implementation of two major features:
- Conversation Branching - Allow users to fork conversations at any message
- Enhanced Keyboard Shortcuts - Global shortcuts with help dialog
✅ Completed Work
Backend (Database & Models)
-
Message Model Updates (
services/api-gateway/app/models/message.py)- Added
parent_message_idcolumn (UUID, nullable, indexed) - Added
branch_idcolumn (String(100), nullable, indexed) - Added self-referential foreign key for parent_message_id
- Added
-
Alembic Migration (
services/api-gateway/alembic/versions/006_add_branching_support.py)- Migration ID: 006
- Revises: 005
- Adds columns to messages table with proper constraints and indexes
- Includes upgrade() and downgrade() methods
🚧 Remaining Backend Work
1. API Endpoints
Update Existing Endpoints
File: services/api-gateway/app/api/realtime.py (or similar)
Update message response schemas to include:
{ "id": "uuid", "session_id": "uuid", "content": "...", "role": "user|assistant|system", "parent_message_id": "uuid|null", # NEW "branch_id": "string|null", # NEW "created_at": "datetime", ... }
New Branch Endpoint
Endpoint: POST /api/conversations/{conversation_id}/branches
Request Body:
{ "parent_message_id": "uuid", "initial_message": "string" (optional) }
Response:
{ "branch_id": "string", "conversation_id": "uuid", "parent_message_id": "uuid", "created_at": "datetime" }
Logic:
- Validate parent message exists and belongs to conversation
- Generate unique branch_id (e.g.,
branch-{timestamp}-{short-uuid}) - If initial_message provided, create first message in branch
- Return branch details
2. Message Creation Update
Update message creation endpoint to accept optional branch_id:
File: services/api-gateway/app/api/realtime.py
async def create_message( session_id: UUID, content: str, role: str, branch_id: Optional[str] = None, # NEW parent_message_id: Optional[UUID] = None, # NEW ... ): message = Message( session_id=session_id, content=content, role=role, branch_id=branch_id or "main", # Default to "main" branch parent_message_id=parent_message_id, ... ) ...
3. Branch Queries
Add helper methods for branch management:
def get_branch_messages(session_id: UUID, branch_id: str) -> List[Message]: """Get all messages in a specific branch, maintaining conversation order""" # Start from first message, follow parent_message_id chain ... def get_branch_tree(session_id: UUID) -> Dict: """Get complete branch structure for visualization""" ... def get_available_branches(session_id: UUID) -> List[Dict]: """Get list of all branches with metadata""" ...
4. Tests
File: services/api-gateway/tests/test_branches.py (NEW)
def test_create_branch(): # Test creating a new branch from a message ... def test_get_branch_messages(): # Test retrieving messages in correct order ... def test_branch_isolation(): # Ensure branches don't interfere with each other ... def test_invalid_parent_message(): # Test error handling for invalid parent ...
🚧 Frontend Work
1. Type Updates
File: packages/types/src/index.ts
export interface Message { id: string; conversationId?: string; role: "user" | "assistant" | "system"; content: string; // NEW: Branching support parentId?: string; branchId?: string; delta?: string; citations?: Citation[]; attachments?: string[]; timestamp: number; metadata?: MessageMetadata; } // NEW: Branch interface export interface Branch { id: string; conversationId: string; parentMessageId: string; createdAt: string; messageCount: number; }
2. API Client Updates
File: packages/api-client/src/index.ts
// Add to VoiceAssistApiClient class async createBranch( conversationId: string, parentMessageId: string, initialMessage?: string ): Promise<Branch> { const response = await this.client.post<ApiResponse<Branch>>( `/conversations/${conversationId}/branches`, { parent_message_id: parentMessageId, initial_message: initialMessage } ); return response.data.data!; } async getBranchMessages( conversationId: string, branchId: string ): Promise<Message[]> { const response = await this.client.get<ApiResponse<Message[]>>( `/conversations/${conversationId}/branches/${branchId}/messages` ); return response.data.data!; } async listBranches(conversationId: string): Promise<Branch[]> { const response = await this.client.get<ApiResponse<Branch[]>>( `/conversations/${conversationId}/branches` ); return response.data.data!; }
3. Branch Management Hook
File: apps/web-app/src/hooks/useBranching.ts (NEW)
/** * useBranching Hook * Manages conversation branching state and operations */ import { useState, useCallback } from "react"; import { useAuth } from "./useAuth"; import type { Branch } from "@voiceassist/types"; export function useBranching(conversationId: string) { const { apiClient } = useAuth(); const [currentBranchId, setCurrentBranchId] = useState<string>("main"); const [branches, setBranches] = useState<Branch[]>([]); const [isLoading, setIsLoading] = useState(false); const createBranch = useCallback( async (parentMessageId: string) => { setIsLoading(true); try { const branch = await apiClient.createBranch(conversationId, parentMessageId); setBranches((prev) => [...prev, branch]); setCurrentBranchId(branch.id); return branch; } catch (error) { console.error("Failed to create branch:", error); throw error; } finally { setIsLoading(false); } }, [conversationId, apiClient], ); const switchBranch = useCallback((branchId: string) => { setCurrentBranchId(branchId); }, []); const loadBranches = useCallback(async () => { setIsLoading(true); try { const branchList = await apiClient.listBranches(conversationId); setBranches(branchList); } catch (error) { console.error("Failed to load branches:", error); } finally { setIsLoading(false); } }, [conversationId, apiClient]); return { currentBranchId, branches, isLoading, createBranch, switchBranch, loadBranches, }; }
4. UI Components
Branch Button (MessageBubble)
File: apps/web-app/src/components/chat/MessageBubble.tsx
Add to MessageActionMenu:
{!isSystem && ( <button onClick={() => onBranch?.(message.id)} className="..." aria-label="Create branch from this message" > <BranchIcon className="w-4 h-4" /> </button> )}
Branch Sidebar
File: apps/web-app/src/components/chat/BranchSidebar.tsx (NEW)
import { useBranching } from '@/hooks/useBranching'; export function BranchSidebar({ conversationId }: { conversationId: string }) { const { branches, currentBranchId, switchBranch, isLoading } = useBranching(conversationId); return ( <div className="w-64 bg-white border-l border-gray-200 p-4"> <h3 className="font-semibold mb-4">Conversation Branches</h3> {isLoading ? ( <Spinner /> ) : ( <ul className="space-y-2"> {branches.map(branch => ( <li key={branch.id}> <button onClick={() => switchBranch(branch.id)} className={` w-full text-left px-3 py-2 rounded ${branch.id === currentBranchId ? 'bg-blue-100' : 'hover:bg-gray-100'} `} > <div className="font-medium">{branch.id}</div> <div className="text-sm text-gray-500"> {branch.messageCount} messages </div> </button> </li> ))} </ul> )} </div> ); }
5. Keyboard Shortcuts Enhancement
File: apps/web-app/src/hooks/useKeyboardShortcuts.ts
Already exists - enhance with:
- Cmd/Ctrl + K → Open conversation search
- Cmd/Ctrl + B → Toggle branch sidebar
- Cmd/Ctrl + Shift + B → Create branch at current message
Keyboard Shortcuts Dialog
File: apps/web-app/src/components/KeyboardShortcutsDialog.tsx (NEW)
import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts'; export function KeyboardShortcutsDialog({ isOpen, onClose }: Props) { return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent> <DialogHeader> <DialogTitle>Keyboard Shortcuts</DialogTitle> </DialogHeader> <div className="space-y-4"> {KEYBOARD_SHORTCUTS.map(shortcut => ( <div key={shortcut.key} className="flex justify-between"> <span>{shortcut.description}</span> <kbd className="px-2 py-1 bg-gray-100 rounded"> {shortcut.metaKey ? '⌘' : shortcut.ctrlKey ? 'Ctrl' : ''} {shortcut.key} </kbd> </div> ))} </div> </DialogContent> </Dialog> ); }
📋 Testing Checklist
Backend Tests
- Create branch endpoint returns valid branch_id
- Messages created in branch have correct branch_id
- Querying branch messages returns only that branch's messages
- Parent message validation works correctly
- Migration applies and rolls back cleanly
Frontend Tests
- Branch button appears on messages
- Creating branch updates UI state
- Switching branches loads correct messages
- Keyboard shortcuts trigger expected actions
- Shortcuts dialog displays all shortcuts
- Branch sidebar shows all branches
- Branch persistence across page refresh
🚀 Deployment Steps
-
Apply Migration:
cd services/api-gateway source venv/bin/activate alembic upgrade head -
Verify Migration:
psql $DATABASE_URL -c "\d messages" # Should show parent_message_id and branch_id columns -
Run Backend Tests:
make test -
Build Frontend:
pnpm build -
Run Frontend Tests:
pnpm test -
Deploy to Production:
- Backend: Docker compose up with new image
- Frontend: Deploy build artifacts
- Run smoke tests
📝 Notes & Considerations
Branch ID Format
- Use format:
branch-{timestamp}-{shortUUID} - Main conversation uses branch_id="main"
- Ensures uniqueness and sortability
Performance
- Index on
branch_idfor fast filtering - Index on
parent_message_idfor tree traversal - Consider caching branch structure for large conversations
UI/UX
- Visual indicator showing current branch
- Breadcrumb showing branch lineage
- Confirmation before switching branches with unsaved changes
- Color-code different branches for easy identification
Future Enhancements
- Branch merging
- Branch naming/descriptions
- Branch comparison view
- Export branch to new conversation
- Branch permissions/sharing
🐛 Known Issues
- Migration requires manual application - Alembic autogenerate needs database connection
- WebSocket timing in tests - Known issue documented in KNOWN_ISSUES.md
- ESM import issues with react-syntax-highlighter - Affects 5 test suites
Status: Phase 3 Complete - Frontend UI Components and Keyboard Shortcuts Ready
Completed in Phase 2:
- ✅ Backend API endpoints (
/api/conversations/{id}/branches) - ✅ Pydantic schemas and response models
- ✅ Router registered in main.py
- ✅ Frontend Message type extended with
parentIdandbranchId - ✅ Frontend Branch type and CreateBranchRequest added
- ✅ API client methods:
createBranch(),listBranches(),getBranchMessages()
Completed in Phase 3:
- ✅
useBranchinghook for branch state management (apps/web-app/src/hooks/useBranching.ts) - ✅ Branch button added to MessageActionMenu (
apps/web-app/src/components/chat/MessageActionMenu.tsx) - ✅ Branch button wired up in MessageBubble (
apps/web-app/src/components/chat/MessageBubble.tsx) - ✅ BranchSidebar component created (
apps/web-app/src/components/chat/BranchSidebar.tsx) - ✅ Keyboard shortcuts hook (
apps/web-app/src/hooks/useKeyboardShortcuts.ts)- Cmd/Ctrl + B: Toggle branch sidebar
- Cmd/Ctrl + Shift + B: Create branch from current message
- Cmd/Ctrl + Enter: Send message
- Escape: Cancel editing
Next Steps:
- Integrate components into main chat page
- Add tests for branching features
- Apply database migration (
alembic upgrade head) - End-to-end testing of branching workflow