Web Application

The VoiceAssist web application provides a modern, responsive interface for interacting with the medical AI assistant through text and voice.

Chat Interface

Markdown rendering, citations, code blocks, and message management

Voice Mode

Real-time voice interaction with OpenAI Realtime API

Clinical Context

Patient context panels for medical consultations

Citations

Source attribution and evidence display

Feature Specifications

VoiceAssist Web App - Comprehensive Feature Specifications

Version: 1.0 Date: 2025-11-21 Application: Main User-Facing Medical AI Assistant URL: https://voiceassist.asimo.io


šŸ“‹ Table of Contents

  1. Overview
  2. Authentication & User Management (5 features)
  3. Chat Interface (12 features)
  4. Voice Mode (8 features)
  5. Clinical Context (6 features)
  6. File Management (4 features)
  7. Citations & Sources (5 features)
  8. Conversation Management (5 features)
  9. Advanced Features (10 features)
  10. Technical Implementation
  11. Testing Strategy

Total Features: 55 (expanded from original 45)


šŸŽÆ Overview

The VoiceAssist Web App is a React-based single-page application that provides medical professionals with an AI-powered assistant for clinical decision support, medical literature queries, and patient care workflows.

Core Technology Stack

  • Framework: React 18.2+ with TypeScript 5.0+
  • Build Tool: Vite 5.0+
  • Routing: React Router 6.20+
  • State Management: Zustand 4.4+
  • UI Components: shadcn/ui + Radix UI
  • Styling: Tailwind CSS 3.4+
  • Forms: React Hook Form 7.48+ with Zod validation
  • Real-time: Native WebSocket + Socket.io client
  • Audio: Web Audio API + MediaRecorder API
  • Markdown: React Markdown + remark-gfm + rehype-katex
  • Charts: Recharts 2.10+
  • Testing: Vitest + React Testing Library + Playwright

Design Principles

  1. Speed First - Every interaction feels instant
  2. Medical Professionalism - Trust-building design
  3. Accessibility - WCAG 2.1 AA compliant
  4. Mobile-Ready - Responsive from 320px to 4K
  5. Offline Capable - Service worker with smart caching

1. Authentication & User Management

1.1 Email/Password Login

Priority: P0 (Critical) Effort: 5 days Dependencies: Backend auth API

Specification

A clean, professional login interface with email and password fields, validation, and error handling.

User Flow:

  1. User navigates to /login
  2. Enters email and password
  3. Clicks "Sign In" or presses Enter
  4. System validates credentials
  5. On success: Redirects to chat interface
  6. On failure: Shows inline error message

Features:

  • Real-time validation
  • Password visibility toggle
  • "Remember me" checkbox
  • Forgot password link
  • Social login options (Google, Microsoft)
  • Rate limiting (5 attempts per 15 minutes)
  • Loading states during authentication

Component Structure

// File: apps/web-app/src/pages/auth/LoginPage.tsx import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@voiceassist/ui"; import { Input } from "@voiceassist/ui"; import { Label } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { GoogleIcon, MicrosoftIcon } from "@/components/icons"; // Validation schema const loginSchema = z.object({ email: z.string().email("Invalid email address").min(1, "Email is required"), password: z.string().min(8, "Password must be at least 8 characters").max(100, "Password is too long"), rememberMe: z.boolean().optional(), }); type LoginFormData = z.infer<typeof loginSchema>; export function LoginPage() { const navigate = useNavigate(); const { login, loginWithOAuth } = useAuth(); const [showPassword, setShowPassword] = useState(false); const [apiError, setApiError] = useState<string | null>(null); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema), defaultValues: { rememberMe: false, }, }); const onSubmit = async (data: LoginFormData) => { try { setApiError(null); await login(data.email, data.password, data.rememberMe); navigate("/chat"); } catch (error: any) { if (error.response?.status === 401) { setApiError("Invalid email or password"); } else if (error.response?.status === 429) { setApiError("Too many login attempts. Please try again later."); } else { setApiError("An error occurred. Please try again."); } } }; const handleOAuthLogin = async (provider: "google" | "microsoft") => { try { await loginWithOAuth(provider); } catch (error) { setApiError(`Failed to sign in with ${provider}`); } }; return ( <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4"> <div className="w-full max-w-md"> {/* Logo */} <div className="text-center mb-8"> <img src="/logo.svg" alt="VoiceAssist" className="h-12 mx-auto mb-4" /> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">Welcome Back</h1> <p className="text-gray-600 dark:text-gray-400 mt-2">Sign in to your VoiceAssist account</p> </div> {/* Login Form Card */} <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Email Field */} <div> <Label htmlFor="email">Email</Label> <Input id="email" type="email" autoComplete="email" placeholder="doctor@hospital.com" error={errors.email?.message} {...register("email")} /> </div> {/* Password Field */} <div> <div className="flex items-center justify-between mb-2"> <Label htmlFor="password">Password</Label> <Link to="/forgot-password" className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"> Forgot password? </Link> </div> <div className="relative"> <Input id="password" type={showPassword ? "text" : "password"} autoComplete="current-password" placeholder="••••••••" error={errors.password?.message} {...register("password")} /> <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700" tabIndex={-1} > {showPassword ? <EyeOffIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />} </button> </div> </div> {/* Remember Me */} <div className="flex items-center"> <input id="rememberMe" type="checkbox" className="h-4 w-4 text-blue-600 rounded border-gray-300" {...register("rememberMe")} /> <label htmlFor="rememberMe" className="ml-2 text-sm text-gray-700 dark:text-gray-300"> Remember me for 30 days </label> </div> {/* API Error */} {apiError && ( <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3"> <p className="text-sm text-red-800 dark:text-red-200">{apiError}</p> </div> )} {/* Submit Button */} <Button type="submit" className="w-full" size="lg" disabled={isSubmitting}> {isSubmitting ? "Signing in..." : "Sign In"} </Button> </form> {/* OAuth Divider */} <div className="relative my-6"> <div className="absolute inset-0 flex items-center"> <div className="w-full border-t border-gray-300 dark:border-gray-600" /> </div> <div className="relative flex justify-center text-sm"> <span className="px-2 bg-white dark:bg-gray-800 text-gray-500">Or continue with</span> </div> </div> {/* OAuth Buttons */} <div className="grid grid-cols-2 gap-3"> <Button variant="outline" onClick={() => handleOAuthLogin("google")} className="w-full"> <GoogleIcon className="w-5 h-5 mr-2" /> Google </Button> <Button variant="outline" onClick={() => handleOAuthLogin("microsoft")} className="w-full"> <MicrosoftIcon className="w-5 h-5 mr-2" /> Microsoft </Button> </div> {/* Sign Up Link */} <div className="mt-6 text-center"> <p className="text-sm text-gray-600 dark:text-gray-400"> Don't have an account?{" "} <Link to="/register" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium"> Sign up </Link> </p> </div> </div> {/* Footer */} <div className="mt-8 text-center"> <p className="text-xs text-gray-500 dark:text-gray-400"> By signing in, you agree to our{" "} <a href="/terms" className="underline hover:text-gray-700"> Terms of Service </a>{" "} and{" "} <a href="/privacy" className="underline hover:text-gray-700"> Privacy Policy </a> </p> </div> </div> </div> ); }

Authentication Hook

// File: apps/web-app/src/hooks/useAuth.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; import { authApi } from "@voiceassist/api-client"; import type { User, AuthTokens } from "@voiceassist/types"; interface AuthState { user: User | null; tokens: AuthTokens | null; isAuthenticated: boolean; isLoading: boolean; login: (email: string, password: string, rememberMe?: boolean) => Promise<void>; loginWithOAuth: (provider: "google" | "microsoft") => Promise<void>; logout: () => Promise<void>; refreshToken: () => Promise<void>; updateProfile: (updates: Partial<User>) => Promise<void>; } export const useAuth = create<AuthState>()( persist( (set, get) => ({ user: null, tokens: null, isAuthenticated: false, isLoading: false, login: async (email: string, password: string, rememberMe = false) => { set({ isLoading: true }); try { const response = await authApi.login({ email, password, rememberMe }); set({ user: response.user, tokens: response.tokens, isAuthenticated: true, isLoading: false, }); // Set up token refresh scheduleTokenRefresh(response.tokens.expiresIn); } catch (error) { set({ isLoading: false }); throw error; } }, loginWithOAuth: async (provider) => { set({ isLoading: true }); try { // Redirect to OAuth provider const authUrl = await authApi.getOAuthUrl(provider); window.location.href = authUrl; } catch (error) { set({ isLoading: false }); throw error; } }, logout: async () => { const { tokens } = get(); if (tokens) { try { await authApi.logout(tokens.refreshToken); } catch (error) { console.error("Logout failed:", error); } } set({ user: null, tokens: null, isAuthenticated: false, }); }, refreshToken: async () => { const { tokens } = get(); if (!tokens?.refreshToken) { throw new Error("No refresh token available"); } try { const response = await authApi.refresh(tokens.refreshToken); set({ tokens: response.tokens, }); scheduleTokenRefresh(response.tokens.expiresIn); } catch (error) { // Refresh failed, logout user get().logout(); throw error; } }, updateProfile: async (updates) => { const { user } = get(); if (!user) throw new Error("No user logged in"); const updatedUser = await authApi.updateProfile(user.id, updates); set({ user: updatedUser }); }, }), { name: "voiceassist-auth", partialize: (state) => ({ user: state.user, tokens: state.tokens, isAuthenticated: state.isAuthenticated, }), }, ), ); // Helper function to schedule token refresh let refreshTimeout: NodeJS.Timeout | null = null; function scheduleTokenRefresh(expiresIn: number) { if (refreshTimeout) { clearTimeout(refreshTimeout); } // Refresh 5 minutes before expiry const refreshIn = (expiresIn - 300) * 1000; refreshTimeout = setTimeout(() => { useAuth.getState().refreshToken(); }, refreshIn); }

Protected Route Component

// File: apps/web-app/src/components/auth/ProtectedRoute.tsx import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; import { Spinner } from "@voiceassist/ui"; interface ProtectedRouteProps { children: React.ReactNode; requireAdmin?: boolean; } export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { const { isAuthenticated, user, isLoading } = useAuth(); const location = useLocation(); if (isLoading) { return ( <div className="min-h-screen flex items-center justify-center"> <Spinner size="lg" /> </div> ); } if (!isAuthenticated) { // Redirect to login with return URL return <Navigate to="/login" state={{ from: location }} replace />; } if (requireAdmin && user?.role !== "admin") { return <Navigate to="/unauthorized" replace />; } return <>{children}</>; }

API Client (Auth Module)

// File: packages/api-client/src/auth.ts import { apiClient } from "./client"; import type { User, AuthTokens } from "@voiceassist/types"; interface LoginRequest { email: string; password: string; rememberMe?: boolean; } interface LoginResponse { user: User; tokens: AuthTokens; } interface RegisterRequest { email: string; password: string; firstName: string; lastName: string; specialty?: string; } export const authApi = { /** * Login with email and password */ login: async (data: LoginRequest): Promise<LoginResponse> => { const response = await apiClient.post<LoginResponse>("/api/auth/login", data); return response.data; }, /** * Register new user account */ register: async (data: RegisterRequest): Promise<LoginResponse> => { const response = await apiClient.post<LoginResponse>("/api/auth/register", data); return response.data; }, /** * Get OAuth authorization URL */ getOAuthUrl: async (provider: "google" | "microsoft"): Promise<string> => { const response = await apiClient.get<{ url: string }>(`/api/auth/oauth/${provider}`); return response.data.url; }, /** * Exchange OAuth code for tokens */ oauthCallback: async (provider: string, code: string): Promise<LoginResponse> => { const response = await apiClient.post<LoginResponse>(`/api/auth/oauth/${provider}/callback`, { code, }); return response.data; }, /** * Refresh access token */ refresh: async (refreshToken: string): Promise<{ tokens: AuthTokens }> => { const response = await apiClient.post<{ tokens: AuthTokens }>("/api/auth/refresh", { refreshToken, }); return response.data; }, /** * Logout and revoke tokens */ logout: async (refreshToken: string): Promise<void> => { await apiClient.post("/api/auth/logout", { refreshToken }); }, /** * Request password reset email */ requestPasswordReset: async (email: string): Promise<void> => { await apiClient.post("/api/auth/forgot-password", { email }); }, /** * Reset password with token */ resetPassword: async (token: string, newPassword: string): Promise<void> => { await apiClient.post("/api/auth/reset-password", { token, newPassword }); }, /** * Update user profile */ updateProfile: async (userId: string, updates: Partial<User>): Promise<User> => { const response = await apiClient.patch<User>(`/api/users/${userId}`, updates); return response.data; }, /** * Get current user */ getCurrentUser: async (): Promise<User> => { const response = await apiClient.get<User>("/api/auth/me"); return response.data; }, };

Testing

// File: apps/web-app/src/pages/auth/__tests__/LoginPage.test.tsx import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { BrowserRouter } from "react-router-dom"; import { LoginPage } from "../LoginPage"; import { useAuth } from "@/hooks/useAuth"; // Mock auth hook vi.mock("@/hooks/useAuth"); describe("LoginPage", () => { const mockLogin = vi.fn(); const mockLoginWithOAuth = vi.fn(); beforeEach(() => { vi.clearAllMocks(); (useAuth as any).mockReturnValue({ login: mockLogin, loginWithOAuth: mockLoginWithOAuth, }); }); it("renders login form", () => { render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); }); it("validates email format", async () => { const user = userEvent.setup(); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const emailInput = screen.getByLabelText(/email/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "invalid-email"); await user.click(submitButton); expect(await screen.findByText(/invalid email address/i)).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); it("validates password length", async () => { const user = userEvent.setup(); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "test@example.com"); await user.type(passwordInput, "short"); await user.click(submitButton); expect(await screen.findByText(/password must be at least 8 characters/i)).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); it("submits valid credentials", async () => { const user = userEvent.setup(); mockLogin.mockResolvedValue(undefined); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "doctor@hospital.com"); await user.type(passwordInput, "SecurePass123!"); await user.click(submitButton); await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith("doctor@hospital.com", "SecurePass123!", false); }); }); it("handles login error", async () => { const user = userEvent.setup(); mockLogin.mockRejectedValue({ response: { status: 401 }, }); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "doctor@hospital.com"); await user.type(passwordInput, "WrongPassword"); await user.click(submitButton); expect(await screen.findByText(/invalid email or password/i)).toBeInTheDocument(); }); it("toggles password visibility", async () => { const user = userEvent.setup(); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement; const toggleButton = screen.getByRole("button", { name: /toggle password/i }); expect(passwordInput.type).toBe("password"); await user.click(toggleButton); expect(passwordInput.type).toBe("text"); await user.click(toggleButton); expect(passwordInput.type).toBe("password"); }); it("handles OAuth login", async () => { const user = userEvent.setup(); render( <BrowserRouter> <LoginPage /> </BrowserRouter>, ); const googleButton = screen.getByRole("button", { name: /google/i }); await user.click(googleButton); expect(mockLoginWithOAuth).toHaveBeenCalledWith("google"); }); });

1.2 User Registration

Priority: P0 (Critical) Effort: 5 days Dependencies: Backend auth API, email service

Specification

Registration page for new users with comprehensive validation, email verification, and specialty selection.

User Flow:

  1. User navigates to /register
  2. Fills in required information
  3. Selects medical specialty
  4. Agrees to terms and conditions
  5. Submits form
  6. Receives verification email
  7. Clicks verification link
  8. Account activated

Features:

  • Real-time validation
  • Password strength meter
  • Email availability check
  • Specialty dropdown with search
  • Terms acceptance checkbox
  • Email verification workflow
  • Auto-login after verification

Component Implementation

// File: apps/web-app/src/pages/auth/RegisterPage.tsx import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Input, Label, Select } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { PasswordStrengthMeter } from "@/components/auth/PasswordStrengthMeter"; import { medicalSpecialties } from "@/constants/specialties"; const registerSchema = z .object({ firstName: z.string().min(1, "First name is required").max(50), lastName: z.string().min(1, "Last name is required").max(50), email: z.string().email("Invalid email address"), password: z .string() .min(8, "Password must be at least 8 characters") .regex(/[A-Z]/, "Password must contain uppercase letter") .regex(/[a-z]/, "Password must contain lowercase letter") .regex(/[0-9]/, "Password must contain number") .regex(/[^A-Za-z0-9]/, "Password must contain special character"), confirmPassword: z.string(), specialty: z.string().optional(), licenseNumber: z.string().optional(), institution: z.string().optional(), agreeToTerms: z.boolean().refine((val) => val === true, { message: "You must agree to the terms and conditions", }), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], }); type RegisterFormData = z.infer<typeof registerSchema>; export function RegisterPage() { const navigate = useNavigate(); const { register: registerUser } = useAuth(); const [apiError, setApiError] = useState<string | null>(null); const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null); const { register, handleSubmit, watch, formState: { errors, isSubmitting }, } = useForm<RegisterFormData>({ resolver: zodResolver(registerSchema), }); const password = watch("password"); const email = watch("email"); // Check email availability with debounce const checkEmailAvailability = useDebouncedCallback(async (email: string) => { if (!email || !z.string().email().safeParse(email).success) { setEmailAvailable(null); return; } try { const available = await authApi.checkEmailAvailability(email); setEmailAvailable(available); } catch (error) { console.error("Failed to check email:", error); } }, 500); useEffect(() => { checkEmailAvailability(email); }, [email]); const onSubmit = async (data: RegisterFormData) => { try { setApiError(null); await registerUser({ email: data.email, password: data.password, firstName: data.firstName, lastName: data.lastName, specialty: data.specialty, licenseNumber: data.licenseNumber, institution: data.institution, }); // Show verification email sent message navigate("/verify-email", { state: { email: data.email }, }); } catch (error: any) { if (error.response?.data?.message) { setApiError(error.response.data.message); } else { setApiError("Registration failed. Please try again."); } } }; return ( <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4 py-12"> <div className="w-full max-w-2xl"> {/* Header */} <div className="text-center mb-8"> <img src="/logo.svg" alt="VoiceAssist" className="h-12 mx-auto mb-4" /> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Account</h1> <p className="text-gray-600 dark:text-gray-400 mt-2">Join VoiceAssist to enhance your clinical practice</p> </div> {/* Registration Form Card */} <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Name Fields */} <div className="grid grid-cols-2 gap-4"> <div> <Label htmlFor="firstName">First Name *</Label> <Input id="firstName" placeholder="John" error={errors.firstName?.message} {...register("firstName")} /> </div> <div> <Label htmlFor="lastName">Last Name *</Label> <Input id="lastName" placeholder="Doe" error={errors.lastName?.message} {...register("lastName")} /> </div> </div> {/* Email Field */} <div> <Label htmlFor="email">Email Address *</Label> <div className="relative"> <Input id="email" type="email" placeholder="doctor@hospital.com" error={errors.email?.message} {...register("email")} /> {emailAvailable === false && ( <div className="absolute right-3 top-1/2 -translate-y-1/2"> <span className="text-red-500 text-sm">Already taken</span> </div> )} {emailAvailable === true && ( <div className="absolute right-3 top-1/2 -translate-y-1/2"> <CheckCircleIcon className="w-5 h-5 text-green-500" /> </div> )} </div> </div> {/* Password Field */} <div> <Label htmlFor="password">Password *</Label> <Input id="password" type="password" placeholder="••••••••" error={errors.password?.message} {...register("password")} /> {password && <PasswordStrengthMeter password={password} />} </div> {/* Confirm Password */} <div> <Label htmlFor="confirmPassword">Confirm Password *</Label> <Input id="confirmPassword" type="password" placeholder="••••••••" error={errors.confirmPassword?.message} {...register("confirmPassword")} /> </div> {/* Professional Information */} <div className="border-t pt-6"> <h3 className="text-lg font-medium mb-4">Professional Information</h3> <div className="space-y-4"> <div> <Label htmlFor="specialty">Medical Specialty</Label> <Select id="specialty" options={medicalSpecialties} placeholder="Select your specialty" {...register("specialty")} /> </div> <div> <Label htmlFor="licenseNumber">Medical License Number</Label> <Input id="licenseNumber" placeholder="e.g., MD123456" {...register("licenseNumber")} /> </div> <div> <Label htmlFor="institution">Institution/Hospital</Label> <Input id="institution" placeholder="e.g., Memorial Hospital" {...register("institution")} /> </div> </div> </div> {/* Terms Agreement */} <div className="flex items-start"> <input id="agreeToTerms" type="checkbox" className="h-4 w-4 text-blue-600 rounded border-gray-300 mt-1" {...register("agreeToTerms")} /> <label htmlFor="agreeToTerms" className="ml-2 text-sm text-gray-700 dark:text-gray-300"> I agree to the{" "} <a href="/terms" target="_blank" className="text-blue-600 hover:underline"> Terms of Service </a> ,{" "} <a href="/privacy" target="_blank" className="text-blue-600 hover:underline"> Privacy Policy </a> , and{" "} <a href="/hipaa" target="_blank" className="text-blue-600 hover:underline"> HIPAA Agreement </a> </label> </div> {errors.agreeToTerms && <p className="text-sm text-red-600">{errors.agreeToTerms.message}</p>} {/* API Error */} {apiError && ( <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3"> <p className="text-sm text-red-800 dark:text-red-200">{apiError}</p> </div> )} {/* Submit Button */} <Button type="submit" className="w-full" size="lg" disabled={isSubmitting || emailAvailable === false}> {isSubmitting ? "Creating Account..." : "Create Account"} </Button> </form> {/* Login Link */} <div className="mt-6 text-center"> <p className="text-sm text-gray-600 dark:text-gray-400"> Already have an account?{" "} <Link to="/login" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium"> Sign in </Link> </p> </div> </div> </div> </div> ); }

Password Strength Component

// File: apps/web-app/src/components/auth/PasswordStrengthMeter.tsx interface PasswordStrengthMeterProps { password: string; } export function PasswordStrengthMeter({ password }: PasswordStrengthMeterProps) { const strength = calculatePasswordStrength(password); const getColor = () => { if (strength < 30) return "bg-red-500"; if (strength < 60) return "bg-yellow-500"; if (strength < 80) return "bg-blue-500"; return "bg-green-500"; }; const getLabel = () => { if (strength < 30) return "Weak"; if (strength < 60) return "Fair"; if (strength < 80) return "Good"; return "Strong"; }; return ( <div className="mt-2"> <div className="flex items-center gap-2 mb-1"> <div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"> <div className={`h-full transition-all duration-300 ${getColor()}`} style={{ width: `${strength}%` }} /> </div> <span className="text-xs font-medium text-gray-600">{getLabel()}</span> </div> <ul className="text-xs text-gray-500 space-y-1 mt-2"> <li className={password.length >= 8 ? "text-green-600" : ""}>āœ“ At least 8 characters</li> <li className={/[A-Z]/.test(password) ? "text-green-600" : ""}>āœ“ Contains uppercase letter</li> <li className={/[a-z]/.test(password) ? "text-green-600" : ""}>āœ“ Contains lowercase letter</li> <li className={/[0-9]/.test(password) ? "text-green-600" : ""}>āœ“ Contains number</li> <li className={/[^A-Za-z0-9]/.test(password) ? "text-green-600" : ""}>āœ“ Contains special character</li> </ul> </div> ); } function calculatePasswordStrength(password: string): number { let strength = 0; // Length if (password.length >= 8) strength += 20; if (password.length >= 12) strength += 10; if (password.length >= 16) strength += 10; // Character variety if (/[a-z]/.test(password)) strength += 15; if (/[A-Z]/.test(password)) strength += 15; if (/[0-9]/.test(password)) strength += 15; if (/[^A-Za-z0-9]/.test(password)) strength += 15; return Math.min(strength, 100); }

1.3 User Profile Management

Priority: P1 (High) Effort: 3 days Dependencies: Auth system

Specification

Comprehensive user profile page where users can view and edit their personal and professional information.

Features:

  • View profile information
  • Edit personal details
  • Update professional information
  • Change password
  • Upload profile picture
  • Manage notification preferences
  • View account activity
  • Delete account (with confirmation)

Component Implementation

// File: apps/web-app/src/pages/settings/ProfilePage.tsx import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Input, Label, Select, Avatar, Tabs, TabsList, TabsTrigger, TabsContent } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { medicalSpecialties } from "@/constants/specialties"; const profileSchema = z.object({ firstName: z.string().min(1, "Required"), lastName: z.string().min(1, "Required"), specialty: z.string().optional(), licenseNumber: z.string().optional(), institution: z.string().optional(), phone: z.string().optional(), bio: z.string().max(500).optional(), }); type ProfileFormData = z.infer<typeof profileSchema>; export function ProfilePage() { const { user, updateProfile } = useAuth(); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const { register, handleSubmit, formState: { errors, isDirty }, reset, } = useForm<ProfileFormData>({ resolver: zodResolver(profileSchema), defaultValues: { firstName: user?.firstName || "", lastName: user?.lastName || "", specialty: user?.specialty || "", licenseNumber: user?.licenseNumber || "", institution: user?.institution || "", phone: user?.phone || "", bio: user?.bio || "", }, }); const onSubmit = async (data: ProfileFormData) => { setIsSaving(true); try { await updateProfile(data); setIsEditing(false); toast.success("Profile updated successfully"); } catch (error) { toast.error("Failed to update profile"); } finally { setIsSaving(false); } }; const handleCancel = () => { reset(); setIsEditing(false); }; return ( <div className="max-w-4xl mx-auto px-4 py-8"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <Tabs defaultValue="profile"> <TabsList className="border-b"> <TabsTrigger value="profile">Profile</TabsTrigger> <TabsTrigger value="security">Security</TabsTrigger> <TabsTrigger value="notifications">Notifications</TabsTrigger> <TabsTrigger value="activity">Activity</TabsTrigger> </TabsList> {/* Profile Tab */} <TabsContent value="profile" className="p-6"> <div className="flex items-start justify-between mb-6"> <div className="flex items-center gap-4"> <Avatar src={user?.avatarUrl} alt={user?.firstName} size="xl" /> <div> <h2 className="text-2xl font-bold"> {user?.firstName} {user?.lastName} </h2> <p className="text-gray-600">{user?.email}</p> <p className="text-sm text-gray-500">{user?.specialty}</p> </div> </div> {!isEditing && <Button onClick={() => setIsEditing(true)}>Edit Profile</Button>} </div> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Personal Information */} <div> <h3 className="text-lg font-medium mb-4">Personal Information</h3> <div className="grid grid-cols-2 gap-4"> <div> <Label htmlFor="firstName">First Name</Label> <Input id="firstName" disabled={!isEditing} error={errors.firstName?.message} {...register("firstName")} /> </div> <div> <Label htmlFor="lastName">Last Name</Label> <Input id="lastName" disabled={!isEditing} error={errors.lastName?.message} {...register("lastName")} /> </div> <div className="col-span-2"> <Label htmlFor="phone">Phone Number</Label> <Input id="phone" type="tel" disabled={!isEditing} placeholder="+1 (555) 000-0000" {...register("phone")} /> </div> </div> </div> {/* Professional Information */} <div> <h3 className="text-lg font-medium mb-4">Professional Information</h3> <div className="space-y-4"> <div> <Label htmlFor="specialty">Medical Specialty</Label> <Select id="specialty" disabled={!isEditing} options={medicalSpecialties} {...register("specialty")} /> </div> <div> <Label htmlFor="licenseNumber">Medical License Number</Label> <Input id="licenseNumber" disabled={!isEditing} {...register("licenseNumber")} /> </div> <div> <Label htmlFor="institution">Institution/Hospital</Label> <Input id="institution" disabled={!isEditing} {...register("institution")} /> </div> <div> <Label htmlFor="bio">Bio</Label> <textarea id="bio" disabled={!isEditing} rows={4} className="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="Tell us about yourself..." {...register("bio")} /> <p className="text-xs text-gray-500 mt-1">Maximum 500 characters</p> </div> </div> </div> {/* Action Buttons */} {isEditing && ( <div className="flex justify-end gap-3 pt-4 border-t"> <Button type="button" variant="outline" onClick={handleCancel}> Cancel </Button> <Button type="submit" disabled={!isDirty || isSaving}> {isSaving ? "Saving..." : "Save Changes"} </Button> </div> )} </form> </TabsContent> {/* Security Tab */} <TabsContent value="security" className="p-6"> <ChangePasswordForm /> <div className="mt-8 pt-8 border-t"> <TwoFactorAuthSetup /> </div> <div className="mt-8 pt-8 border-t border-red-200"> <DangerZone /> </div> </TabsContent> {/* Notifications Tab */} <TabsContent value="notifications" className="p-6"> <NotificationPreferences /> </TabsContent> {/* Activity Tab */} <TabsContent value="activity" className="p-6"> <AccountActivity /> </TabsContent> </Tabs> </div> </div> ); }

This document continues with detailed specifications for all 55 features. Due to length constraints, I'm showing the format and depth. Would you like me to continue with the remaining features in this document, or shall I proceed to create the other planning documents (Admin Panel, Docs Site, Technical Architecture, etc.)?

Current Progress:

  • āœ… MASTER_IMPLEMENTATION_PLAN.md (Complete - 20,000+ words)
  • ā³ WEB_APP_FEATURE_SPECS.md (In progress - showing 3 of 55 features with full code examples)

Next Steps:

  1. Complete remaining 52 web app features (estimated 40,000+ more words)
  2. Create ADMIN_PANEL_FEATURE_SPECS.md
  3. Create DOCS_SITE_FEATURE_SPECS.md
  4. Create TECHNICAL_ARCHITECTURE.md
  5. Create INTEGRATION_GUIDE.md
  6. Create CODE_EXAMPLES.md
  7. Create DEVELOPMENT_WORKFLOW.md
  8. Update existing README files
  9. Prepare GitHub commit

Should I continue with Option A (complete all documents), or would you like to review what I've created so far and provide feedback before I continue?

Phase 3 Implementation Plan

Frontend Phase 3 Plan - Web App UX & Voice Enhancements

Date: 2025-11-25 Branch: feature/frontend-phase3D-voice-transcript-preview Status: Phase 3A-D Complete Scope: Web App (apps/web-app) frontend-focused improvements


Goals for Phase 3

Phase 3 focuses on Voice/Realtime UX polish, advanced chat controls, and evidence/context UX enhancements. The primary objective is to elevate the user experience from functional to polished and professional.

Success Criteria

  1. Voice mode feels seamless with clear status indicators and metrics visibility
  2. Message actions (edit, regenerate, branch) are discoverable and intuitive
  3. Citations and clinical context are easily accessible and useful
  4. All new features have comprehensive test coverage

Current Implementation Status (Phase 2 Complete)

Voice Mode (Existing)

  • āœ… useRealtimeVoiceSession hook with OpenAI Realtime API integration
  • āœ… VoiceModePanel with waveform visualization
  • āœ… VoiceModeSettings for voice/language selection
  • āœ… Voice metrics tracking (connection time, STT latency, response latency)
  • āš ļø Metrics are tracked but not prominently displayed in UI
  • āš ļø Mic permission handling could be more graceful

Chat Interface (Existing)

  • āœ… MessageBubble with markdown, code blocks, citations
  • āœ… MessageActionMenu with edit, regenerate, delete, copy, branch
  • āœ… Message editing with save/cancel
  • āœ… Copy-to-clipboard with toast feedback
  • āš ļø Branch UI exists but is sidebar-based, not inline
  • āš ļø Edit/regenerate UX could be more discoverable

Citations & Context (Existing)

  • āœ… CitationDisplay with expandable details and copy
  • āœ… CitationSidebar for browsing all citations
  • āœ… ClinicalContextSidebar and ClinicalContextPanel
  • āš ļø Citation filtering could be enhanced
  • āš ļø Clinical context presets not implemented

Phase 3 Backlog (Prioritized)

P0 - Must Have (Critical Path)

1. Voice Metrics Dashboard in Voice Panel

Status: āœ… Implemented (feature/frontend-phase2-polish, PR #66) Effort: 1-2 days Files: VoiceModePanel.tsx, VoiceMetricsDisplay.tsx

Description: Display voice metrics prominently in the VoiceModePanel so users can see connection health and latency.

Features:

  • Show connection time, STT latency, response latency in real-time
  • Color-coded indicators (green/yellow/red) for latency thresholds
  • Expandable/collapsible metrics panel
  • Time to first transcript display
  • User/AI message counts and reconnect tracking
  • Accessibility: sr-only legend text, aria-expanded, aria-controls

Acceptance Criteria:

  • Metrics visible during active voice session
  • Latency thresholds: <500ms green, 500-1000ms yellow, >1000ms red
  • Tests for VoiceMetricsDisplay component (25 tests)
  • Integration tests for VoiceModePanel metrics wiring (8 tests)
  • Accessible legend with screen reader support

2. Mic Permission Error Handling UX

Status: āœ… Implemented (feature/frontend-phase2-polish, PR #66) Effort: 0.5-1 day Files: VoiceModePanel.tsx, useRealtimeVoiceSession.ts

Description: Improve the user experience when microphone permission is denied or unavailable.

Features:

  • Clear error message when mic permission denied
  • Link to browser settings instructions
  • Retry button after granting permission
  • Graceful fallback ("Use text-only mode" button)
  • State hygiene (micPermissionDenied reset on disconnect/reconnect)

Acceptance Criteria:

  • Permission denied shows helpful UI instead of error
  • User can recover without refreshing page
  • "Use text-only mode" fallback button available
  • Tests for permission error states (14 tests)

3. Enhanced Message Action Menu UX

Effort: 1-2 days Files: MessageActionMenu.tsx, MessageBubble.tsx

Description: Make message actions more discoverable and polish the interaction patterns.

Features:

  • Show action icons on hover (not hidden in dropdown)
  • Add tooltips with keyboard shortcuts
  • Confirmation dialogs for destructive actions (delete)
  • Optimistic UI updates for edit/regenerate
  • Loading states during async operations

Acceptance Criteria:

  • Actions visible on hover without opening menu
  • Delete requires confirmation
  • Loading spinners during async ops
  • Tests for all action states

P1 - Important (Near-term)

4. Inline Branch Creation UI

Status: āœ… Implemented (feature/frontend-phase3C-branches-citations) Effort: 2-3 days Files: MessageBubble.tsx, useBranching.ts, new BranchPreview.tsx

Description: Allow users to "fork" a conversation from any message with inline preview.

Features:

  • "Branch from here" action in message menu
  • Inline preview showing where branch will start
  • Navigate to new branch or stay in current
  • Visual indicator for messages that have branches

Acceptance Criteria:

  • Can create branch from any message
  • Visual feedback for branched messages
  • Branch preview before confirming
  • Tests for branch creation flow (16 tests)

5. Voice Transcript Preview During Speech

Status: āœ… Implemented (feature/frontend-phase3D-voice-transcript-preview) Effort: 1-2 days Files: VoiceModePanel.tsx, useRealtimeVoiceSession.ts, new VoiceTranscriptPreview.tsx

Description: Show real-time transcript preview as user speaks, before finalizing.

Features:

  • Live partial transcript display (streaming text)
  • Visual distinction between partial and final transcripts
  • Auto-clear partial on new utterance
  • Smooth animation for transcript updates

Acceptance Criteria:

  • Partial transcripts appear as user speaks
  • Clear visual distinction (e.g., italic/faded)
  • Smooth transitions to final transcript
  • Tests for component and hook (14 + 2 tests)

6. Citation Sidebar Filters

Status: āœ… Implemented (feature/frontend-phase3C-branches-citations) Effort: 1-2 days Files: CitationSidebar.tsx

Description: Add filtering and search capabilities to the citation sidebar.

Features:

  • Filter by source type (KB, PubMed, guidelines)
  • Filter by message (show citations for selected message)
  • Search citations by text
  • Sort by relevance/date
  • "Jump to source" in message

Acceptance Criteria:

  • Can filter citations by type
  • Can search citation text
  • "Jump to" scrolls to citation in message
  • Tests for filter/search functionality (18 new tests in Phase 3C)

P2 - Nice to Have (Future)

7. Clinical Context Presets

Effort: 2-3 days Files: ClinicalContextPanel.tsx, new ClinicalContextPresets.tsx

Description: Allow users to save and load clinical context presets for common scenarios.

Features:

  • Save current context as named preset
  • Load preset to populate fields
  • Built-in presets for common scenarios (pediatric, cardiac, etc.)
  • Export/import presets

Acceptance Criteria:

  • Can save custom presets
  • Can load presets
  • Built-in presets available
  • Tests for preset save/load

8. Voice Interruption (Barge-in) Indicator

Effort: 1-2 days Files: VoiceModePanel.tsx, useRealtimeVoiceSession.ts

Description: Visual feedback when user interrupts AI response with new speech.

Features:

  • Visual indicator when barge-in detected
  • Show which part of AI response was interrupted
  • Smooth transition from AI speaking to user speaking

Acceptance Criteria:

  • Barge-in visually indicated
  • AI audio stops gracefully
  • Tests for barge-in detection

9. Message Regeneration Options

Effort: 1-2 days Files: MessageBubble.tsx, new RegenerateOptionsDialog.tsx

Description: Allow users to customize regeneration with options (temperature, length, etc.).

Features:

  • "Regenerate with options" menu item
  • Temperature slider (more creative vs more focused)
  • Length preference (shorter/longer)
  • Keep or clear clinical context

Acceptance Criteria:

  • Can regenerate with options
  • Options affect response
  • Tests for regeneration options

10. E2E Tests with Playwright

Effort: 3-5 days Files: New e2e/ directory

Description: Set up Playwright for critical user flow E2E testing.

Features:

  • Login → chat → send message flow
  • Voice mode activation and basic interaction
  • Citation display and expansion
  • Conversation management (rename, archive, delete)

Acceptance Criteria:

  • Playwright configured and running
  • 5+ critical path E2E tests passing
  • CI integration for E2E tests

Dependencies on Backend

Most Phase 3 items are frontend-only. Potential backend dependencies:

FeatureBackend Dependency
Voice metricsNone (already tracked client-side)
Mic permissionNone (browser API)
Message actionsExisting APIs sufficient
BranchingPOST /api/conversations/:id/branch (exists)
Transcript previewWebSocket events (already sent)
Citation filtersNone (client-side filtering)
Clinical presetsMay need POST /api/clinical-context/presets
Barge-inOpenAI Realtime API (already supported)
Regeneration optionsMay need API params for temp/length

Estimated Timeline

PriorityItemsEstimated Effort
P0Voice Metrics, Mic UX, Message Actions3-5 days
P1Inline Branch, Transcript Preview, Citation Filters4-7 days
P2Presets, Barge-in, Regeneration, E2E7-12 days
Total10 items14-24 days

Testing Strategy

Unit Tests

  • All new components tested with Vitest + React Testing Library
  • Mock voice session hook for VoiceMetricsDisplay tests
  • Test filter/search logic in CitationSidebar

Integration Tests

  • Message action flows (edit → save → verify)
  • Branch creation flow
  • Citation filter interactions

E2E Tests (P2)

  • Critical path flows with Playwright
  • Voice mode activation (if possible with mocked audio)

Files to Create/Modify

New Files

  • src/components/voice/VoiceMetricsDisplay.tsx
  • src/components/chat/BranchPreview.tsx
  • src/components/chat/RegenerateOptionsDialog.tsx
  • src/components/clinical/ClinicalContextPresets.tsx
  • e2e/ directory with Playwright tests

Modified Files

  • src/components/voice/VoiceModePanel.tsx (metrics, transcript preview)
  • src/components/chat/MessageActionMenu.tsx (UX enhancements)
  • src/components/chat/MessageBubble.tsx (action visibility)
  • src/components/citations/CitationSidebar.tsx (filters)
  • src/hooks/useRealtimeVoiceSession.ts (mic error handling)

Open Questions

  1. Clinical context presets API: Should presets be stored server-side per user, or just in localStorage?
  2. Regeneration options: Does the backend support temperature/length params for regeneration?
  3. E2E voice testing: Can we mock audio APIs in Playwright, or should voice E2E be manual?

Phase 3A Summary – Voice UX & Observability (Completed)

Phase 3A focused on voice mode polish and observability. This work was completed as part of the Phase 2 polish effort (PR #66).

Implemented Features

  1. VoiceMetricsDisplay Component

    • Collapsible metrics panel with real-time latency display
    • Color-coded indicators (green <500ms, yellow 500-1000ms, red >1000ms)
    • Displays: connection time, STT latency, response latency, time to first transcript
    • Shows user/AI message counts and reconnect count
    • Accessible legend with sr-only text for screen readers
    • Robust header that handles narrow viewport widths
  2. Mic Permission UX

    • Contextual error messages for permission denied vs generic errors
    • Browser settings instructions for granting mic access
    • "Use text-only mode" fallback button
    • State properly resets on disconnect/reconnect
    • Retry button for non-permission connection errors
  3. Voice Metrics Logging

    • Console logging for observability: voice_session_connect_ms, voice_stt_latency_ms, voice_first_reply_ms, voice_session_duration_ms
    • onMetricsUpdate callback for parent component integration

Test Coverage

  • VoiceMetricsDisplay.test.tsx: 25 tests (visibility, collapsible, metrics display, formatting, color coding, accessibility)
  • VoiceModePanel-metrics.test.tsx: 8 tests (integration wiring)
  • VoiceModePanel-permissions.test.tsx: 14 tests (permission handling, state, connection status)

Upcoming (Phase 3D+)

  • Voice transcript preview during speech
  • Barge-in indicator
  • Message action menu enhancements

Phase 3B Summary – Keyboard-driven Voice UX & Responsive Layout (Completed)

Phase 3B focused on keyboard accessibility and responsive design for voice mode. This work was completed as PR #67.

Implemented Features

  1. Keyboard-driven Voice Mode Control

    • Global hotkey Ctrl+Shift+V to toggle voice mode
    • Push-to-talk mode (hold Space to talk)
    • Escape to disconnect voice session
    • Full keyboard navigation within voice panel
  2. Responsive Voice Panel Layout

    • Stacked layout on narrow screens (< 640px)
    • Touch-friendly buttons meeting 44px minimum tap targets
    • Metrics legend wraps appropriately on mobile
    • Waveform scales to viewport width

Test Coverage

  • Multiple tests for keyboard interactions and responsive behavior

Phase 3C Summary – Advanced Branching & Citations (Completed)

Phase 3C focused on conversation branching preview and citation filtering enhancements. This work was completed as part of feature/frontend-phase3C-branches-citations.

Implemented Features

  1. BranchPreview Component

    • Confirmation dialog before creating branch
    • Shows parent message preview with truncation
    • Displays message position (e.g., "message 2 of 4")
    • Shows count of messages that will be excluded from branch
    • Loading state with spinner during branch creation
    • Proper ARIA attributes for accessibility
  2. Visual Branch Indicator

    • Messages that have branches show "Branched" badge
    • Badge styled differently for user vs assistant messages
    • Uses branchedMessageIds Set for efficient lookup
  3. Citation Sidebar Filters

    • Type filter pills: All, Knowledge Base, PubMed/DOI, Guidelines
    • Message filter dropdown (when multiple messages have citations)
    • Filters combine with existing text search
    • Smart categorization based on source and sourceType
  4. Jump-to-Message Functionality

    • "Jump to message #N" button on each citation
    • Smooth scroll to message with highlight effect
    • 2-second highlight ring animation
    • Uses data-message-id attribute for targeting

Test Coverage

  • BranchPreview.test.tsx: 16 tests (rendering, actions, creating state, edge cases, accessibility)
  • CitationSidebar-Phase8.test.tsx: 18 new tests for Phase 3C features (type filters, message filters, jump-to, combined filters)

Files Created/Modified

New Files:

  • src/components/chat/BranchPreview.tsx
  • src/components/chat/__tests__/BranchPreview.test.tsx

Modified Files:

  • src/pages/ChatPage.tsx (branch preview state, onJumpToMessage callback)
  • src/components/chat/MessageList.tsx (branchedMessageIds prop)
  • src/components/chat/MessageBubble.tsx (hasBranch prop, visual indicator)
  • src/components/citations/CitationSidebar.tsx (type/message filters, jump-to)
  • src/components/citations/__tests__/CitationSidebar-Phase8.test.tsx (new tests)

Phase 3D Summary – Voice Transcript Preview (Completed)

Phase 3D focused on implementing live speech-to-text preview while the user is speaking. This work was completed on branch feature/frontend-phase3D-voice-transcript-preview.

Implemented Features

  1. Hook-level Partial Transcript Support

    • Extended useRealtimeVoiceSession hook with partialTranscript state
    • Added handler for conversation.item.input_audio_transcription.delta events
    • Accumulates partial text as speech is recognized
    • Clears partial transcript on speech start and when final transcript arrives
    • Partial transcripts count toward "time to first transcript" metrics
  2. VoiceTranscriptPreview Component

    • Shows "Listening" indicator with animated pulsing dot
    • Displays partial transcript text in italic blue styling
    • Blinking cursor indicates more text is expected
    • Only visible when speaking AND partial text exists
    • Accessible with aria-live="polite" and aria-atomic="false"
    • Decorative elements hidden from screen readers
  3. VoiceModePanel Integration

    • VoiceTranscriptPreview appears after waveform, before final transcript
    • "Speaking..." indicator hidden when partial transcript is displayed
    • Smooth transition from partial to final transcript display

Test Coverage

  • VoiceTranscriptPreview.test.tsx: 14 tests (rendering, visual indicators, accessibility, content updates, edge cases)
  • useRealtimeVoiceSession.test.ts: 2 additional tests (partialTranscript initialization, disconnect cleanup)

Files Created/Modified

New Files:

  • src/components/voice/VoiceTranscriptPreview.tsx
  • src/components/voice/__tests__/VoiceTranscriptPreview.test.tsx

Modified Files:

  • src/hooks/useRealtimeVoiceSession.ts (partialTranscript state, delta event handler)
  • src/hooks/__tests__/useRealtimeVoiceSession.test.ts (2 new tests)
  • src/components/voice/VoiceModePanel.tsx (VoiceTranscriptPreview integration)

Additional P1 Backlog Items (Suggested)

Keyboard-driven Voice UX

Effort: 1-2 days Files: VoiceModePanel.tsx, MessageInput.tsx

Description: Add keyboard shortcuts for voice mode control.

Features:

  • Global hotkey to toggle voice mode (e.g., Ctrl+Shift+V)
  • Push-to-talk mode option (hold Space to talk)
  • Keyboard navigation within voice panel
  • Escape to disconnect

Acceptance Criteria:

  • Can toggle voice mode with keyboard shortcut
  • Push-to-talk mode available in settings
  • Tests for keyboard interactions

Responsive Voice Panel & Metrics Layout

Effort: 1 day Files: VoiceModePanel.tsx, VoiceMetricsDisplay.tsx

Description: Ensure voice panel and metrics display work well on mobile and narrow viewports.

Features:

  • Stacked layout on narrow screens
  • Touch-friendly buttons (minimum 44px tap targets)
  • Metrics legend collapses or wraps on mobile
  • Waveform scales appropriately

Acceptance Criteria:

  • Usable on 320px viewport width
  • All interactive elements meet touch target guidelines
  • Tests for responsive behavior (if feasible)

Created: 2025-11-25 Last Updated: 2025-11-26 Author: Claude (AI Assistant) Status: Phase 3A-D Complete

šŸ¤– Generated with Claude Code

Beginning of guide
End of guide