Initial commit: Brachnha Insight project setup

- Next.js 14+ with App Router and TypeScript
- Tailwind CSS and ShadCN UI styling
- Zustand state management
- Dexie.js for IndexedDB (local-first data)
- Auth.js v5 for authentication
- BMAD framework integration

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Max
2026-01-26 12:28:43 +07:00
commit 3fbbb1a93b
812 changed files with 150531 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { ChatBubble } from './ChatBubble';
describe('ChatBubble', () => {
it('renders user variant correctly', () => {
const { container } = render(
<ChatBubble
role="user"
content="Hello world"
timestamp={Date.now()}
/>
);
const bubble = screen.getByText('Hello world');
expect(bubble).toBeInTheDocument();
expect(container.querySelector('.bg-slate-700')).toBeInTheDocument();
expect(container.querySelector('.ml-auto')).toBeInTheDocument();
});
it('renders ai variant correctly', () => {
const { container } = render(
<ChatBubble
role="ai"
content="AI response"
timestamp={Date.now()}
/>
);
const bubble = screen.getByText('AI response');
expect(bubble).toBeInTheDocument();
expect(container.querySelector('.bg-slate-100')).toBeInTheDocument();
expect(container.querySelector('.mr-auto')).toBeInTheDocument();
});
it('renders system variant correctly', () => {
const { container } = render(
<ChatBubble
role="system"
content="System message"
timestamp={Date.now()}
/>
);
const bubble = screen.getByText('System message');
expect(bubble).toBeInTheDocument();
expect(container.querySelector('.text-center')).toBeInTheDocument();
// System messages don't have timestamps
expect(container.querySelector('.text-xs.opacity-70')).not.toBeInTheDocument();
});
it('renders markdown inline code', () => {
render(
<ChatBubble
role="user"
content="Check `const x = 1;` here"
timestamp={Date.now()}
/>
);
expect(screen.getByText('const x = 1;')).toBeInTheDocument();
});
it('renders markdown code blocks', () => {
const { container } = render(
<ChatBubble
role="user"
content="Check this code block:\n\n```\nconst x = 1;\n```"
timestamp={Date.now()}
/>
);
// Verify content is rendered
expect(container.textContent).toContain('const x = 1;');
// Check for code element (code blocks have both pre and code)
const codeElement = container.querySelector('code');
expect(codeElement).toBeInTheDocument();
});
it('displays timestamp for non-system messages', () => {
const timestamp = Date.now();
const { container } = render(
<ChatBubble
role="user"
content="Test"
timestamp={timestamp}
/>
);
const timeString = new Date(timestamp).toLocaleTimeString();
const timeElement = screen.getByText(timeString);
expect(timeElement).toBeInTheDocument();
expect(timeElement).toHaveClass('text-xs', 'opacity-70');
});
it('applies correct color contrast for accessibility', () => {
const { container: userContainer } = render(
<ChatBubble role="user" content="User msg" timestamp={Date.now()} />
);
const { container: aiContainer } = render(
<ChatBubble role="ai" content="AI msg" timestamp={Date.now()} />
);
// User bubbles have white text on dark background
expect(userContainer.querySelector('.bg-slate-700.text-white')).toBeInTheDocument();
// AI bubbles have dark text on light background
expect(aiContainer.querySelector('.bg-slate-100')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMemo } from 'react';
type MessageRole = 'user' | 'ai' | 'system';
interface ChatBubbleProps {
role: MessageRole;
content: string;
timestamp: number;
}
const bubbleStyles = {
user: 'bg-slate-700 text-white ml-auto',
ai: 'bg-slate-100 text-slate-800 mr-auto',
system: 'bg-transparent text-slate-500 mx-auto text-center text-sm',
};
export function ChatBubble({ role, content, timestamp }: ChatBubbleProps) {
const baseClassName = 'p-3 rounded-lg max-w-[80%]';
const roleClassName = bubbleStyles[role];
// Memoize markdown configuration to prevent re-creation on every render
const markdownComponents = useMemo(() => ({
// Style code blocks with dark theme - pre wraps code blocks
pre: ({ children }: any) => (
<pre className="bg-slate-900 text-white p-2 rounded overflow-x-auto my-2">
{children}
</pre>
),
// Inline code - code inside inline text
code: ({ inline, className, children }: any) => {
if (inline) {
return (
<code className="bg-slate-200 dark:bg-slate-700 px-1 rounded text-sm">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
}), []);
const markdownPlugins = useMemo(() => [remarkGfm], []);
return (
<div className={`${baseClassName} ${roleClassName}`} data-testid={`chat-bubble-${role}`}>
<ReactMarkdown
remarkPlugins={markdownPlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
{role !== 'system' && (
<div className="text-xs opacity-70 mt-1">
{new Date(timestamp).toLocaleTimeString()}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ChatInput } from './ChatInput';
describe('ChatInput', () => {
it('renders textarea and send button', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const textarea = screen.getByRole('textbox');
expect(textarea).toBeInTheDocument();
const sendButton = screen.getByRole('button', { name: /send/i });
expect(sendButton).toBeInTheDocument();
});
it('updates input value when typing', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'Hello world' } });
expect(textarea).toHaveValue('Hello world');
});
it('calls onSend and clears input when send button clicked', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'Test message' } });
const sendButton = screen.getByRole('button', { name: /send/i });
fireEvent.click(sendButton);
expect(mockOnSend).toHaveBeenCalledWith('Test message');
expect(textarea).toHaveValue('');
});
it('sends message on Enter key press', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'Test message' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockOnSend).toHaveBeenCalledWith('Test message');
});
it('does not send empty messages', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: ' ' } });
const sendButton = screen.getByRole('button', { name: /send/i });
fireEvent.click(sendButton);
expect(mockOnSend).not.toHaveBeenCalled();
});
it('disables send button when disabled prop is true', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={true} />);
const sendButton = screen.getByRole('button', { name: /send/i });
expect(sendButton).toBeDisabled();
});
it('send button meets 44px minimum touch target', () => {
const mockOnSend = vi.fn();
render(<ChatInput onSend={mockOnSend} disabled={false} />);
const sendButton = screen.getByRole('button', { name: /send/i });
// Check that button has min-h-[44px] and min-w-[44px] classes
expect(sendButton).toHaveClass('min-h-[44px]', 'min-w-[44px]');
});
});

View File

@@ -0,0 +1,84 @@
import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
interface ChatInputProps {
onSend: (message: string) => void;
isFastTrack?: boolean;
onToggleFastTrack?: () => void;
disabled?: boolean;
}
export function ChatInput({ onSend, isFastTrack = false, onToggleFastTrack, disabled = false }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea based on content
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
}
}, [input]);
const handleSend = () => {
const trimmed = input.trim();
if (trimmed && !disabled) {
onSend(trimmed);
setInput('');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex flex-col gap-2 border-t pt-4">
{onToggleFastTrack && (
<div className="flex justify-end px-1">
<button
onClick={onToggleFastTrack}
className={`text-xs flex items-center gap-1 px-2 py-1 rounded-full transition-colors ${isFastTrack
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'text-slate-500 hover:bg-slate-100'
}`}
data-testid="fast-track-toggle"
>
<span className={isFastTrack ? "text-amber-500" : "text-slate-400"}></span>
{isFastTrack ? 'Fast Track Active' : 'Fast Track Mode'}
</button>
</div>
)}
<div className={`flex gap-2 items-end ${isFastTrack ? 'p-1 rounded-lg bg-amber-50/50 -m-1' : ''}`}>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isFastTrack ? "Enter your insight directly (skipping interview)..." : "Type a message..."}
disabled={disabled}
rows={1}
className={`flex-1 p-3 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-slate-500 min-h-[44px] ${isFastTrack ? 'border-amber-200 focus:ring-amber-400' : ''
}`}
data-testid="chat-input"
/>
<button
onClick={handleSend}
disabled={disabled || !input.trim()}
className={`p-3 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-h-[44px] min-w-[44px] transition-colors ${isFastTrack
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-slate-700 hover:bg-slate-800'
}`}
aria-label="Send message"
data-testid="send-button"
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
// Mock scrollIntoView
Element.prototype.scrollIntoView = vi.fn();
// Create a selector-based mock system
let mockState = {
messages: [] as any[],
isLoading: false,
hydrate: vi.fn(),
addMessage: vi.fn(),
isRefining: false,
cancelRefinement: vi.fn(),
showDraftView: false,
isFastTrack: false,
toggleFastTrack: vi.fn(),
};
const mockUseChatStore = vi.fn((selector?: Function) => {
return selector ? selector(mockState) : mockState;
});
vi.mock('@/lib/store/chat-store', () => ({
useChatStore: (selector?: Function) => {
return selector ? selector(mockState) : mockState;
},
}));
import { ChatWindow } from './ChatWindow';
describe('ChatWindow', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset state
mockState = {
messages: [],
isLoading: false,
hydrate: vi.fn(),
addMessage: vi.fn(),
isRefining: false,
cancelRefinement: vi.fn(),
showDraftView: false,
isFastTrack: false,
toggleFastTrack: vi.fn(),
};
});
it('renders messages from store using atomic selectors', () => {
mockState.messages = [
{ id: 1, role: 'user', content: 'Hello', timestamp: Date.now() },
{ id: 2, role: 'assistant', content: 'Hi there!', timestamp: Date.now() },
];
render(<ChatWindow />);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('Hi there!')).toBeInTheDocument();
});
it('shows typing indicator when isTyping is true', () => {
render(<ChatWindow isTyping={true} />);
expect(screen.getByText(/teacher is typing/i)).toBeInTheDocument();
});
it('renders messages container with proper data attribute', () => {
const { container } = render(<ChatWindow />);
const messagesContainer = container.querySelector('[data-testid="messages-container"]');
expect(messagesContainer).toBeInTheDocument();
});
it('shows loading state while hydrating', () => {
mockState.isLoading = true;
render(<ChatWindow />);
expect(screen.getByText(/loading history/i)).toBeInTheDocument();
});
it('shows empty state when no messages', () => {
render(<ChatWindow />);
expect(screen.getByText(/start a conversation/i)).toBeInTheDocument();
});
it('applies Morning Mist theme classes', () => {
const { container } = render(<ChatWindow />);
expect(container.firstChild).toHaveClass('bg-slate-50');
});
// Story 2.3: Refinement Mode Tests
describe('Refinement Mode (Story 2.3)', () => {
it('should not show refinement badge when isRefining is false', () => {
mockState.isRefining = false;
const { container } = render(<ChatWindow />);
expect(screen.queryByText(/refining your draft/i)).not.toBeInTheDocument();
});
it('should show refinement badge when isRefining is true', () => {
mockState.isRefining = true;
mockState.cancelRefinement = vi.fn();
const { container } = render(<ChatWindow />);
expect(screen.getByText(/refining your draft/i)).toBeInTheDocument();
});
it('should call cancelRefinement when cancel button is clicked', () => {
mockState.isRefining = true;
mockState.cancelRefinement = vi.fn();
const { container } = render(<ChatWindow />);
const cancelButton = screen.getByRole('button', { name: /cancel refinement/i });
cancelButton.click();
expect(mockState.cancelRefinement).toHaveBeenCalledTimes(1);
});
it('should disable chat input when refinement mode is active', () => {
mockState.isRefining = true;
mockState.showDraftView = true;
render(<ChatWindow />);
const chatInput = screen.getByRole('textbox');
expect(chatInput).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,100 @@
'use client';
import { useEffect, useRef } from 'react';
import { useChatStore } from '@/lib/store/chat-store';
import { ChatBubble } from './ChatBubble';
import { TypingIndicator } from './TypingIndicator';
import { ChatInput } from './ChatInput';
import { DraftViewSheet } from '../draft/DraftViewSheet';
import { RefinementModeBadge } from './RefinementModeBadge';
interface ChatWindowProps {
isTyping?: boolean;
}
export function ChatWindow({ isTyping = false }: ChatWindowProps) {
const messages = useChatStore((s) => s.messages);
const isLoading = useChatStore((s) => s.isLoading);
const sendMessage = useChatStore((s) => s.addMessage);
const hydrate = useChatStore((s) => s.hydrate);
const isFastTrack = useChatStore((s) => s.isFastTrack);
const toggleFastTrack = useChatStore((s) => s.toggleFastTrack);
const showDraftView = useChatStore((s) => s.showDraftView);
// Refinement state (Story 2.3)
const isRefining = useChatStore((s) => s.isRefining);
const cancelRefinement = useChatStore((s) => s.cancelRefinement);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Hydrate messages on mount
useEffect(() => {
hydrate();
}, [hydrate]);
// Auto-scroll to bottom when messages change or typing indicator shows
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isTyping]);
const handleSend = (content: string) => {
sendMessage(content, 'user');
};
return (
<>
<div className="flex flex-col h-screen bg-slate-50 max-w-2xl mx-auto">
{/* Header */}
<header className="py-4 px-4 border-b bg-white">
<h1 className="text-xl font-bold text-slate-800">Venting Session</h1>
</header>
{/* Refinement Mode Badge (Story 2.3) */}
{isRefining && <RefinementModeBadge onCancel={cancelRefinement || (() => {})} />}
{/* Messages Container */}
<div
ref={messagesContainerRef}
data-testid="messages-container"
className="flex-1 overflow-y-auto px-4 py-4 space-y-4 flex flex-col"
>
{isLoading ? (
<p className="text-center text-slate-500">Loading history...</p>
) : messages.length === 0 ? (
<p className="text-center text-slate-400">
Start a conversation by typing a message below
</p>
) : (
messages.map((msg) => (
<ChatBubble
key={msg.id || msg.timestamp}
role={msg.role === 'assistant' ? 'ai' : 'user'}
content={msg.content}
timestamp={msg.timestamp}
/>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Typing Indicator */}
<TypingIndicator isTyping={isTyping} />
{/* Input */}
<div className="px-4 pb-4">
<ChatInput
onSend={handleSend}
disabled={isLoading || showDraftView}
isFastTrack={isFastTrack}
onToggleFastTrack={toggleFastTrack}
/>
</div>
</div>
{/* Draft View Sheet */}
<DraftViewSheet />
</>
);
}

View File

@@ -0,0 +1,33 @@
/**
* DraftingIndicator Component
*
* Displays a distinct animation when the Ghostwriter Agent is generating a draft.
* Uses a skeleton/shimmer pattern different from the typing indicator.
*
* UX Design:
* - "Skeleton card loader" (shimmering lines) to show work is happening
* - Different from "Teacher is typing..." dots
* - Text: "Drafting your post..." or "Polishing your insight..."
*/
import { useChatStore } from '../../../lib/store/chat-store';
export function DraftingIndicator() {
const isDrafting = useChatStore((s) => s.isDrafting);
if (!isDrafting) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground">
{/* Shimmering skeleton animation */}
<div className="flex gap-1">
<span className="w-2 h-2 bg-foreground/20 rounded-full animate-pulse" />
<span className="w-2 h-2 bg-foreground/20 rounded-full animate-pulse delay-75" />
<span className="w-2 h-2 bg-foreground/20 rounded-full animate-pulse delay-150" />
</div>
<span className="animate-pulse">Drafting your post...</span>
</div>
);
}

View File

@@ -0,0 +1,53 @@
/**
* Tests for RefinementIndicator Component
*
* Story 2.3: Refinement Loop (Regeneration)
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RefinementIndicator } from './RefinementIndicator';
describe('RefinementIndicator', () => {
it('should render default message', () => {
render(<RefinementIndicator />);
expect(screen.getByText('Refining your draft...')).toBeInTheDocument();
});
it('should render custom message when provided', () => {
render(<RefinementIndicator message="Applying your changes..." />);
expect(screen.getByText('Applying your changes...')).toBeInTheDocument();
});
it('should render animated dots', () => {
const { container } = render(<RefinementIndicator />);
const dots = container.querySelectorAll('.animate-pulse');
expect(dots).toHaveLength(3);
});
it('should have proper accessibility attributes', () => {
const { container } = render(<RefinementIndicator />);
const indicator = container.querySelector('[role="status"]');
expect(indicator).toBeInTheDocument();
expect(indicator).toHaveAttribute('aria-live', 'polite');
expect(indicator).toHaveAttribute('aria-busy', 'true');
});
it('should hide dots from screen readers', () => {
const { container } = render(<RefinementIndicator />);
const dots = container.querySelectorAll('.animate-pulse');
dots.forEach(dot => {
expect(dot).toHaveAttribute('aria-hidden', 'true');
});
});
it('should have proper styling for loading state', () => {
const { container } = render(<RefinementIndicator />);
const indicator = container.querySelector('[role="status"]');
expect(indicator).toHaveClass('bg-slate-50');
expect(indicator).toHaveClass('text-slate-600');
});
});

View File

@@ -0,0 +1,38 @@
'use client';
/**
* RefinementIndicator Component
*
* Story 2.3: Loading state during draft regeneration
*
* Displays a loading indicator while the Ghostwriter is regenerating
* the draft based on user feedback.
*
* Features:
* - Matches the DraftingIndicator styling
* - Shows "Refining..." message
* - Accessible with proper ARIA labels
*/
interface RefinementIndicatorProps {
message?: string;
}
export function RefinementIndicator({ message = "Refining your draft..." }: RefinementIndicatorProps) {
return (
<div
className="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 px-4 py-2 rounded-lg"
role="status"
aria-live="polite"
aria-busy="true"
>
{/* Animated dots */}
<div className="flex gap-1">
<span className="w-2 h-2 bg-slate-400 rounded-full animate-pulse" aria-hidden="true" />
<span className="w-2 h-2 bg-slate-400 rounded-full animate-pulse delay-100" aria-hidden="true" />
<span className="w-2 h-2 bg-slate-400 rounded-full animate-pulse delay-200" aria-hidden="true" />
</div>
<span>{message}</span>
</div>
);
}

View File

@@ -0,0 +1,64 @@
/**
* Tests for RefinementModeBadge Component
*
* Story 2.3: Refinement Loop (Regeneration)
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RefinementModeBadge } from './RefinementModeBadge';
describe('RefinementModeBadge', () => {
it('should render badge with text', () => {
const mockCancel = vi.fn();
render(<RefinementModeBadge onCancel={mockCancel} />);
expect(screen.getByText('Refining your draft...')).toBeInTheDocument();
});
it('should render cancel button', () => {
const mockCancel = vi.fn();
render(<RefinementModeBadge onCancel={mockCancel} />);
const cancelButton = screen.getByRole('button', { name: /cancel refinement/i });
expect(cancelButton).toBeInTheDocument();
});
it('should call onCancel when cancel button is clicked', () => {
const mockCancel = vi.fn();
render(<RefinementModeBadge onCancel={mockCancel} />);
const cancelButton = screen.getByRole('button', { name: /cancel refinement/i });
fireEvent.click(cancelButton);
expect(mockCancel).toHaveBeenCalledTimes(1);
});
it('should have proper accessibility attributes', () => {
const mockCancel = vi.fn();
const { container } = render(<RefinementModeBadge onCancel={mockCancel} />);
const badge = container.querySelector('[role="status"]');
expect(badge).toBeInTheDocument();
expect(badge).toHaveAttribute('aria-live', 'polite');
expect(badge).toHaveAttribute('aria-label', 'Refinement mode active');
});
it('should have amber color scheme for visual indication', () => {
const mockCancel = vi.fn();
const { container } = render(<RefinementModeBadge onCancel={mockCancel} />);
const badge = container.querySelector('[role="status"]');
expect(badge).toHaveClass('bg-amber-50');
expect(badge).toHaveClass('border-amber-200');
expect(badge).toHaveClass('text-amber-800');
});
it('should have keyboard accessible cancel button', () => {
const mockCancel = vi.fn();
render(<RefinementModeBadge onCancel={mockCancel} />);
const cancelButton = screen.getByRole('button', { name: /cancel refinement/i });
expect(cancelButton).toHaveAttribute('type', 'button');
});
});

View File

@@ -0,0 +1,42 @@
'use client';
/**
* RefinementModeBadge Component
*
* Story 2.3: Visual indicator for refinement mode
*
* Displays a badge at the top of the chat interface when the user is
* in refinement mode, indicating they're providing feedback to improve
* their draft.
*
* Features:
* - Amber/yellow theme to indicate "work in progress"
* - Cancel button to exit refinement mode
* - Accessible with proper ARIA labels
* - Keyboard accessible cancel button
*/
interface RefinementModeBadgeProps {
onCancel: () => void;
}
export function RefinementModeBadge({ onCancel }: RefinementModeBadgeProps) {
return (
<div
className="flex items-center gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-full text-sm text-amber-800 mx-4 mt-4"
role="status"
aria-live="polite"
aria-label="Refinement mode active"
>
<span className="flex-1">Refining your draft...</span>
<button
onClick={onCancel}
type="button"
className="text-amber-600 hover:text-amber-800 underline text-xs font-medium"
aria-label="Cancel refinement"
>
Cancel
</button>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TypingIndicator } from './TypingIndicator';
describe('TypingIndicator', () => {
it('renders when isTyping is true', () => {
render(<TypingIndicator isTyping={true} />);
expect(screen.getByText(/teacher is typing/i)).toBeInTheDocument();
});
it('does not render when isTyping is false', () => {
const { container } = render(<TypingIndicator isTyping={false} />);
expect(container.firstChild).toBe(null);
});
it('has animated dots', () => {
const { container } = render(<TypingIndicator isTyping={true} />);
const dots = container.querySelectorAll('.animate-pulse');
expect(dots.length).toBeGreaterThan(0);
});
it('uses correct styling for Morning Mist theme', () => {
const { container } = render(<TypingIndicator isTyping={true} />);
expect(container.querySelector('.text-slate-500')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
interface TypingIndicatorProps {
isTyping: boolean;
}
export function TypingIndicator({ isTyping }: TypingIndicatorProps) {
if (!isTyping) {
return null;
}
return (
<div className="text-slate-500 text-sm px-3 py-2 flex items-center gap-1" data-testid="typing-indicator">
<span>Teacher is typing</span>
<span className="flex gap-1">
<span className="animate-pulse">.</span>
<span className="animate-pulse" style={{ animationDelay: '0.2s' }}>.</span>
<span className="animate-pulse" style={{ animationDelay: '0.4s' }}>.</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from '@/lib/utils';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ChatBubbleProps {
role: 'user' | 'assistant' | 'system';
content: string;
}
export function ChatBubble({ role, content }: ChatBubbleProps) {
const isUser = role === 'user';
return (
<div className={cn(
"flex w-full mb-4",
isUser ? "justify-end" : "justify-start"
)}>
<div className={cn(
"max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm",
isUser
? "bg-blue-600 text-white rounded-tr-sm"
: "bg-white border border-slate-200 text-slate-800 rounded-tl-sm"
)}>
{/* Render Markdown safely */}
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => <p className="mb-1 last:mb-0" {...props} />,
pre: ({ node, ...props }) => (
<div className="max-w-full overflow-x-auto my-2 rounded bg-black/5 p-2 scrollbar-thin scrollbar-thumb-gray-300">
<pre {...props} className="whitespace-pre" />
</div>
),
code: ({ node, className, ...props }) => (
<code
className={cn(
"font-mono text-xs break-words",
!className && "bg-black/5 px-1 py-0.5 rounded",
className
)}
{...props}
/>
)
}}
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Send, StopCircle } from 'lucide-react';
interface ChatInputProps {
onSend: (message: string) => void;
isLoading: boolean;
}
export function ChatInput({ onSend, isLoading }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [input]);
const handleSend = () => {
if (!input.trim() || isLoading) return;
onSend(input);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 sticky bottom-0">
<div className="flex gap-2 items-center max-w-3xl mx-auto">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="What's specifically frustrating you right now?"
className="resize-none min-h-[44px] max-h-[120px] py-3 rounded-xl border-slate-300 focus:ring-blue-500"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isLoading}
size="icon"
className="h-11 w-11 rounded-xl shrink-0 bg-blue-600 hover:bg-blue-700 transition-colors"
>
{isLoading ? <StopCircle className="h-5 w-5 animate-pulse" /> : <Send className="h-5 w-5" />}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useEffect, useRef } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { ChatBubble } from './chat-bubble';
import { TypingIndicator } from './typing-indicator';
import { useTeacherStatus } from '@/store/use-session';
interface ChatWindowProps {
sessionId: string | null;
}
export function ChatWindow({ sessionId }: ChatWindowProps) {
const teacherStatus = useTeacherStatus();
const bottomRef = useRef<HTMLDivElement>(null);
// Reactive query for messages
const messages = useLiveQuery(
async () => {
if (!sessionId) return [];
return await db.chatLogs
.where('sessionId')
.equals(sessionId)
.sortBy('timestamp');
},
[sessionId]
);
// Auto-scroll to bottom
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, teacherStatus]);
if (!sessionId) {
return <div className="flex-1 flex items-center justify-center text-slate-400">Loading session...</div>;
}
if (!messages || messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 space-y-4">
<h2 className="text-xl font-semibold text-slate-700">What's specifically frustrating you right now?</h2>
<p className="text-slate-500 max-w-sm">
Don't hold back. I'll help you turn that annoyance into a valuable insight.
</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-4 py-6 scroll-smooth">
<div className="max-w-3xl mx-auto space-y-4">
{messages.map((msg) => (
<ChatBubble key={msg.id} role={msg.role} content={msg.content} />
))}
{teacherStatus !== 'idle' && (
<TypingIndicator />
)}
<div ref={bottomRef} className="h-4" />
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { ChatBubble } from './ChatBubble';
export { ChatInput } from './ChatInput';
export { ChatWindow } from './ChatWindow';
export { TypingIndicator } from './TypingIndicator';
export { RefinementModeBadge } from './RefinementModeBadge';
export { RefinementIndicator } from './RefinementIndicator';

View File

@@ -0,0 +1,14 @@
export function TypingIndicator() {
return (
<div className="flex w-full mb-4 justify-start">
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm">
<div className="flex space-x-1 items-center h-5">
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce"></div>
</div>
<span className="sr-only">Teacher is typing...</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
/**
* OfflineIndicator Component Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { render } from '@testing-library/react';
import { OfflineIndicator } from './OfflineIndicator';
import { useOfflineStore } from '../../../lib/store/offline-store';
describe('Story 3.3: OfflineIndicator Component', () => {
beforeEach(() => {
// Reset store state before each test
useOfflineStore.setState({
isOnline: true,
pendingCount: 0,
lastSyncAt: null,
syncing: false,
});
});
describe('when online and synced', () => {
it('should render nothing', () => {
const { container } = render(<OfflineIndicator />);
expect(container.firstChild).toBe(null);
});
});
describe('when offline', () => {
it('should show offline badge', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { getByRole, getByText } = render(<OfflineIndicator />);
expect(getByRole('status')).toBeInTheDocument();
expect(getByText('Offline - Saved locally')).toBeInTheDocument();
});
it('should have correct styling for offline state', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { getByRole } = render(<OfflineIndicator />);
const badge = getByRole('status');
expect(badge).toHaveClass('bg-slate-800');
expect(badge).toHaveClass('text-white');
});
it('should include WifiOff icon', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { container } = render(<OfflineIndicator />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
describe('when online with pending items', () => {
it('should show pending count badge', () => {
useOfflineStore.setState({ isOnline: true, pendingCount: 3 });
const { getByRole, getByText } = render(<OfflineIndicator />);
expect(getByRole('status')).toBeInTheDocument();
expect(getByText('3 items to sync')).toBeInTheDocument();
});
it('should have correct styling for pending state', () => {
useOfflineStore.setState({ isOnline: true, pendingCount: 3 });
const { getByRole } = render(<OfflineIndicator />);
const badge = getByRole('status');
expect(badge).toHaveClass('bg-blue-100');
expect(badge).toHaveClass('text-blue-700');
});
});
describe('when syncing', () => {
it('should show syncing badge with spinner', () => {
useOfflineStore.setState({ isOnline: true, pendingCount: 3, syncing: true });
const { getByRole, getByText } = render(<OfflineIndicator />);
expect(getByRole('status')).toBeInTheDocument();
expect(getByText('Syncing...')).toBeInTheDocument();
});
it('should include Loader2 icon with animation', () => {
useOfflineStore.setState({ isOnline: true, pendingCount: 3, syncing: true });
const { container } = render(<OfflineIndicator />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('animate-spin');
});
});
describe('positioning', () => {
it('should be fixed at top center of screen', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { container } = render(<OfflineIndicator />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toBeInTheDocument();
expect(wrapper).toHaveClass('fixed');
expect(wrapper).toHaveClass('top-4');
expect(wrapper).toHaveClass('left-1/2');
expect(wrapper).toHaveClass('-translate-x-1/2');
});
it('should have high z-index', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { container } = render(<OfflineIndicator />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('z-50');
});
});
describe('accessibility', () => {
it('should have role="status"', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { getByRole } = render(<OfflineIndicator />);
expect(getByRole('status')).toBeInTheDocument();
});
it('should have aria-live="polite"', () => {
useOfflineStore.setState({ isOnline: false, pendingCount: 0 });
const { getByRole } = render(<OfflineIndicator />);
expect(getByRole('status')).toHaveAttribute('aria-live', 'polite');
});
});
});

View File

@@ -0,0 +1,68 @@
/**
* OfflineIndicator Component
*
* Story 3.3: Displays offline/sync status badge
*
* Architecture Compliance:
* - Uses atomic selectors from OfflineStore
* - Follows Logic Sandwich: UI -> Store (no direct DB access)
* - Self-contained feature component
*/
'use client';
import { WifiOff, Cloud, Loader2 } from 'lucide-react';
import { useOfflineStore } from '../../../lib/store/offline-store';
/**
* Offline status indicator component
*
* Shows a subtle badge when offline or syncing:
* - Offline: "Offline - Saved locally" badge
* - Syncing: "Syncing..." badge with spinner
* - Online & synced: No badge (clean UX)
*/
export function OfflineIndicator() {
const isOnline = useOfflineStore(s => s.isOnline);
const pendingCount = useOfflineStore(s => s.pendingCount);
const syncing = useOfflineStore(s => s.syncing);
// Show nothing when online and synced
if (isOnline && pendingCount === 0) {
return null;
}
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50">
<div
className={`
px-4 py-2 rounded-full shadow-lg text-sm font-medium flex items-center gap-2
${isOnline ? 'bg-blue-100 text-blue-700' : 'bg-slate-800 text-white'}
`}
role="status"
aria-live="polite"
>
{!isOnline && (
<>
<WifiOff className="w-4 h-4" aria-hidden="true" />
<span>Offline - Saved locally</span>
</>
)}
{isOnline && pendingCount > 0 && !syncing && (
<>
<Cloud className="w-4 h-4" aria-hidden="true" />
<span>{pendingCount} items to sync</span>
</>
)}
{syncing && (
<>
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
<span>Syncing...</span>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { OfflineIndicator } from './OfflineIndicator';

View File

@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { useChatStore } from '@/lib/store/chat-store';
interface CopyButtonProps {
draftId: number;
onCopy?: () => void;
variant?: 'standalone' | 'toolbar';
className?: string;
label?: string;
}
/**
* CopyButton Component
*
* Standalone button to copy draft content to clipboard.
* Uses ChatStore action which uses ClipboardUtil.
*/
export function CopyButton({
draftId,
onCopy,
variant = 'standalone',
className = '',
label = 'Copy'
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const copyDraftToClipboard = useChatStore(s => s.copyDraftToClipboard);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyDraftToClipboard(draftId);
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 2000);
};
if (variant === 'toolbar') {
return (
<button
onClick={handleCopy}
type="button"
className={`flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 ${className}`}
aria-label={copied ? "Copied to clipboard" : "Copy to clipboard without closing"}
>
{copied ? (
<Check className="w-5 h-5 text-green-600" aria-hidden="true" />
) : (
<Copy className="w-5 h-5" aria-hidden="true" />
)}
<span>{copied ? 'Copied!' : label}</span>
</button>
);
}
return (
<button
onClick={handleCopy}
type="button"
className={`p-2 rounded-md hover:bg-slate-100 transition-colors ${className}`}
aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
>
{copied ? (
<Check className="w-4 h-4 text-green-600" aria-hidden="true" />
) : (
<Copy className="w-4 h-4 text-slate-500" aria-hidden="true" />
)}
</button>
);
}

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DraftActions } from './DraftActions';
import { ThumbsUp, ThumbsDown, Copy } from 'lucide-react';
import type { Draft } from '@/lib/db/draft-service';
// Mock chat store to provide currentDraft for CopyButton rendering
vi.mock('@/lib/store/chat-store', () => ({
useChatStore: vi.fn(),
}));
import { useChatStore } from '@/lib/store/chat-store';
describe('DraftActions', () => {
const mockDraft: Draft = {
id: 1,
sessionId: 'session-1',
title: 'Test Draft',
content: '# Test Draft\n\nThis is test content.',
tags: ['test'],
createdAt: Date.now(),
status: 'draft',
};
const mockState = {
currentDraft: mockDraft,
startRefinement: vi.fn().mockResolvedValue(undefined),
copyDraftToClipboard: vi.fn().mockResolvedValue(true),
};
beforeEach(() => {
vi.clearAllMocks();
// Mock useChatStore to return currentDraft for CopyButton tests
(useChatStore as vi.Mock).mockImplementation((selector) => {
return selector(mockState);
});
});
it('renders Thumbs Up and Thumbs Down buttons', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /request changes/i })).toBeInTheDocument();
});
it('calls onApprove when Thumbs Up is clicked', () => {
const handleApprove = vi.fn();
render(
<DraftActions
onApprove={handleApprove}
onReject={vi.fn()}
/>
);
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
fireEvent.click(approveButton);
expect(handleApprove).toHaveBeenCalledTimes(1);
});
it('calls onReject when Thumbs Down is clicked', () => {
const handleReject = vi.fn();
render(
<DraftActions
onApprove={vi.fn()}
onReject={handleReject}
/>
);
const rejectButton = screen.getByRole('button', { name: /request changes/i });
fireEvent.click(rejectButton);
expect(handleReject).toHaveBeenCalledTimes(1);
});
it('renders Thumbs Up button with correct styling', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
expect(approveButton).toHaveClass('bg-slate-700', 'hover:bg-slate-800');
});
it('renders Thumbs Down button with outline style', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
const rejectButton = screen.getByRole('button', { name: /request changes/i });
expect(rejectButton).toHaveClass('border');
});
it('has minimum touch target height of 44px', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
const rejectButton = screen.getByRole('button', { name: /request changes/i });
// Check min-height class
expect(approveButton).toHaveClass('min-h-[44px]');
expect(rejectButton).toHaveClass('min-h-[44px]');
});
it('renders Thumbs Up and Thumbs Down icons', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
// Check that icons are rendered via lucide-react
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
const rejectButton = screen.getByRole('button', { name: /request changes/i });
// Icons should be present (lucide-react icons are SVG)
expect(approveButton.querySelector('svg')).toBeInTheDocument();
expect(rejectButton.querySelector('svg')).toBeInTheDocument();
});
it('uses sticky footer positioning', () => {
const { container } = render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
const footer = container.querySelector('.sticky');
expect(footer).toBeInTheDocument();
});
describe('Story 2.4: Copy Only Button', () => {
it('does not render Copy Only button when onCopyOnly is not provided', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
/>
);
expect(screen.queryByRole('button', { name: /copy to clipboard without closing/i })).not.toBeInTheDocument();
});
it('renders Copy Only button when onCopyOnly is provided and currentDraft exists', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /copy to clipboard without closing/i })).toBeInTheDocument();
});
it('calls onCopyOnly when Copy Only button is clicked', async () => {
const handleCopyOnly = vi.fn();
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={handleCopyOnly}
/>
);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
fireEvent.click(copyOnlyButton);
// Wait for async copy operation to complete
await waitFor(() => {
expect(handleCopyOnly).toHaveBeenCalledTimes(1);
});
});
it('renders Copy Only button with outline style', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={vi.fn()}
/>
);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
expect(copyOnlyButton).toHaveClass('border');
});
it('renders Copy icon in Copy Only button', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={vi.fn()}
/>
);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
expect(copyOnlyButton.querySelector('svg')).toBeInTheDocument();
});
it('has minimum touch target height of 44px for Copy Only button', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={vi.fn()}
/>
);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
expect(copyOnlyButton).toHaveClass('min-h-[44px]');
});
it('renders all three buttons when onCopyOnly is provided', () => {
render(
<DraftActions
onApprove={vi.fn()}
onReject={vi.fn()}
onCopyOnly={vi.fn()}
/>
);
// Should have 3 buttons: Not Quite, Just Copy, Approve
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(3);
});
});
});

View File

@@ -0,0 +1,71 @@
'use client';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
import { useChatStore } from '@/lib/store/chat-store';
import { CopyButton } from './CopyButton';
interface DraftActionsProps {
onApprove: () => void;
onReject: () => void;
onCopyOnly?: () => void;
}
/**
* DraftActions Component - Action bar with Thumbs Up/Down buttons
*
* Story 2.4: Extended with Copy Only button for copying draft without completing.
*
* Sticky footer with approve (copy + complete), copy only, and reject (regenerate) actions.
* - Minimum 44px touch targets for accessibility (WCAG AA)
* - Proper ARIA labels for screen readers
* - Sticky positioning to stay visible when scrolling long drafts
*/
export function DraftActions({ onApprove, onReject, onCopyOnly }: DraftActionsProps) {
const currentDraft = useChatStore((s) => s.currentDraft);
const startRefinement = useChatStore((s) => s.startRefinement);
const handleReject = () => {
// Trigger refinement flow (Story 2.3)
if (currentDraft && startRefinement) {
startRefinement(currentDraft.id);
}
// Then call the onReject callback to close the sheet
onReject();
};
return (
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
{/* Thumbs Down - Request changes (Story 2.3: triggers refinement) */}
<button
onClick={handleReject}
type="button"
className="flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
aria-label="Request changes to this draft"
>
<ThumbsDown className="w-5 h-5" aria-hidden="true" />
<span>Not Quite</span>
</button>
{/* Copy Only - Copy without completing (Story 2.4) */}
{onCopyOnly && currentDraft && (
<CopyButton
draftId={currentDraft.id}
onCopy={onCopyOnly}
variant="toolbar"
label="Just Copy"
/>
)}
{/* Thumbs Up - Approve, Copy, and Complete */}
<button
onClick={onApprove}
type="button"
className="flex-1 min-h-[44px] px-4 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-md transition-colors flex items-center justify-center gap-2"
aria-label="Approve, copy to clipboard, and mark as completed"
>
<ThumbsUp className="w-5 h-5" aria-hidden="true" />
<span>Approve</span>
</button>
</nav>
);
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DraftContent } from './DraftContent';
import type { Draft } from '@/lib/db/draft-service';
describe('DraftContent', () => {
const mockDraft: Draft = {
id: 1,
sessionId: 'session-1',
title: 'My Test Draft',
content: 'This is a test draft with **bold** and *italic* text.\n\n## Section 2\n\nSome content here.',
tags: ['testing', 'draft'],
createdAt: Date.now(),
status: 'draft',
};
it('renders draft title correctly', () => {
render(<DraftContent draft={mockDraft} />);
// Title is rendered in the h2 with class draft-title
const titleElement = document.querySelector('.draft-title');
expect(titleElement).toBeInTheDocument();
expect(titleElement?.textContent).toBe('My Test Draft');
});
it('renders draft content as Markdown', () => {
render(<DraftContent draft={mockDraft} />);
// Use a function matcher for text that might be split across elements
expect(screen.getByText((content, element) => {
return content.includes('test draft');
})).toBeInTheDocument();
expect(screen.getByText('bold')).toBeInTheDocument();
expect(screen.getByText('italic')).toBeInTheDocument();
});
it('renders headings correctly', () => {
render(<DraftContent draft={mockDraft} />);
expect(screen.getByText('Section 2')).toBeInTheDocument();
});
it('renders tags when present', () => {
render(<DraftContent draft={mockDraft} />);
expect(screen.getByText('#testing')).toBeInTheDocument();
expect(screen.getByText('#draft')).toBeInTheDocument();
});
it('does not render tags section when tags array is empty', () => {
const draftWithoutTags: Draft = {
...mockDraft,
tags: [],
};
const { container } = render(<DraftContent draft={draftWithoutTags} />);
expect(container.querySelector('.tag-chip')).not.toBeInTheDocument();
});
it('handles code blocks correctly', () => {
const draftWithCode: Draft = {
...mockDraft,
content: '```typescript\nconst x = 1;\n```',
};
render(<DraftContent draft={draftWithCode} />);
// Code blocks get syntax highlighted, so the text is split
// Just check that the pre element exists
const preElement = document.querySelector('pre');
expect(preElement).toBeInTheDocument();
expect(preElement?.textContent).toContain('const');
expect(preElement?.textContent).toContain('x =');
expect(preElement?.textContent).toContain('1');
});
it('handles lists correctly', () => {
const draftWithList: Draft = {
...mockDraft,
content: '- Item 1\n- Item 2\n- Item 3',
};
render(<DraftContent draft={draftWithList} />);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
it('handles links correctly', () => {
const draftWithLink: Draft = {
...mockDraft,
content: '[Link text](https://example.com)',
};
render(<DraftContent draft={draftWithLink} />);
const link = screen.getByText('Link text');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
});
});

View File

@@ -0,0 +1,133 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import type { Draft } from '@/lib/db/draft-service';
interface DraftContentProps {
draft: Draft;
}
/**
* DraftContent Component - Markdown renderer for draft content
*
* Renders draft content with:
* - Merriweather serif font for "published" feel
* - Markdown parsing with GFM support
* - Syntax highlighting for code blocks
* - Tag display
* - Generous whitespace for comfortable reading
*/
export function DraftContent({ draft }: DraftContentProps) {
// Strip the first heading from content if it matches the title
const processedContent = (() => {
const lines = draft.content.split('\n');
const firstLine = lines[0]?.trim() || '';
// Check if first line is a heading that matches the title
const headingMatch = firstLine.match(/^#+\s*(.+)$/);
if (headingMatch) {
const headingText = headingMatch[1].trim();
if (headingText.toLowerCase() === draft.title.toLowerCase()) {
// Remove the first line (and any immediate blank lines after it)
let startIndex = 1;
while (startIndex < lines.length && lines[startIndex].trim() === '') {
startIndex++;
}
return lines.slice(startIndex).join('\n');
}
}
return draft.content;
})();
return (
<article className="draft-content px-4 sm:px-6 py-6 bg-white">
{/* Title - using Merriweather serif font */}
<h2 className="draft-title text-2xl sm:text-3xl font-bold text-slate-800 mb-6 font-serif leading-tight">
{draft.title}
</h2>
{/* Body content - Markdown with prose styling */}
<div className="draft-body prose prose-slate max-w-none font-serif">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={{
// Custom heading styles
h1: ({ node, ...props }) => (
<h1 className="text-2xl font-bold text-slate-800 mt-8 mb-4 first:mt-0" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-xl font-bold text-slate-800 mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-lg font-semibold text-slate-800 mt-5 mb-2" {...props} />
),
// Paragraph styling
p: ({ node, ...props }) => (
<p className="text-base leading-relaxed text-slate-700 mb-4" {...props} />
),
// Code blocks
code: ({ node, inline, className, children, ...props }: any) => {
if (inline) {
return (
<code
className="px-1.5 py-0.5 bg-slate-100 text-slate-800 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<code
className={`block bg-slate-100 text-slate-800 p-4 rounded-lg text-sm font-mono overflow-x-auto ${className || ''}`}
{...props}
>
{children}
</code>
);
},
// Pre tags
pre: ({ node, ...props }) => (
<pre className="bg-slate-100 p-4 rounded-lg overflow-x-auto mb-4" {...props} />
),
// Links
a: ({ node, ...props }) => (
<a className="text-slate-600 hover:text-slate-800 underline" {...props} />
),
// Lists
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside mb-4 text-slate-700 space-y-1" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside mb-4 text-slate-700 space-y-1" {...props} />
),
// Blockquotes
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-4 border-slate-300 pl-4 italic text-slate-600 my-4" {...props} />
),
}}
>
{processedContent}
</ReactMarkdown>
</div>
{/* Tags section */}
{draft.tags && draft.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-6 pt-4 border-t border-slate-200">
{draft.tags.map((tag) => (
<span
key={tag}
className="tag-chip px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-sans"
>
#{tag}
</span>
))}
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import type { Draft } from '@/lib/db/draft-service';
// Mock clipboard API
const mockClipboard = {
writeText: vi.fn(),
};
Object.assign(navigator, {
clipboard: mockClipboard,
});
// Mock DraftService
vi.mock('@/lib/db/draft-service', () => ({
DraftService: {
updateDraftStatus: vi.fn().mockResolvedValue(true),
getDraftById: vi.fn(),
markAsCompleted: vi.fn(),
},
}));
// Mock chat store
vi.mock('@/lib/store/chat-store', () => ({
useChatStore: vi.fn(),
}));
import { DraftService } from '@/lib/db/draft-service';
import { DraftViewSheet } from './DraftViewSheet';
import { useChatStore } from '@/lib/store/chat-store';
describe('DraftViewSheet Integration', () => {
const mockDraft: Draft = {
id: 1,
sessionId: 'session-1',
title: 'Test Draft',
content: '# Test Draft\n\nThis is test content.',
tags: ['test'],
createdAt: Date.now(),
status: 'draft',
};
// Create a mock state system
let mockState = {
currentDraft: null as Draft | null,
showDraftView: false,
closeDraftView: vi.fn(),
approveDraft: vi.fn(),
completeDraft: vi.fn(),
copyDraftToClipboard: vi.fn(),
rejectDraft: vi.fn(),
startRefinement: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
vi.clearAllMocks();
mockState = {
currentDraft: null,
showDraftView: false,
closeDraftView: vi.fn(),
approveDraft: vi.fn(),
completeDraft: vi.fn().mockResolvedValue(undefined),
copyDraftToClipboard: vi.fn().mockResolvedValue(undefined),
rejectDraft: vi.fn(),
startRefinement: vi.fn().mockResolvedValue(undefined),
};
// Setup mock store function
(useChatStore as any).mockImplementation((selector?: Function) => {
return selector ? selector(mockState) : mockState;
});
});
it('does not render when no draft is available', () => {
render(<DraftViewSheet />);
expect(screen.queryByTestId('sheet-backdrop')).not.toBeInTheDocument();
});
it('opens sheet when draft is available and showDraftView is true', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
expect(screen.getByTestId('sheet-backdrop')).toBeInTheDocument();
expect(screen.getByTestId('sheet-content')).toBeInTheDocument();
// Check by class selector since title appears twice (h2 title and h1 markdown)
expect(document.querySelector('.draft-title')?.textContent).toBe('Test Draft');
});
it('renders draft content with Markdown', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
expect(screen.getByText('This is test content.')).toBeInTheDocument();
});
it('renders tags', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
expect(screen.getByText('#test')).toBeInTheDocument();
});
it('calls completeDraft when Approve button is clicked', async () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
mockState.completeDraft.mockResolvedValue(undefined);
(DraftService.getDraftById as any).mockResolvedValue(mockDraft);
(DraftService.markAsCompleted as any).mockResolvedValue(mockDraft);
render(<DraftViewSheet />);
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
fireEvent.click(approveButton);
await waitFor(() => {
expect(mockState.completeDraft).toHaveBeenCalledWith(1);
});
});
it('calls startRefinement when Thumbs Down is clicked', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
const rejectButton = screen.getByRole('button', { name: /request changes/i });
fireEvent.click(rejectButton);
// Story 2.3: Thumbs Down now triggers refinement flow
expect(mockState.startRefinement).toHaveBeenCalledWith(1);
expect(mockState.rejectDraft).toHaveBeenCalledWith(1);
});
it('calls closeDraftView when backdrop is clicked', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
const backdrop = screen.getByTestId('sheet-backdrop');
fireEvent.click(backdrop);
expect(mockState.closeDraftView).toHaveBeenCalledTimes(1);
});
it('disables chat input when draft view is open', () => {
// This test verifies the integration with ChatWindow
// The ChatWindow should disable input when showDraftView is true
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
// Verify sheet is open
expect(screen.getByTestId('sheet-content')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i })).toBeInTheDocument();
});
it('handles empty tags array gracefully', () => {
const draftWithoutTags: Draft = {
...mockDraft,
tags: [],
};
mockState.currentDraft = draftWithoutTags;
mockState.showDraftView = true;
render(<DraftViewSheet />);
expect(screen.queryByText('#test')).not.toBeInTheDocument();
});
describe('Story 2.4: Copy Only Button and Toast', () => {
it('renders Copy Only button when onCopyOnly is provided', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
expect(screen.getByRole('button', { name: /copy to clipboard without closing/i })).toBeInTheDocument();
});
it('calls copyDraftToClipboard when Copy Only button is clicked', async () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
mockState.copyDraftToClipboard.mockResolvedValue(undefined);
render(<DraftViewSheet />);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
fireEvent.click(copyOnlyButton);
await waitFor(() => {
expect(mockState.copyDraftToClipboard).toHaveBeenCalledWith(1);
});
});
it('shows toast with correct message when Approve is clicked', async () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
mockState.completeDraft.mockResolvedValue(undefined);
render(<DraftViewSheet />);
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
fireEvent.click(approveButton);
await waitFor(() => {
expect(mockState.completeDraft).toHaveBeenCalledWith(1);
});
// Toast is rendered in a separate fragment, check for live region
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
it('shows toast with correct message when Copy Only is clicked', async () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
mockState.copyDraftToClipboard.mockResolvedValue(undefined);
render(<DraftViewSheet />);
const copyOnlyButton = screen.getByRole('button', { name: /copy to clipboard without closing/i });
fireEvent.click(copyOnlyButton);
await waitFor(() => {
expect(mockState.copyDraftToClipboard).toHaveBeenCalledWith(1);
});
// Toast is rendered in a separate fragment, check for live region
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
it('renders all action buttons including delete button (Story 3.2)', () => {
mockState.currentDraft = mockDraft;
mockState.showDraftView = true;
render(<DraftViewSheet />);
// Story 3.2: Now has 4 buttons: Delete, Not Quite, Just Copy, Approve
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(4);
});
});
});

View File

@@ -0,0 +1,140 @@
'use client';
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useChatStore } from '@/lib/store/chat-store';
import { Sheet } from './Sheet';
import { DraftContent } from './DraftContent';
import { DraftActions } from './DraftActions';
import { CopySuccessToast } from '@/components/features/feedback/CopySuccessToast';
import { DeleteConfirmDialog } from '../journal/DeleteConfirmDialog';
/**
* DraftViewSheet - Main draft view component
*
* Story 2.4: Updated to use completeDraft action with toast feedback.
* Story 3.2: Added delete functionality with confirmation dialog.
*
* Combines Sheet, DraftContent, and DraftActions to display
* the Ghostwriter's draft in a polished, reading-focused interface.
*
* Auto-opens when currentDraft transitions from null to populated.
* Integrates with ChatStore for approval/rejection actions.
*/
export function DraftViewSheet() {
const currentDraft = useChatStore((s) => s.currentDraft);
const showDraftView = useChatStore((s) => s.showDraftView);
const closeDraftView = useChatStore((s) => s.closeDraftView);
const completeDraft = useChatStore((s) => s.completeDraft);
const copyDraftToClipboard = useChatStore((s) => s.copyDraftToClipboard);
const rejectDraft = useChatStore((s) => s.rejectDraft);
// Story 3.2: Use store action for architecture compliance
const deleteDraft = useChatStore((s) => s.deleteDraft);
// Story 3.2: Delete dialog state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Toast state for copy feedback
const [toastShow, setToastShow] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const showCopyToast = (message: string = 'Copied to clipboard!') => {
setToastMessage(message);
setToastShow(true);
};
const handleClose = () => {
closeDraftView();
};
const handleApprove = async () => {
if (currentDraft) {
// Story 2.4: Use completeDraft which copies + marks completed
await completeDraft(currentDraft.id);
showCopyToast('Copied and saved to history!');
}
};
const handleCopyOnly = async () => {
if (currentDraft) {
// Story 2.4: Copy without closing sheet or marking as completed
await copyDraftToClipboard(currentDraft.id);
showCopyToast('Copied to clipboard!');
}
};
const handleReject = () => {
if (currentDraft) {
rejectDraft(currentDraft.id);
// Add system message to chat: "What should we change?"
// This will be handled by the ChatService in story 2.3
}
};
// Story 3.2: Delete handler
const handleDelete = async () => {
if (currentDraft) {
// Use store action
const success = await deleteDraft(currentDraft.id);
if (success) {
// Close the dialog (Sheet closed by store action if current draft verified)
setShowDeleteDialog(false);
showCopyToast('Post deleted successfully!');
} else {
// Handle error - close dialog but keep sheet open
setShowDeleteDialog(false);
showCopyToast('Failed to delete post');
}
}
};
if (!currentDraft) {
return null;
}
return (
<>
<Sheet open={showDraftView} onClose={handleClose}>
<DraftContent draft={currentDraft} />
{/* Story 3.2: Extended footer with delete button */}
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
{/* Delete button (Story 3.2) */}
<button
onClick={() => setShowDeleteDialog(true)}
type="button"
className="min-h-[44px] px-4 py-3 border border-destructive text-destructive rounded-md hover:bg-destructive/10 transition-colors flex items-center justify-center gap-2"
aria-label="Delete this draft"
>
<Trash2 className="w-5 h-5" aria-hidden="true" />
<span>Delete</span>
</button>
{/* Draft actions from original component */}
<DraftActions
onApprove={handleApprove}
onReject={handleReject}
onCopyOnly={handleCopyOnly}
/>
</nav>
</Sheet>
{/* Story 3.2: Delete confirmation dialog */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
draftTitle={currentDraft.title}
/>
{/* Toast for copy feedback (Story 2.4) */}
<CopySuccessToast
show={toastShow}
message={toastMessage}
onClose={() => setToastShow(false)}
/>
</>
);
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Sheet } from './Sheet';
describe('Sheet', () => {
it('renders in closed state by default', () => {
const { container } = render(
<Sheet open={false} onClose={vi.fn()}>
<div>Sheet Content</div>
</Sheet>
);
// Sheet should not be visible when closed
expect(container.querySelector('[data-testid="sheet-content"]')).not.toBeInTheDocument();
});
it('opens when open prop is true', () => {
render(
<Sheet open={true} onClose={vi.fn()}>
<div>Sheet Content</div>
</Sheet>
);
// Sheet should be visible when open
expect(screen.getByTestId('sheet-content')).toBeInTheDocument();
expect(screen.getByText('Sheet Content')).toBeInTheDocument();
});
it('calls onClose when backdrop is clicked', () => {
const handleClose = vi.fn();
const { container } = render(
<Sheet open={true} onClose={handleClose}>
<div data-testid="sheet-content">Sheet Content</div>
</Sheet>
);
// Click backdrop
const backdrop = container.querySelector('[data-testid="sheet-backdrop"]');
if (backdrop) {
fireEvent.click(backdrop);
expect(handleClose).toHaveBeenCalledTimes(1);
}
});
it('calls onClose when Escape key is pressed', () => {
const handleClose = vi.fn();
render(
<Sheet open={true} onClose={handleClose}>
<div data-testid="sheet-content">Sheet Content</div>
</Sheet>
);
// Press Escape key
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
expect(handleClose).toHaveBeenCalledTimes(1);
});
it('applies responsive classes', () => {
render(
<Sheet open={true} onClose={vi.fn()}>
<div>Sheet Content</div>
</Sheet>
);
const sheetContent = screen.getByTestId('sheet-content');
expect(sheetContent).toHaveClass('sm:max-w-[600px]', 'h-[85vh]');
});
it('renders children correctly', () => {
render(
<Sheet open={true} onClose={vi.fn()}>
<div data-testid="test-child">Test Child Content</div>
</Sheet>
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
expect(screen.getByText('Test Child Content')).toBeInTheDocument();
});
it('does not close when clicking inside sheet content', () => {
const handleClose = vi.fn();
render(
<Sheet open={true} onClose={handleClose}>
<div data-testid="test-child">Sheet Content</div>
</Sheet>
);
// Click inside sheet content
const sheetContent = screen.getByTestId('sheet-content');
fireEvent.click(sheetContent);
expect(handleClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useRef } from 'react';
interface SheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
/**
* Sheet Component - Slide-up modal component
*
* A mobile-first slide-up sheet that displays content from the bottom.
* On mobile, it takes full screen. On desktop (sm+), it shows as a centered card.
*
* Features:
* - Backdrop dim overlay with tap-to-close
* - Keyboard navigation (Escape to close)
* - Responsive: full-screen on mobile, centered card on desktop
* - Prevents body scroll when open
* - Focus trap inside sheet when open
*/
export function Sheet({ open, onClose, children }: SheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Handle Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open, onClose]);
// Prevent body scroll when sheet is open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
// Store current focused element to restore later
previousActiveElement.current = document.activeElement as HTMLElement;
// Focus the sheet when it opens
setTimeout(() => {
sheetRef.current?.focus();
}, 100);
} else {
document.body.style.overflow = '';
// Restore focus when closed
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!open) {
return null;
}
return (
<div
data-testid="sheet-backdrop"
className="fixed inset-0 z-50 bg-black/50 animate-in fade-in duration-300"
onClick={handleBackdropClick}
>
<div
ref={sheetRef}
tabIndex={-1}
data-testid="sheet-content"
className="fixed inset-x-0 bottom-0 z-50 bg-background shadow-lg animate-in slide-in-from-bottom-10 duration-300 sm:inset-y-auto sm:top-1/2 sm:-translate-y-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:max-w-[600px] sm:w-full sm:max-h-[85vh] sm:rounded-lg sm:border h-[85vh] sm:h-auto sm:max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} // Prevent click from propagating to backdrop
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { Sheet } from './Sheet';
export { DraftContent } from './DraftContent';
export { DraftActions } from './DraftActions';
export { DraftViewSheet } from './DraftViewSheet';

View File

@@ -0,0 +1,326 @@
/**
* CopySuccessToast Component Tests
*
* Tests for CopySuccessToast component covering:
* - Rendering with default props
* - Custom message display
* - Auto-dismiss functionality
* - Close button functionality
* - Accessibility (ARIA attributes)
* - Haptic feedback on mobile
*/
import { render, screen, waitFor, fireEvent, getByRole } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { CopySuccessToast, useCopySuccessToast } from './CopySuccessToast';
// Mock navigator.vibrate
Object.assign(navigator, {
vibrate: vi.fn(),
});
describe('CopySuccessToast', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Rendering', () => {
it('should render with default message', () => {
const { rerender } = render(
<CopySuccessToast show={true} onClose={() => {}} />
);
// Use the toast-message id to be specific
const messageElement = document.getElementById('toast-message');
expect(messageElement).toHaveTextContent('Copied to clipboard!');
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('should render with custom message', () => {
render(
<CopySuccessToast
show={true}
message="Draft saved to history!"
onClose={() => {}}
/>
);
// Use id to be more specific
const messageElement = document.getElementById('toast-message');
expect(messageElement).toHaveTextContent('Draft saved to history!');
});
it('should not render when show is false', () => {
render(
<CopySuccessToast show={false} onClose={() => {}} />
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('should render with dismiss button', () => {
render(
<CopySuccessToast show={true} onClose={() => {}} />
);
const dismissButton = screen.getByLabelText('Dismiss notification');
expect(dismissButton).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
render(
<CopySuccessToast
show={true}
message="Test message"
onClose={() => {}}
/>
);
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-describedby', 'toast-message');
const message = document.getElementById('toast-message');
expect(message).toHaveTextContent('Test message');
expect(message).toHaveAttribute('id', 'toast-message');
});
it('should announce to screen readers via live region', () => {
render(
<CopySuccessToast
show={true}
message="Copied!"
onClose={() => {}}
/>
);
const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
expect(liveRegion).toHaveTextContent('Copied!');
});
it('should clear live region message when hidden', async () => {
const { rerender } = render(
<CopySuccessToast
show={true}
message="Visible message"
onClose={() => {}}
/>
);
const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveTextContent('Visible message');
rerender(
<CopySuccessToast
show={false}
message="Visible message"
onClose={() => {}}
/>
);
await waitFor(() => {
expect(liveRegion).toHaveTextContent('');
});
});
});
describe('Auto-dismiss', () => {
it('should auto-dismiss after default duration (3000ms)', async () => {
vi.useFakeTimers();
const onClose = vi.fn();
render(
<CopySuccessToast show={true} onClose={onClose} />
);
// Initially visible
expect(screen.getByRole('alert')).toBeInTheDocument();
// Fast-forward past 3000ms
vi.advanceTimersByTime(3500);
// Run all pending timers
await vi.runAllTimersAsync();
expect(onClose).toHaveBeenCalled();
vi.useRealTimers();
});
it('should auto-dismiss after custom duration', async () => {
vi.useFakeTimers();
const onClose = vi.fn();
render(
<CopySuccessToast
show={true}
onClose={onClose}
duration={1000}
/>
);
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onClose).toHaveBeenCalled();
vi.useRealTimers();
});
});
describe('Close button', () => {
it('should call onClose when dismiss button is clicked', async () => {
vi.useFakeTimers();
const onClose = vi.fn();
render(
<CopySuccessToast show={true} onClose={onClose} />
);
const dismissButton = screen.getByLabelText('Dismiss notification');
fireEvent.click(dismissButton);
// Advance time for exit animation (300ms)
vi.advanceTimersByTime(350);
await vi.runAllTimersAsync();
expect(onClose).toHaveBeenCalled();
vi.useRealTimers();
});
it('should hide toast when dismiss button is clicked', async () => {
const onClose = vi.fn();
render(
<CopySuccessToast show={true} onClose={onClose} />
);
const dismissButton = screen.getByLabelText('Dismiss notification');
// Initially visible
expect(screen.getByRole('alert')).toBeInTheDocument();
fireEvent.click(dismissButton);
// The toast should fade out
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveClass(/opacity-0/);
});
});
});
describe('Haptic feedback', () => {
it('should trigger vibration on mobile when shown', () => {
render(
<CopySuccessToast show={true} onClose={() => {}} />
);
expect(navigator.vibrate).toHaveBeenCalledWith(50);
});
it('should not trigger vibration when hidden', () => {
render(
<CopySuccessToast show={false} onClose={() => {}} />
);
expect(navigator.vibrate).not.toHaveBeenCalled();
});
it('should not trigger vibration when navigator.vibrate is unavailable', () => {
// @ts-expect-error - Testing missing vibrate API
const originalVibrate = navigator.vibrate;
delete navigator.vibrate;
render(
<CopySuccessToast show={true} onClose={() => {}} />
);
// Should not throw error
expect(screen.getByRole('alert')).toBeInTheDocument();
navigator.vibrate = originalVibrate;
});
});
describe('useCopySuccessToast hook', () => {
it('should provide showToast and hideToast functions', async () => {
const TestComponent = () => {
const { showToast, hideToast, show } = useCopySuccessToast();
return (
<div>
<span data-testid="show-state">{show ? 'visible' : 'hidden'}</span>
<button onClick={() => showToast()}>Show</button>
<button onClick={() => hideToast()}>Hide</button>
<CopySuccessToast show={show} onClose={hideToast} />
</div>
);
};
render(<TestComponent />);
expect(screen.getByTestId('show-state')).toHaveTextContent('hidden');
const showButton = screen.getByText('Show');
await fireEvent.click(showButton);
await waitFor(() => {
expect(screen.getByTestId('show-state')).toHaveTextContent('visible');
});
});
it('should allow custom message in showToast', async () => {
const TestComponent = () => {
const { showToast, show, message: hookMessage } = useCopySuccessToast();
return (
<div>
<button onClick={() => showToast('Custom message!')}>
Show Custom
</button>
<CopySuccessToast show={show} message={hookMessage} onClose={() => {}} />
</div>
);
};
render(<TestComponent />);
const button = screen.getByText('Show Custom');
fireEvent.click(button);
// Use id to be more specific
const messageElement = document.getElementById('toast-message');
await waitFor(() => {
expect(messageElement).toHaveTextContent('Custom message!');
}, { timeout: 3000 });
});
});
describe('Confetti animation', () => {
it('should render confetti pieces when shown', () => {
render(
<CopySuccessToast show={true} onClose={() => {}} />
);
// Confetti container should be present
const confettiContainer = document.querySelector('[aria-hidden="true"]');
expect(confettiContainer).toBeInTheDocument();
});
it('should not render confetti when hidden', () => {
render(
<CopySuccessToast show={false} onClose={() => {}} />
);
const confettiContainer = document.querySelector('[aria-hidden="true"]');
expect(confettiContainer).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,196 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { useChatStore } from '@/lib/store/chat-store';
interface CopySuccessToastProps {
message?: string;
duration?: number;
show: boolean;
onClose: () => void;
}
/**
* CopySuccessToast Component
*
* Success feedback toast for clipboard copy operations.
* - Fixed position at bottom-center for mobile visibility
* - Auto-dismiss after configurable duration (default 3 seconds)
* - Confetti animation effect on mount
* - Screen reader announcement for accessibility
* - Haptic feedback on mobile devices
*
* @param message - Success message to display (default: "Copied to clipboard!")
* @param duration - Auto-dismiss duration in ms (default: 3000)
* @param show - Whether to show the toast
* @param onClose - Callback when toast should close
*/
export function CopySuccessToast({
message = 'Copied to clipboard!',
duration = 3000,
show,
onClose,
}: CopySuccessToastProps) {
const [isVisible, setIsVisible] = useState(show);
const [confettiPieces, setConfettiPieces] = useState<Array<{
id: number;
x: number;
y: number;
color: string;
rotation: number;
velocityX: number;
velocityY: number;
}>>([]);
// Trigger haptic feedback on mobile when toast appears
useEffect(() => {
if (show && 'vibrate' in navigator) {
navigator.vibrate(50); // Short haptic burst
}
}, [show]);
// Generate confetti pieces on mount
useEffect(() => {
if (show) {
setIsVisible(true);
const colors = ['#64748B', '#94A3B8', '#CBD5E1', '#E2E8F0']; // Slate colors for "Morning Mist" theme
const pieces = Array.from({ length: 20 }, (_, i) => ({
id: i,
x: 50, // Center horizontally (percent)
y: 50, // Center vertically (percent)
color: colors[Math.floor(Math.random() * colors.length)],
rotation: Math.random() * 360,
velocityX: (Math.random() - 0.5) * 20,
velocityY: (Math.random() - 1) * 20 - 5, // Upward bias
}));
setConfettiPieces(pieces);
} else {
setIsVisible(false);
}
}, [show]);
// Auto-dismiss timer
useEffect(() => {
if (!show) return;
const timer = setTimeout(() => {
setIsVisible(false);
// Allow exit animation to complete before calling onClose
setTimeout(() => onClose(), 300);
}, duration);
return () => clearTimeout(timer);
}, [show, duration, onClose]);
// Animate confetti
useEffect(() => {
if (!isVisible || confettiPieces.length === 0) return;
const animationFrame = requestAnimationFrame(() => {
setConfettiPieces(prev =>
prev.map(piece => ({
...piece,
x: piece.x + piece.velocityX * 0.1,
y: piece.y + piece.velocityY * 0.1,
velocityY: piece.velocityY + 0.5, // Gravity
rotation: piece.rotation + 5,
}))
);
});
return () => cancelAnimationFrame(animationFrame);
}, [isVisible, confettiPieces]);
if (!show && !isVisible) return null;
return (
<>
{/* Live region for screen readers */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{show ? message : ''}
</div>
{/* Confetti overlay */}
{show && confettiPieces.length > 0 && (
<div className="fixed inset-0 pointer-events-none z-50" aria-hidden="true">
{confettiPieces.map(piece => (
<div
key={piece.id}
className="absolute w-2 h-2"
style={{
left: `${piece.x}%`,
top: `${piece.y}%`,
backgroundColor: piece.color,
transform: `rotate(${piece.rotation}deg)`,
opacity: 0.8,
}}
/>
))}
</div>
)}
{/* Toast */}
<div
className={`
fixed bottom-4 left-1/2 -translate-x-1/2 z-50
px-4 py-3 bg-slate-800 text-white rounded-lg shadow-lg
flex items-center gap-3
transition-all duration-300 ease-out
${show && isVisible
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-4 scale-95'
}
`}
role="alert"
aria-describedby="toast-message"
>
<Check className="w-5 h-5 text-green-400" aria-hidden="true" />
<span id="toast-message" className="font-medium text-sm">
{message}
</span>
<button
onClick={() => {
setIsVisible(false);
setTimeout(() => onClose(), 300);
}}
className="ml-2 p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Dismiss notification"
>
<Copy className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</>
);
}
/**
* Hook to manage toast state
* Provides a simple interface for showing/hiding the success toast
*/
export function useCopySuccessToast() {
const [show, setShow] = useState(false);
const [message, setMessage] = useState('Copied to clipboard!');
const showToast = (customMessage?: string) => {
setMessage(customMessage || 'Copied to clipboard!');
setShow(true);
};
const hideToast = () => {
setShow(false);
};
return {
show,
message,
showToast,
hideToast,
};
}

View File

@@ -0,0 +1,7 @@
/**
* Feedback Feature Components
*
* Export all feedback-related components
*/
export { CopySuccessToast, useCopySuccessToast } from './CopySuccessToast';

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
describe('DeleteConfirmDialog', () => {
const mockOnConfirm = vi.fn();
const mockOnOpenChange = vi.fn();
const defaultProps = {
open: true,
onOpenChange: mockOnOpenChange,
onConfirm: mockOnConfirm,
draftTitle: 'Test Draft Title',
};
beforeEach(() => {
mockOnConfirm.mockClear();
mockOnOpenChange.mockClear();
});
it('should render dialog when open', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
expect(screen.getByText('Delete this post?')).toBeInTheDocument();
expect(screen.getByText(/Test Draft Title/)).toBeInTheDocument();
// "This action cannot be undone" is split across lines
expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument();
});
it('should not render when closed', () => {
render(<DeleteConfirmDialog {...defaultProps} open={false} />);
expect(screen.queryByText('Delete this post?')).not.toBeInTheDocument();
});
it('should call onConfirm when Delete button is clicked', async () => {
const user = userEvent.setup();
render(<DeleteConfirmDialog {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: 'Delete' });
await user.click(deleteButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onOpenChange when Cancel button is clicked', async () => {
const user = userEvent.setup();
render(<DeleteConfirmDialog {...defaultProps} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it('should have accessible labels', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
// Check for descriptive text
expect(screen.getByText(/Delete this post\?/)).toBeInTheDocument();
expect(screen.getByText(/You are about to delete/)).toBeInTheDocument();
expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument();
});
it('should have proper button styling for destructive action', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: 'Delete' });
expect(deleteButton).toHaveClass('bg-destructive');
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toHaveClass('border');
});
});

View File

@@ -0,0 +1,65 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
/**
* DeleteConfirmDialog - Confirmation dialog for destructive actions
*
* Story 3.2: Delete confirmation dialog for drafts/posts
*
* Uses ShadCN AlertDialog for critical interruptions like deletion.
* - Clear warning text: "This cannot be undone"
* - Two buttons: Cancel (secondary), Delete (destructive/red)
* - Accessible: proper aria labels, focus management, keyboard navigation
*/
interface DeleteConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
draftTitle: string;
}
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
draftTitle
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-destructive">
Delete this post?
</AlertDialogTitle>
<AlertDialogDescription>
You are about to delete <strong>"{draftTitle}"</strong>.
</AlertDialogDescription>
<AlertDialogDescription className="text-sm text-muted-foreground mt-2">
This action cannot be undone. The post will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { BookOpen, Sparkles } from 'lucide-react';
interface EmptyHistoryStateProps {
onStartVent?: () => void;
}
/**
* EmptyHistoryState Component
*
* Story 3.1: Encouraging empty state for new users
*
* Shown when user has no completed drafts yet.
* Displays:
* - Encouraging message
* - Calming illustration (using icons)
* - CTA to start first vent
*/
export function EmptyHistoryState({ onStartVent }: EmptyHistoryStateProps) {
return (
<div className="empty-history-state flex flex-col items-center justify-center py-16 px-6 text-center">
{/* Illustration */}
<div className="mb-6 relative">
<div className="w-32 h-32 bg-gradient-to-br from-slate-100 to-slate-200 rounded-full flex items-center justify-center">
<BookOpen className="w-16 h-16 text-slate-400" aria-hidden="true" />
</div>
<Sparkles className="w-8 h-8 text-amber-400 absolute -top-2 -right-2" aria-hidden="true" />
</div>
{/* Message */}
<h2 className="text-2xl font-bold text-slate-800 mb-2 font-serif">
Your journey starts here
</h2>
<p className="text-slate-600 mb-6 max-w-md font-sans">
Every venting session becomes a learning moment. Start your first session to see your growth unfold.
</p>
{/* CTA Button */}
<button
onClick={onStartVent}
type="button"
className="min-h-[44px] px-6 py-3 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors flex items-center gap-2 font-sans"
>
<Sparkles className="w-5 h-5" aria-hidden="true" />
<span>Start My First Vent</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
/**
* HistoryCard Component Tests
* Story 3.1: Testing history card display
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HistoryCard } from './HistoryCard';
import type { Draft } from '@/lib/db/draft-service';
// Mock date utility
vi.mock('@/lib/utils/date', () => ({
formatRelativeDate: vi.fn((timestamp: number) => 'Today')
}));
const mockDraft: Draft = {
id: 1,
sessionId: 'test-session',
title: 'Test Draft Title',
content: 'This is a test draft content that is longer than 100 characters to test the preview functionality.',
tags: ['react', 'testing'],
createdAt: Date.now(),
completedAt: Date.now(),
status: 'completed'
};
describe('HistoryCard', () => {
it('renders draft title', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('Test Draft Title')).toBeInTheDocument();
});
it('renders relative date', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('Today')).toBeInTheDocument();
});
it('renders tags', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('#react')).toBeInTheDocument();
expect(screen.getByText('#testing')).toBeInTheDocument();
});
it('renders content preview truncated to 100 chars', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const previewText = screen.getByText(/This is a test draft/);
expect(previewText).toBeInTheDocument();
// Note: If content is exactly 100 chars, no "..." is added
const contentLength = mockDraft.content.length;
if (contentLength > 100) {
expect(previewText.textContent).toContain('...');
}
});
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const button = screen.getByRole('button', { name: /View post: Test Draft Title/ });
button.click();
expect(onClick).toHaveBeenCalledWith(mockDraft);
});
it('handles draft without tags', () => {
const onClick = vi.fn();
const draftWithoutTags: Draft = {
...mockDraft,
tags: []
};
render(<HistoryCard draft={draftWithoutTags} onClick={onClick} />);
expect(screen.queryByText('#react')).not.toBeInTheDocument();
expect(screen.queryByText('#testing')).not.toBeInTheDocument();
});
it('handles short content (no truncation)', () => {
const onClick = vi.fn();
const shortDraft: Draft = {
...mockDraft,
content: 'Short content'
};
render(<HistoryCard draft={shortDraft} onClick={onClick} />);
const previewText = screen.getByText(/Short content/);
expect(previewText.textContent).not.toContain('...');
});
it('has correct accessibility attributes', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const button = screen.getByRole('button', { name: /View post: Test Draft Title/ });
expect(button).toHaveAttribute('type', 'button');
});
it('handles draft without completedAt (uses createdAt)', () => {
const onClick = vi.fn();
const draftWithoutCompletedAt: Draft = {
...mockDraft,
completedAt: undefined
};
render(<HistoryCard draft={draftWithoutCompletedAt} onClick={onClick} />);
// Should still render with createdAt
expect(screen.getByText('Today')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
'use client';
import { type Draft } from '@/lib/db/draft-service';
import { formatRelativeDate } from '@/lib/utils/date';
interface HistoryCardProps {
draft: Draft;
onClick: (draft: Draft) => void;
}
/**
* HistoryCard Component
*
* Story 3.1: Individual history item for the feed
*
* Displays a completed draft with:
* - Title (Merriweather serif font)
* - Relative date ("Today", "Yesterday", etc.)
* - Tags (if present)
* - Content preview (first 100 chars)
* - Click to view full detail
*
* Accessibility:
* - Semantic button with aria-label
* - 44px minimum touch target
*/
export function HistoryCard({ draft, onClick }: HistoryCardProps) {
// Generate preview text (first 100 chars)
const preview = draft.content.slice(0, 100);
// Calculate the display date (use completedAt if available, otherwise createdAt)
const displayDate = draft.completedAt || draft.createdAt;
return (
<button
onClick={() => onClick(draft)}
type="button"
className="history-card group w-full text-left p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-slate-200"
aria-label={`View post: ${draft.title}`}
>
{/* Title - Merriweather serif font for "published" feel */}
<h3 className="history-title text-lg font-bold text-slate-800 mb-2 font-serif leading-tight line-clamp-2">
{draft.title}
</h3>
{/* Date - Inter font, subtle gray, relative format */}
<p className="history-date text-sm text-slate-500 mb-2 font-sans">
{formatRelativeDate(displayDate)}
</p>
{/* Tags - pill badges */}
{draft.tags && draft.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{draft.tags.map((tag) => (
<span
key={tag}
className="tag-chip px-2 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-sans"
>
#{tag}
</span>
))}
</div>
)}
{/* Preview - light gray text */}
<p className="history-preview text-sm text-slate-400 font-sans line-clamp-2">
{preview}
{draft.content.length > 100 && '...'}
</p>
</button>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { Copy, Check, X } from 'lucide-react';
import { useHistoryStore } from '@/lib/store/history-store';
import { DraftContent } from '@/components/features/draft/DraftContent';
import { CopySuccessToast } from '@/components/features/feedback/CopySuccessToast';
import { useChatStore } from '@/lib/store/chat-store';
import { Sheet } from '@/components/features/draft/Sheet';
/**
* HistoryDetailSheet Component
*
* Story 3.1: Full draft view from history feed
*
* Reuses:
* - Sheet component from DraftViewSheet (Story 2.2)
* - DraftContent component (Story 2.2)
* - CopyButton functionality (Story 2.4)
*
* Features:
* - Displays full draft with Merriweather font
* - Copy button for clipboard export
* - Close button
* - Swipe-to-dismiss support (via Sheet)
*
* Architecture Compliance:
* - Uses atomic selectors from HistoryStore
* - Reuses ChatStore's copyDraftToClipboard action
*/
export function HistoryDetailSheet() {
const selectedDraft = useHistoryStore((s) => s.selectedDraft);
const closeDetail = useHistoryStore((s) => s.closeDetail);
// Reuse copy action from ChatStore
const copyDraftToClipboard = useChatStore((s) => s.copyDraftToClipboard);
// Toast state
const [toastShow, setToastShow] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const showCopyToast = (message: string = 'Copied to clipboard!') => {
setToastMessage(message);
setToastShow(true);
};
const handleCopy = async () => {
if (selectedDraft) {
await copyDraftToClipboard(selectedDraft.id);
showCopyToast();
}
};
const handleClose = () => {
closeDetail();
};
if (!selectedDraft) {
return null;
}
return (
<>
<Sheet open={!!selectedDraft} onClose={handleClose}>
<DraftContent draft={selectedDraft} />
{/* Footer with copy and close buttons */}
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
{/* Copy button */}
<button
onClick={handleCopy}
type="button"
className="flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
aria-label="Copy to clipboard"
>
<Copy className="w-5 h-5" aria-hidden="true" />
<span>Copy</span>
</button>
{/* Close button */}
<button
onClick={handleClose}
type="button"
className="min-h-[44px] px-4 py-3 bg-slate-800 text-white rounded-md hover:bg-slate-700 transition-colors flex items-center justify-center gap-2"
aria-label="Close"
>
<X className="w-5 h-5" aria-hidden="true" />
<span>Close</span>
</button>
</nav>
</Sheet>
{/* Toast for copy feedback */}
<CopySuccessToast
show={toastShow}
message={toastMessage}
onClose={() => setToastShow(false)}
/>
</>
);
}

View File

@@ -0,0 +1,316 @@
/**
* HistoryFeed Component Tests
* Story 3.1: Testing history feed with lazy loading
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HistoryFeed } from './HistoryFeed';
import { useHistoryStore } from '@/lib/store/history-store';
import { DraftService } from '@/lib/db/draft-service';
// Mock HistoryStore
vi.mock('@/lib/store/history-store', () => ({
useHistoryStore: vi.fn()
}));
// Mock DraftService
vi.mock('@/lib/db/draft-service', () => ({
DraftService: {
getCompletedDrafts: vi.fn()
}
}));
const mockDraft = {
id: 1,
sessionId: 'test-session',
title: 'Test Draft',
content: 'Test content',
tags: ['test'],
createdAt: Date.now(),
completedAt: Date.now(),
status: 'completed' as const
};
describe('HistoryFeed', () => {
const mockLoadMore = vi.fn();
const mockClearError = vi.fn();
const mockRefreshHistory = vi.fn();
const mockSelectDraft = vi.fn();
const mockCloseDetail = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock store state
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
});
describe('initial loading', () => {
it('shows loading spinner on initial load', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: true,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText(/Loading more entries/i)).toBeInTheDocument();
});
it('calls loadMore on mount', () => {
render(<HistoryFeed />);
expect(mockLoadMore).toHaveBeenCalled();
});
});
describe('empty state', () => {
it('shows empty state when no drafts', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
await waitFor(() => {
expect(screen.getByText('Your journey starts here')).toBeInTheDocument();
});
});
it('shows CTA button in empty state', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
await waitFor(() => {
expect(screen.getByText('Start My First Vent')).toBeInTheDocument();
});
});
});
describe('rendering drafts', () => {
it('renders draft cards', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText('Test Draft')).toBeInTheDocument();
});
it('shows loading indicator when loading more', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: true,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText(/Loading more entries/i)).toBeInTheDocument();
});
});
describe('error state', () => {
it('shows error message and retry button', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: 'Failed to load history',
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText('Failed to load history')).toBeInTheDocument();
expect(screen.getByText('Retry')).toBeInTheDocument();
});
it('clears error and retries when retry clicked', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: 'Failed to load history',
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
const user = userEvent.setup();
render(<HistoryFeed />);
const retryButton = screen.getByText('Retry');
await user.click(retryButton);
expect(mockClearError).toHaveBeenCalled();
expect(mockLoadMore).toHaveBeenCalled();
});
});
describe('end of list', () => {
it('shows end message when no more drafts', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: false,
hasMore: false,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText("You've reached the beginning")).toBeInTheDocument();
});
});
describe('week-based grouping', () => {
it('groups drafts by week with headers', () => {
const now = new Date('2026-01-25T10:00:00');
const lastWeek = new Date('2026-01-18T10:00:00');
const draftsFromDifferentWeeks = [
{ ...mockDraft, id: 1, title: 'Draft 1', createdAt: now.getTime() },
{ ...mockDraft, id: 2, title: 'Draft 2', createdAt: now.getTime() },
{ ...mockDraft, id: 3, title: 'Draft 3', createdAt: lastWeek.getTime() },
];
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: draftsFromDifferentWeeks,
loading: false,
hasMore: false,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
// Should show week headers (W4 - 2026 and W3 - 2026)
expect(screen.getByText(/W4 - 2026/)).toBeInTheDocument();
expect(screen.getByText(/W3 - 2026/)).toBeInTheDocument();
// Should show all drafts
expect(screen.getByText('Draft 1')).toBeInTheDocument();
expect(screen.getByText('Draft 2')).toBeInTheDocument();
expect(screen.getByText('Draft 3')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
import { Loader2 } from 'lucide-react';
import { useHistoryStore } from '@/lib/store/history-store';
import { HistoryCard } from './HistoryCard';
import { EmptyHistoryState } from './EmptyHistoryState';
import type { Draft } from '@/lib/db/draft-service';
/**
* Get ISO week number and year for a given date
* Returns format: "W{week} - {year}"
*/
function getWeekLabel(date: Date): string {
const d = new Date(date);
// Set to nearest Thursday (current date + 4 - current day number)
// Make Sunday's day number 7
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
// Get first day of year
const yearStart = new Date(d.getFullYear(), 0, 1);
// Calculate full weeks to nearest Thursday
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `W${weekNo} - ${d.getFullYear()}`;
}
/**
* Group drafts by week
*/
function groupDraftsByWeek(drafts: Draft[]): Map<string, Draft[]> {
const groups = new Map<string, Draft[]>();
drafts.forEach(draft => {
const weekLabel = getWeekLabel(new Date(draft.createdAt));
if (!groups.has(weekLabel)) {
groups.set(weekLabel, []);
}
groups.get(weekLabel)!.push(draft);
});
return groups;
}
/**
* HistoryFeed Component
*
* Story 3.1: List of completed drafts with lazy loading
*
* Features:
* - Week-based grouping with headers (W36 - 2026)
* - Lazy loading (infinite scroll)
* - Loading spinner at bottom
* - Empty state for new users
* - Error state with retry
* - Progressive fade-in for new items
*
* Architecture Compliance:
* - Uses atomic selectors from HistoryStore
* - Triggers loadMore when scrolling near bottom
*/
export function HistoryFeed() {
const drafts = useHistoryStore((s) => s.drafts);
const loading = useHistoryStore((s) => s.loading);
const hasMore = useHistoryStore((s) => s.hasMore);
const error = useHistoryStore((s) => s.error);
const loadMore = useHistoryStore((s) => s.loadMore);
const clearError = useHistoryStore((s) => s.clearError);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [initialLoad, setInitialLoad] = useState(true);
// Group drafts by week
const groupedDrafts = useMemo(() => groupDraftsByWeek(drafts), [drafts]);
// Initial load on mount
useEffect(() => {
loadMore().finally(() => {
setInitialLoad(false);
});
}, [loadMore]);
// Infinite scroll handler
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Trigger loadMore when 200px from bottom
if (scrollBottom < 200 && hasMore && !loading && !error) {
loadMore();
}
};
const handleRetry = () => {
clearError();
loadMore();
};
const handleStartVent = () => {
// Navigate to chat page
window.location.href = '/chat';
};
// Empty state
if (!initialLoad && drafts.length === 0 && !loading) {
return <EmptyHistoryState onStartVent={handleStartVent} />;
}
return (
<div className="history-feed flex flex-col h-full">
{/* Scrollable feed container */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-4"
>
{/* Render grouped drafts by week */}
{Array.from(groupedDrafts.entries()).map(([weekLabel, weekDrafts]) => (
<div key={weekLabel} className="mb-6">
{/* Week separator header */}
<div className="flex items-center justify-center gap-3 mt-6 mb-4">
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide px-3 py-1 bg-slate-50 rounded-full border border-slate-200">
{weekLabel}
</span>
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
</div>
{/* Drafts for this week */}
<div className="space-y-3">
{weekDrafts.map((draft) => (
<HistoryCard
key={draft.id}
draft={draft}
onClick={(draft) => useHistoryStore.getState().selectDraft(draft)}
/>
))}
</div>
</div>
))}
{/* Loading indicator at bottom */}
{loading && (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" aria-hidden="true" />
<span className="sr-only">Loading more entries...</span>
</div>
)}
{/* Error state with retry */}
{error && (
<div className="flex flex-col items-center py-4 px-6 bg-red-50 rounded-lg border border-red-200">
<p className="text-red-700 mb-2">{error}</p>
<button
onClick={handleRetry}
type="button"
className="min-h-[44px] px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{/* End of list message */}
{!hasMore && drafts.length > 0 && !loading && (
<p className="text-center text-slate-500 py-4 font-sans">
You've reached the beginning
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Journal Feature Exports
* Story 3.1: History feed and related components
*/
export { HistoryCard } from './HistoryCard';
export { HistoryFeed } from './HistoryFeed';
export { HistoryDetailSheet } from './HistoryDetailSheet';
export { EmptyHistoryState } from './EmptyHistoryState';

View File

@@ -0,0 +1,174 @@
/**
* Tests for InstallPromptButton Component
*
* Story 3.4: Verify install prompt button behavior
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InstallPromptButton } from './InstallPromptButton';
import { useInstallPromptStore } from '@/lib/store/install-prompt-store';
import { EngagementTracker } from '@/services/engagement-tracker';
import { db } from '@/lib/db';
import type { DraftRecord } from '@/lib/db';
// Mock the services
vi.mock('@/services/engagement-tracker', () => ({
EngagementTracker: {
hasEngaged: vi.fn(),
},
}));
vi.mock('@/services/install-prompt-service', () => ({
InstallPromptService: {
promptInstall: vi.fn().mockResolvedValue(true),
dismissInstall: vi.fn(),
},
}));
describe('InstallPromptButton', () => {
beforeEach(async () => {
// Clear database and store state before each test
await db.delete();
await db.open();
useInstallPromptStore.setState({
isInstallable: false,
isInstalled: false,
deferredPrompt: null,
});
vi.clearAllMocks();
});
describe('Visibility Conditions', () => {
it('should not render when not installable', () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: false, isInstalled: false });
const { container } = render(<InstallPromptButton />);
expect(container.querySelector('button')).toBeNull();
});
it('should not render when already installed', () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: true });
const { container } = render(<InstallPromptButton />);
expect(container.querySelector('button')).toBeNull();
});
it('should not render when user has not engaged', async () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(false);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
const { container } = render(<InstallPromptButton />);
await waitFor(() => {
expect(container.querySelector('button')).toBeNull();
});
});
it('should render when all conditions are met', async () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
render(<InstallPromptButton />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /install test01 app/i })).toBeInTheDocument();
});
});
it('should show download icon and install text', async () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
render(<InstallPromptButton />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /install test01 app/i })).toBeInTheDocument();
expect(screen.getByText('Install App')).toBeInTheDocument();
});
});
it('should show "Not now" dismiss button', async () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
render(<InstallPromptButton />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /dismiss install prompt/i })).toBeInTheDocument();
expect(screen.getByText('Not now')).toBeInTheDocument();
});
});
});
describe('Install Button Click', () => {
it('should call promptInstall when clicked', async () => {
const { InstallPromptService } = await import('@/services/install-prompt-service');
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({
isInstallable: true,
isInstalled: false,
});
render(<InstallPromptButton />);
const button = await screen.findByRole('button', { name: /install test01 app/i });
await userEvent.click(button);
await waitFor(() => {
expect(InstallPromptService.promptInstall).toHaveBeenCalled();
});
});
});
describe('Dismiss Button Click', () => {
it('should call dismissInstall when clicked', async () => {
const { InstallPromptService } = await import('@/services/install-prompt-service');
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
render(<InstallPromptButton />);
const dismissButton = await screen.findByRole('button', { name: /dismiss install prompt/i });
await userEvent.click(dismissButton);
expect(InstallPromptService.dismissInstall).toHaveBeenCalled();
});
});
describe('Atomic Selectors Pattern', () => {
it('should use atomic selectors from InstallPromptStore', async () => {
vi.mocked(EngagementTracker.hasEngaged).mockResolvedValue(true);
useInstallPromptStore.setState({ isInstallable: true, isInstalled: false });
render(<InstallPromptButton />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /install test01 app/i })).toBeInTheDocument();
});
// Verify component doesn't break when individual state properties change
useInstallPromptStore.setState({ isInstallable: false });
await waitFor(() => {
expect(screen.queryByRole('button', { name: /install test01 app/i })).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,120 @@
/**
* InstallPromptButton Component
*
* Story 3.4: PWA install prompt button
*
* Architecture Compliance:
* - Uses atomic selectors from InstallPromptStore
* - Follows Logic Sandwich: UI -> Store -> Service
* - Non-intrusive, fixed bottom-right position
* - Only shows when: isInstallable AND !isInstalled AND hasEngaged
*
* User Flow:
* 1. User engages with app (completes 1+ drafts)
* 2. Browser fires beforeinstallprompt event
* 3. InstallPromptButton appears in bottom-right
* 4. User clicks button -> native install prompt appears
* 5. User accepts -> app installs, button disappears
*/
'use client';
import { useState, useEffect } from 'react';
import { Download } from 'lucide-react';
import { useInstallPromptStore } from '@/lib/store/install-prompt-store';
import { InstallPromptService } from '@/services/install-prompt-service';
import { EngagementTracker } from '@/services/engagement-tracker';
/**
* InstallPromptButton Component
*
* Shows a non-intrusive "Install App" button when:
* 1. Browser supports install prompt (beforeinstallprompt fired)
* 2. App is not already installed (not in standalone mode)
* 3. User has engaged (completed at least 1 draft)
*/
export function InstallPromptButton() {
// Atomic selectors for performance
const isInstallable = useInstallPromptStore(s => s.isInstallable);
const isInstalled = useInstallPromptStore(s => s.isInstalled);
// Remove direct store action access
// Track engagement state
const [hasEngaged, setHasEngaged] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Check engagement status on mount and when drafts change
useEffect(() => {
const checkEngagement = async () => {
setIsLoading(true);
const engaged = await EngagementTracker.hasEngaged();
setHasEngaged(engaged);
setIsLoading(false);
};
checkEngagement();
// Re-check engagement periodically (in case user completes a draft)
const interval = setInterval(checkEngagement, 5000);
return () => clearInterval(interval);
}, []);
/**
* Handle install button click
* Triggers the native browser install prompt
*/
const handleInstall = async () => {
const accepted = await InstallPromptService.promptInstall();
if (accepted) {
console.log('[InstallPromptButton] App installed successfully');
// Button will disappear due to isInstalled becoming true
}
};
/**
* Dismiss the install prompt
* Clears the stored event so button won't show again this session
*/
const handleDismiss = () => {
InstallPromptService.dismissInstall();
};
// Only show if:
// 1. Browser supports install prompt (isInstallable)
// 2. App is not already installed (!isInstalled)
// 3. User has engaged (completed at least 1 draft)
// 4. Not still loading engagement status
const shouldShow = isInstallable && !isInstalled && hasEngaged && !isLoading;
if (!shouldShow) {
return null;
}
return (
<div className="fixed bottom-20 right-4 z-40 flex flex-col gap-2 items-end">
{/* Non-intrusive install button */}
<button
onClick={handleInstall}
type="button"
className="flex items-center gap-2 px-4 py-3 bg-slate-800 text-white rounded-lg shadow-lg hover:bg-slate-700 transition-all hover:scale-105 min-h-[44px]"
aria-label="Install Test01 app to home screen"
>
<Download className="w-5 h-5" aria-hidden="true" />
<span className="font-medium">Install App</span>
</button>
{/* Dismiss button */}
<button
onClick={handleDismiss}
type="button"
className="text-xs text-slate-500 hover:text-slate-700 underline pr-1"
aria-label="Dismiss install prompt"
>
Not now
</button>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { PWAInitializer } from './PWAInitializer';
import { SyncManager } from '@/services/sync-manager';
import { InstallPromptService } from '@/services/install-prompt-service';
// Mock services
vi.mock('@/services/sync-manager', () => ({
SyncManager: {
startNetworkListener: vi.fn(),
isOnline: vi.fn().mockReturnValue(true),
},
}));
vi.mock('@/services/install-prompt-service', () => ({
InstallPromptService: {
initialize: vi.fn(),
},
}));
// Mock store
vi.mock('@/lib/store/offline-store', () => ({
useOfflineStore: {
getState: () => ({
setOnlineStatus: vi.fn(),
}),
},
}));
describe('PWAInitializer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('initializes services on mount', () => {
render(<PWAInitializer />);
expect(SyncManager.startNetworkListener).toHaveBeenCalled();
expect(InstallPromptService.initialize).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,39 @@
'use client';
import { useEffect } from 'react';
import { SyncManager } from '@/services/sync-manager';
import { InstallPromptService } from '@/services/install-prompt-service';
import { useOfflineStore } from '@/lib/store/offline-store';
export function PWAInitializer() {
useEffect(() => {
if (typeof window === 'undefined') return;
// Story 3.3: Initialize offline network listeners
SyncManager.startNetworkListener();
// Update online status when network changes
const updateOnlineStatus = () => {
const isOnline = SyncManager.isOnline();
useOfflineStore.getState().setOnlineStatus(isOnline);
};
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Initial status check
updateOnlineStatus();
// Story 3.4: Initialize install prompt service
InstallPromptService.initialize();
console.log('[PWAInitializer] Services initialized');
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
};
}, []);
return null;
}

View File

@@ -0,0 +1,2 @@
export * from './InstallPromptButton';
export * from './PWAInitializer';

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InstallPrompt } from './install-prompt';
// Mock BeforeInstallPromptEvent
class BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
constructor() {
super('beforeinstallprompt');
this.prompt = vi.fn().mockResolvedValue(undefined);
this.userChoice = Promise.resolve({ outcome: 'accepted' });
}
}
describe('InstallPrompt Component', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should not render initially', () => {
render(<InstallPrompt />);
expect(screen.queryByText(/Install App/i)).toBeNull();
});
it('should appear when beforeinstallprompt event fires', async () => {
render(<InstallPrompt />);
// Simulate event
const event = new BeforeInstallPromptEvent();
window.dispatchEvent(event);
await waitFor(() => {
expect(screen.getByText(/Install Test01/i)).toBeInTheDocument();
});
});
it('scrolls call prompt() when "Install" is clicked', async () => {
render(<InstallPrompt />);
// Trigger show
const event = new BeforeInstallPromptEvent();
window.dispatchEvent(event);
// Find button and click
const button = await screen.findByRole('button', { name: /Install/i });
fireEvent.click(button);
// Verify prompt was called on the stashed event
expect(event.prompt).toHaveBeenCalled();
});
it('should dismiss when "Not Now" is clicked', async () => {
render(<InstallPrompt />);
// Trigger show
const event = new BeforeInstallPromptEvent();
window.dispatchEvent(event);
// Find dismiss button
const closeButton = await screen.findByRole('button', { name: /Not Now/i });
fireEvent.click(closeButton);
// Verify it disappears
await waitFor(() => {
expect(screen.queryByText(/Install Test01/i)).toBeNull();
});
});
});

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Download } from 'lucide-react';
import { toast } from 'sonner';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
// Prevent default browser install banner
e.preventDefault();
// Stash the event so it can be triggered later
setDeferredPrompt(e as BeforeInstallPromptEvent);
// Show the UI
setIsVisible(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
// Show the install prompt
await deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
toast.success('Thanks for installing!');
} else {
console.log('User dismissed the install prompt');
}
// We can't use the prompt again, so clear it
setDeferredPrompt(null);
setIsVisible(false);
};
const handleDismiss = () => {
setIsVisible(false);
};
if (!isVisible) return null;
return (
<div className="fixed bottom-20 left-4 right-4 z-50 md:left-auto md:right-6 md:w-80 md:bottom-6 animate-in slide-in-from-bottom-4 fade-in duration-300">
<div className="bg-slate-900 text-white p-4 rounded-xl shadow-2xl flex items-start gap-3 border border-slate-700/50">
<div className="bg-white/10 p-2 rounded-lg shrink-0">
<Download className="w-6 h-6 text-sky-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm">Install Test01</h3>
<p className="text-xs text-slate-400 mt-1">
Install this app on your home screen for quick access and offline use.
</p>
<div className="flex items-center gap-3 mt-3">
<button
onClick={handleInstallClick}
className="bg-sky-500 hover:bg-sky-400 text-white text-xs font-semibold px-3 py-1.5 rounded-full transition-colors"
>
Install
</button>
<button
onClick={handleDismiss}
className="text-slate-400 hover:text-white text-xs font-medium px-2 py-1 transition-colors"
>
Not Now
</button>
</div>
</div>
<button
onClick={handleDismiss}
className="text-slate-500 hover:text-slate-300 -mr-1 -mt-1 p-1"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ConnectionStatus } from './connection-status';
import { useSettingsStore } from '@/store/use-settings';
import { SettingsService } from '@/services/settings-service';
// Mock SettingsService (Logic Sandwich pattern)
vi.mock('@/services/settings-service', () => ({
SettingsService: {
validateProviderConnection: vi.fn(),
},
}));
describe('ConnectionStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: false,
});
});
it('does not render when API key is not set', () => {
render(<ConnectionStatus />);
expect(screen.queryByText(/test connection/i)).not.toBeInTheDocument();
});
it('renders Test Connection button when API key is set', () => {
useSettingsStore.setState({
apiKey: 'sk-test-key',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: true,
});
render(<ConnectionStatus />);
expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument();
});
it('shows testing state during validation', async () => {
useSettingsStore.setState({
apiKey: 'sk-test-key',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: true,
});
vi.mocked(SettingsService.validateProviderConnection).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ isValid: true }), 100))
);
render(<ConnectionStatus />);
const button = screen.getByRole('button', { name: /test connection/i });
fireEvent.click(button);
// Should show testing state
await waitFor(() => {
expect(screen.getByText(/testing\.\.\./i)).toBeInTheDocument();
});
});
it('shows success message on valid connection', async () => {
useSettingsStore.setState({
apiKey: 'sk-test-key',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: true,
});
vi.mocked(SettingsService.validateProviderConnection).mockResolvedValue({ isValid: true });
render(<ConnectionStatus />);
const button = screen.getByRole('button', { name: /test connection/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/connected.*✅/i)).toBeInTheDocument();
});
});
it('shows error message on failed connection', async () => {
useSettingsStore.setState({
apiKey: 'sk-invalid-key',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: true,
});
vi.mocked(SettingsService.validateProviderConnection).mockResolvedValue({
isValid: false,
error: 'Invalid API key',
});
render(<ConnectionStatus />);
const button = screen.getByRole('button', { name: /test connection/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/connection failed.*❌/i)).toBeInTheDocument();
});
// Should also show the error message
await waitFor(() => {
expect(screen.getByText(/invalid api key/i)).toBeInTheDocument();
});
});
it('disables button during testing', async () => {
useSettingsStore.setState({
apiKey: 'sk-test-key',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: true,
});
vi.mocked(SettingsService.validateProviderConnection).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ isValid: true }), 100))
);
render(<ConnectionStatus />);
const button = screen.getByRole('button', { name: /test connection/i });
fireEvent.click(button);
// Button should be disabled during testing
await waitFor(() => {
expect(button).toBeDisabled();
});
});
it('calls SettingsService.validateProviderConnection on test', async () => {
useSettingsStore.setState({
apiKey: 'sk-test-key',
baseUrl: 'https://api.custom.com/v1',
modelName: 'custom-model',
isConfigured: true,
});
vi.mocked(SettingsService.validateProviderConnection).mockResolvedValue({ isValid: true });
render(<ConnectionStatus />);
const button = screen.getByRole('button', { name: /test connection/i });
fireEvent.click(button);
await waitFor(() => {
expect(SettingsService.validateProviderConnection).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,95 @@
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { SettingsService } from '@/services/settings-service';
import { useApiKey } from '@/store/use-settings';
/**
* Connection Validation States
*/
type ValidationStatus = 'idle' | 'testing' | 'success' | 'error';
/**
* Connection Status Component (Story 4.2 Enhanced)
*
* Displays API connection validation status with detailed error messages
* and retry capability.
*/
export function ConnectionStatus() {
const apiKey = useApiKey();
const [status, setStatus] = useState<ValidationStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
const handleTest = async () => {
if (!apiKey) return;
setStatus('testing');
setErrorMessage('');
try {
// Use SettingsService for proper Logic Sandwich pattern
const result = await SettingsService.validateProviderConnection();
if (result.isValid) {
setStatus('success');
} else {
setStatus('error');
setErrorMessage(result.error || 'Connection failed');
}
} catch (error) {
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Connection failed');
}
};
if (!apiKey) return null;
return (
<div className="space-y-2 mt-4">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleTest}
disabled={status === 'testing'}
>
{status === 'testing' ? 'Testing...' : 'Test Connection'}
</Button>
{status === 'success' && (
<span className="text-green-600 font-medium flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full" />
Connected
</span>
)}
{status === 'error' && (
<span className="text-red-600 font-medium flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-red-500 rounded-full" />
Connection Failed
</span>
)}
</div>
{status === 'error' && errorMessage && (
<div className="space-y-2">
<p className="text-sm text-red-600">{errorMessage}</p>
{/* Retry hint for network errors */}
{errorMessage.toLowerCase().includes('network') && (
<p className="text-xs text-muted-foreground">
Tip: Network errors can be temporary. Try again in a moment.
</p>
)}
</div>
)}
{/* Success message with auto-hide hint */}
{status === 'success' && (
<p className="text-xs text-green-600">
Your API credentials are working correctly!
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
/**
* Tests for Connection Status component validation enhancement (Story 4.2)
*
* Tests the enhanced component with detailed error display.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ConnectionStatus } from './connection-status';
import { ApiErrorType } from '@/types/settings';
import { useApiKey } from '@/store/use-settings';
// Mock the settings service
vi.mock('@/services/settings-service', () => ({
SettingsService: {
validateProviderConnection: vi.fn(),
},
}));
// Mock the settings store
vi.mock('@/store/use-settings', () => ({
useApiKey: vi.fn(() => 'sk-test-key'),
}));
import { SettingsService } from '@/services/settings-service';
describe('ConnectionStatus Component - Validation Enhancement (Story 4.2)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should display success status when connection is valid', async () => {
vi.mocked(SettingsService.validateProviderConnection).mockResolvedValueOnce({
isValid: true,
});
render(<ConnectionStatus />);
const testButton = screen.getByRole('button', { name: /test connection/i });
testButton.click();
await waitFor(() => {
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
});
it('should display detailed error message for invalid API key', async () => {
vi.mocked(SettingsService.validateProviderConnection).mockResolvedValueOnce({
isValid: false,
error: 'Your API key appears to be invalid or expired.',
});
render(<ConnectionStatus />);
const testButton = screen.getByRole('button', { name: /test connection/i });
testButton.click();
await waitFor(() => {
expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
expect(screen.getByText(/invalid or expired/i)).toBeInTheDocument();
});
});
it('should show loading state during validation', async () => {
// Make validation take some time
vi.mocked(SettingsService.validateProviderConnection).mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ isValid: true }), 100))
);
render(<ConnectionStatus />);
const testButton = screen.getByRole('button', { name: /test connection/i });
testButton.click();
// The button text should change to "Testing..." during validation
await waitFor(() => {
expect(screen.getByRole('button', { name: /testing/i })).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
});
it('should disable button while testing', async () => {
vi.mocked(SettingsService.validateProviderConnection).mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ isValid: true }), 100))
);
render(<ConnectionStatus />);
const testButton = screen.getByRole('button', { name: /test connection/i });
testButton.click();
// Check that button is disabled during testing
await waitFor(() => {
expect(testButton).toBeDisabled();
});
await waitFor(() => {
expect(testButton).not.toBeDisabled();
});
});
it('should not render when API key is not present', () => {
vi.mocked(useApiKey).mockReturnValueOnce('');
const { container } = render(<ConnectionStatus />);
expect(container.firstChild).toBeNull();
});
it('should enable retry after failed connection', async () => {
vi.mocked(SettingsService.validateProviderConnection)
.mockResolvedValueOnce({
isValid: false,
error: 'Network error',
})
.mockResolvedValueOnce({
isValid: true,
});
render(<ConnectionStatus />);
const testButton = screen.getByRole('button', { name: /test connection/i });
// First attempt - fails
testButton.click();
await waitFor(() => {
expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
});
// Second attempt - succeeds
testButton.click();
await waitFor(() => {
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,2 @@
export { ProviderForm } from './provider-form';
export { ConnectionStatus } from './connection-status';

View File

@@ -0,0 +1,186 @@
/**
* Model Selection Tests for ProviderForm Component
* Tests for Story 4.3: Model Selection Configuration
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ProviderForm } from './provider-form';
import { useSettingsStore } from '@/store/use-settings';
// Helper to reset store before each test
const resetStore = () => {
useSettingsStore.setState({
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4o',
isConfigured: false,
actions: useSettingsStore.getState().actions
});
// Reset localStorage
localStorage.clear();
};
describe('ProviderForm - Model Selection (Story 4.3)', () => {
beforeEach(() => {
resetStore();
});
afterEach(() => {
resetStore();
});
describe('AC 1: Model Name Field with Examples', () => {
it('should render model name input field with label', () => {
render(<ProviderForm />);
const modelLabel = screen.getByLabelText('Model Name');
expect(modelLabel).toBeInTheDocument();
});
it('should have placeholder text showing example model format', () => {
render(<ProviderForm />);
const modelInput = screen.getByLabelText('Model Name');
expect(modelInput).toHaveAttribute('placeholder', 'gpt-4o');
});
it('should have helper text with model examples', () => {
render(<ProviderForm />);
const helperText = screen.getByText(/Model identifier/i);
expect(helperText).toBeInTheDocument();
expect(helperText.textContent).toMatch(/gpt-4o|deepseek-chat/i);
});
});
describe('AC 2: Custom Model Name Storage', () => {
it('should store custom model name in settings store', () => {
render(<ProviderForm />);
const modelInput = screen.getByLabelText('Model Name');
fireEvent.change(modelInput, { target: { value: 'gpt-3.5-turbo' } });
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('gpt-3.5-turbo');
});
it('should persist model name across page reloads', () => {
const { unmount } = render(<ProviderForm />);
// Set custom model
const modelInput = screen.getByLabelText('Model Name');
fireEvent.change(modelInput, { target: { value: 'deepseek-coder' } });
// Unmount and remount (simulating page reload)
unmount();
render(<ProviderForm />);
// Model name should be persisted
const modelInputAfterReload = screen.getByLabelText('Model Name');
expect(modelInputAfterReload).toHaveValue('deepseek-coder');
});
});
describe('AC 3: Default Model Behavior', () => {
it('should have sensible default model name in store', () => {
// Reset to fresh state
resetStore();
const { unmount } = render(<ProviderForm />);
unmount();
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('gpt-4o');
});
it('should use preset default model when provider preset is clicked', () => {
render(<ProviderForm />);
// Click OpenAI preset
const openaiButton = screen.getByRole('button', { name: 'OpenAI' });
fireEvent.click(openaiButton);
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('gpt-4o');
});
it('should set deepseek-chat when DeepSeek preset is clicked', () => {
render(<ProviderForm />);
const deepseekButton = screen.getByRole('button', { name: 'DeepSeek' });
fireEvent.click(deepseekButton);
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('deepseek-chat');
});
it('should set claude-3-haiku when OpenRouter preset is clicked', () => {
render(<ProviderForm />);
const openrouterButton = screen.getByRole('button', { name: 'OpenRouter' });
fireEvent.click(openrouterButton);
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('anthropic/claude-3-haiku');
});
});
describe('Integration: Model Name in LLM API Calls', () => {
it('should allow custom model name to be set and used', () => {
render(<ProviderForm />);
const modelInput = screen.getByLabelText('Model Name');
fireEvent.change(modelInput, { target: { value: 'custom-model-v1' } });
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('custom-model-v1');
});
it('should preserve custom model name when provider preset is clicked', () => {
render(<ProviderForm />);
// Set a custom model name
const modelInput = screen.getByLabelText('Model Name');
fireEvent.change(modelInput, { target: { value: 'my-custom-model' } });
// Click OpenAI preset - should NOT overwrite custom model because it's not a known default
const openaiButton = screen.getByRole('button', { name: 'OpenAI' });
fireEvent.click(openaiButton);
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('my-custom-model'); // Preserved
const baseUrl = useSettingsStore.getState().baseUrl;
expect(baseUrl).toBe('https://api.openai.com/v1'); // URL still updates
});
it('should update model name if current is a known default (e.g. switching presets)', () => {
render(<ProviderForm />);
// Start with OpenAI default
useSettingsStore.setState({ modelName: 'gpt-4o' });
// Click DeepSeek preset
const deepseekButton = screen.getByRole('button', { name: 'DeepSeek' });
fireEvent.click(deepseekButton);
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('deepseek-chat'); // Updated because 'gpt-4o' is a known default
});
it('should allow manual override of preset model', () => {
render(<ProviderForm />);
// First click a preset
const openaiButton = screen.getByRole('button', { name: 'OpenAI' });
fireEvent.click(openaiButton);
// Then manually change the model
const modelInput = screen.getByLabelText('Model Name');
fireEvent.change(modelInput, { target: { value: 'gpt-3.5-turbo' } });
const modelName = useSettingsStore.getState().modelName;
expect(modelName).toBe('gpt-3.5-turbo');
});
});
});

View File

@@ -0,0 +1,292 @@
/**
* Tests for ProviderForm Provider Management Features
* Story 4.4: Provider Switching
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProviderForm } from './provider-form';
import { ProviderManagementService } from '@/services/provider-management-service';
import { useSettingsStore } from '@/store/use-settings';
// Mock services
vi.mock('@/services/settings-service', () => ({
SettingsService: {
saveProviderSettingsWithValidation: vi.fn(),
},
}));
vi.mock('@/services/provider-management-service', () => ({
ProviderManagementService: {
addProviderProfile: vi.fn(),
updateProviderProfile: vi.fn(),
setActiveProvider: vi.fn(),
getActiveProvider: vi.fn(),
},
}));
vi.mock('@/hooks/use-toast', () => ({
toast: vi.fn(),
}));
import { SettingsService } from '@/services/settings-service';
import { toast } from '@/hooks/use-toast';
describe('ProviderForm Provider Management', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset store state before each test
useSettingsStore.setState({
savedProviders: [],
activeProviderId: null,
providerMigrationState: {
hasMigrated: false,
},
});
});
describe('Add New Provider mode', () => {
it('renders provider name input field', () => {
render(<ProviderForm mode="add" />);
expect(screen.getByLabelText(/provider name/i)).toBeTruthy();
});
it('shows "Save as New Provider" button in add mode', () => {
render(<ProviderForm mode="add" />);
expect(screen.getByRole('button', { name: /save as new provider/i })).toBeTruthy();
});
it('calls addProviderProfile when saving new provider', async () => {
const mockProviderId = 'new-provider-id';
vi.mocked(ProviderManagementService.addProviderProfile).mockReturnValue(mockProviderId);
// Mock validation to succeed
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: true,
errorMessage: null,
});
render(<ProviderForm mode="add" />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'My New Provider' },
});
fireEvent.change(screen.getByLabelText(/base url/i), {
target: { value: 'https://api.new.com/v1' },
});
fireEvent.change(screen.getByLabelText(/model name/i), {
target: { value: 'new-model' },
});
// Use id to be more specific for API key input
fireEvent.change(screen.getByDisplayValue('') || screen.getByPlaceholderText('sk-...'), {
target: { value: 'sk-new-key' },
});
fireEvent.click(screen.getByRole('button', { name: /save as new provider/i }));
await waitFor(() => {
expect(ProviderManagementService.addProviderProfile).toHaveBeenCalledWith({
name: 'My New Provider',
baseUrl: 'https://api.new.com/v1',
apiKey: 'sk-new-key',
modelName: 'new-model',
});
});
});
it('auto-selects newly created provider after save', async () => {
const mockProviderId = 'new-provider-id';
vi.mocked(ProviderManagementService.addProviderProfile).mockReturnValue(mockProviderId);
// Mock setActiveProvider to update the store
vi.mocked(ProviderManagementService.setActiveProvider).mockImplementation((id) => {
useSettingsStore.setState({ activeProviderId: id });
});
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: true,
errorMessage: null,
});
render(<ProviderForm mode="add" />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'Test Provider' },
});
fireEvent.change(screen.getByLabelText(/base url/i), {
target: { value: 'https://api.test.com/v1' },
});
fireEvent.change(screen.getByLabelText(/model name/i), {
target: { value: 'test-model' },
});
// Use placeholder to find the input
const apiKeyInput = screen.getByPlaceholderText('sk-...');
fireEvent.change(apiKeyInput, {
target: { value: 'sk-test-key' },
});
fireEvent.click(screen.getByRole('button', { name: /save as new provider/i }));
await waitFor(() => {
expect(useSettingsStore.getState().activeProviderId).toBe(mockProviderId);
});
});
it('shows validation error when provider name is empty', async () => {
render(<ProviderForm mode="add" />);
fireEvent.click(screen.getByRole('button', { name: /save as new provider/i }));
await waitFor(() => {
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Validation Error',
})
);
});
});
});
describe('Edit Provider mode', () => {
const mockProvider = {
id: 'edit-provider-id',
name: 'Provider to Edit',
baseUrl: 'https://api.edit.com/v1',
apiKey: 'sk-edit-key',
modelName: 'edit-model',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
};
it('pre-fills form with existing provider data', () => {
render(<ProviderForm mode="edit" provider={mockProvider} />);
expect(screen.getByLabelText(/provider name/i)).toHaveValue('Provider to Edit');
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.edit.com/v1');
expect(screen.getByLabelText(/model name/i)).toHaveValue('edit-model');
});
it('shows "Update Provider" button in edit mode', () => {
render(<ProviderForm mode="edit" provider={mockProvider} />);
expect(screen.getByRole('button', { name: /update provider/i })).toBeTruthy();
});
it('calls updateProviderProfile when saving edits', async () => {
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: true,
errorMessage: null,
});
render(<ProviderForm mode="edit" provider={mockProvider} />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'Updated Provider Name' },
});
fireEvent.change(screen.getByLabelText(/base url/i), {
target: { value: 'https://api.updated.com/v1' },
});
fireEvent.click(screen.getByRole('button', { name: /update provider/i }));
await waitFor(() => {
expect(ProviderManagementService.updateProviderProfile).toHaveBeenCalledWith(
'edit-provider-id',
{
name: 'Updated Provider Name',
baseUrl: 'https://api.updated.com/v1',
apiKey: 'sk-edit-key',
modelName: 'edit-model',
}
);
});
});
it('does not change active provider when editing', async () => {
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: true,
errorMessage: null,
});
useSettingsStore.setState({
activeProviderId: 'other-provider-id',
});
render(<ProviderForm mode="edit" provider={mockProvider} />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'Updated Provider' },
});
fireEvent.click(screen.getByRole('button', { name: /update provider/i }));
await waitFor(() => {
expect(useSettingsStore.getState().activeProviderId).toBe('other-provider-id');
});
});
});
describe('Connection validation', () => {
it('validates connection before saving new provider', async () => {
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: true,
errorMessage: null,
});
render(<ProviderForm mode="add" />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'Validated Provider' },
});
fireEvent.change(screen.getByLabelText(/base url/i), {
target: { value: 'https://api.valid.com/v1' },
});
fireEvent.change(screen.getByLabelText(/model name/i), {
target: { value: 'valid-model' },
});
const apiKeyInput = screen.getByPlaceholderText('sk-...');
fireEvent.change(apiKeyInput, {
target: { value: 'sk-valid-key' },
});
fireEvent.click(screen.getByRole('button', { name: /save as new provider/i }));
await waitFor(() => {
expect(SettingsService.saveProviderSettingsWithValidation).toHaveBeenCalled();
});
});
it('does not save provider if validation fails', async () => {
vi.mocked(SettingsService.saveProviderSettingsWithValidation).mockResolvedValue({
isValid: false,
errorMessage: 'Invalid API key',
});
render(<ProviderForm mode="add" />);
fireEvent.change(screen.getByLabelText(/provider name/i), {
target: { value: 'Invalid Provider' },
});
fireEvent.change(screen.getByLabelText(/base url/i), {
target: { value: 'https://api.invalid.com/v1' },
});
fireEvent.change(screen.getByLabelText(/model name/i), {
target: { value: 'invalid-model' },
});
const apiKeyInput = screen.getByPlaceholderText('sk-...');
fireEvent.change(apiKeyInput, {
target: { value: 'sk-invalid-key' },
});
fireEvent.click(screen.getByRole('button', { name: /save as new provider/i }));
await waitFor(() => {
expect(ProviderManagementService.addProviderProfile).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Connection Failed',
})
);
});
});
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProviderForm } from './provider-form';
import { useSettingsStore } from '@/store/use-settings';
// Mock ConnectionStatus component
vi.mock('./connection-status', () => ({
ConnectionStatus: () => <div data-testid="connection-status">Connection Status</div>,
}));
describe('ProviderForm', () => {
beforeEach(() => {
// Reset store state
useSettingsStore.setState({
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: false,
});
});
it('renders all required input fields', () => {
render(<ProviderForm />);
expect(screen.getByLabelText(/base url/i)).toBeInTheDocument();
expect(screen.getByLabelText(/model name/i)).toBeInTheDocument();
// Use placeholder for API key since show/hide button has "API key" in aria-label
expect(screen.getByPlaceholderText('sk-...')).toBeInTheDocument();
});
it('shows helper text for API key', () => {
render(<ProviderForm />);
expect(screen.getByText(/stored locally in your browser with basic encoding/i)).toBeInTheDocument();
});
it('has show/hide toggle for API key', () => {
render(<ProviderForm />);
const apiKeyInput = screen.getByPlaceholderText('sk-...') as HTMLInputElement;
const toggleButton = screen.getByRole('button', { name: /show/i });
// Initially password type
expect(apiKeyInput.type).toBe('password');
// Click show button
fireEvent.click(toggleButton);
// Should change to text type
expect(apiKeyInput.type).toBe('text');
});
it('updates base URL when user types', () => {
render(<ProviderForm />);
const baseUrlInput = screen.getByLabelText(/base url/i);
fireEvent.change(baseUrlInput, { target: { value: 'https://api.deepseek.com/v1' } });
const state = useSettingsStore.getState();
expect(state.baseUrl).toBe('https://api.deepseek.com/v1');
});
it('updates model name when user types', () => {
render(<ProviderForm />);
const modelInput = screen.getByLabelText(/model name/i);
fireEvent.change(modelInput, { target: { value: 'deepseek-chat' } });
const state = useSettingsStore.getState();
expect(state.modelName).toBe('deepseek-chat');
});
it('updates API key when user types', () => {
render(<ProviderForm />);
const apiKeyInput = screen.getByPlaceholderText('sk-...');
fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key-12345' } });
const state = useSettingsStore.getState();
expect(state.apiKey).toBe('sk-test-key-12345');
expect(state.isConfigured).toBe(true);
});
it('displays current values from store', () => {
useSettingsStore.setState({
apiKey: 'sk-existing-key',
baseUrl: 'https://api.custom.com/v1',
modelName: 'custom-model',
isConfigured: true,
});
render(<ProviderForm />);
expect(screen.getByPlaceholderText('sk-...')).toHaveValue('sk-existing-key');
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.custom.com/v1');
expect(screen.getByLabelText(/model name/i)).toHaveValue('custom-model');
});
it('renders ConnectionStatus component', () => {
render(<ProviderForm />);
expect(screen.getByTestId('connection-status')).toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<ProviderForm />);
// All inputs should have associated labels
expect(screen.getByLabelText(/base url/i)).toBeInTheDocument();
expect(screen.getByLabelText(/model name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText('sk-...')).toBeInTheDocument();
});
it('displays provider preset buttons', () => {
render(<ProviderForm />);
expect(screen.getByRole('button', { name: 'OpenAI' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'DeepSeek' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'OpenRouter' })).toBeInTheDocument();
});
it('applies preset when clicked', () => {
render(<ProviderForm />);
const deepseekButton = screen.getByRole('button', { name: 'DeepSeek' });
fireEvent.click(deepseekButton);
const state = useSettingsStore.getState();
expect(state.baseUrl).toBe('https://api.deepseek.com/v1');
expect(state.modelName).toBe('deepseek-chat');
});
});

View File

@@ -0,0 +1,332 @@
"use client";
import { useEffect, useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useApiKey, useBaseUrl, useModelName, useSettingsActions } from '@/store/use-settings';
import { ConnectionStatus } from './connection-status';
import { SettingsService } from '@/services/settings-service';
import { ProviderManagementService } from '@/services/provider-management-service';
import { toast } from '@/hooks/use-toast';
import type { ProviderProfile } from '@/types/settings';
// Provider presets for common LLM providers
const PROVIDER_PRESETS = [
{
name: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o',
description: 'Official OpenAI API endpoint',
},
{
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
description: 'DeepSeek AI - High performance, cost effective',
},
{
name: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
defaultModel: 'anthropic/claude-3-haiku',
description: 'Unified API for multiple providers',
},
];
type ProviderFormMode = 'add' | 'edit';
interface ProviderFormProps {
mode?: ProviderFormMode;
provider?: ProviderProfile;
onSave?: () => void;
onCancel?: () => void;
}
export function ProviderForm({ mode, provider, onSave, onCancel }: ProviderFormProps) {
const apiKey = useApiKey();
const baseUrl = useBaseUrl();
const modelName = useModelName();
const actions = useSettingsActions();
// Local form state for provider management modes
const [providerName, setProviderName] = useState(provider?.name ?? '');
const [formBaseUrl, setFormBaseUrl] = useState(provider?.baseUrl ?? baseUrl);
const [formModelName, setFormModelName] = useState(provider?.modelName ?? modelName);
const [formApiKey, setFormApiKey] = useState(provider?.apiKey ?? apiKey);
const [showKey, setShowKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Hydration fix for persistent store
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Update form when provider prop changes
useEffect(() => {
if (provider) {
setProviderName(provider.name);
setFormBaseUrl(provider.baseUrl);
setFormModelName(provider.modelName);
setFormApiKey(provider.apiKey);
}
}, [provider]);
// Handle preset selection
const handlePresetSelect = (preset: typeof PROVIDER_PRESETS[0]) => {
const currentModel = mode ? formModelName : modelName;
// Smart Preset Logic: Only overwrite model name if it's empty or matches a known default
// This preserves custom model names (e.g., "my-finetune") when switching providers
const isKnownDefault = PROVIDER_PRESETS.some(p => p.defaultModel === currentModel);
const isEmpty = !currentModel || currentModel.trim() === '';
const shouldUpdateModel = isEmpty || isKnownDefault;
if (mode) {
setFormBaseUrl(preset.baseUrl);
if (shouldUpdateModel) {
setFormModelName(preset.defaultModel);
}
} else {
actions.setBaseUrl(preset.baseUrl);
if (shouldUpdateModel) {
actions.setModelName(preset.defaultModel);
}
}
};
// Handle save with validation (Story 4.2 AC: validate on save)
const handleSaveWithValidation = async () => {
// For provider management mode, validate provider name
if (mode && !providerName.trim()) {
toast({
title: 'Validation Error',
description: 'Please enter a provider name.',
variant: 'destructive',
});
return;
}
const currentApiKey = mode ? formApiKey : apiKey;
const currentBaseUrl = mode ? formBaseUrl : baseUrl;
const currentModelName = mode ? formModelName : modelName;
if (!currentApiKey || !currentBaseUrl || !currentModelName) {
toast({
title: 'Validation Error',
description: 'Please fill in all fields before saving.',
variant: 'destructive',
});
return;
}
setIsSaving(true);
try {
const result = await SettingsService.saveProviderSettingsWithValidation({
apiKey: currentApiKey,
baseUrl: currentBaseUrl,
modelName: currentModelName,
});
if (result && !result.isValid) {
// Validation failed - show error toast
toast({
title: 'Connection Failed',
description: result.errorMessage || 'Could not connect to provider.',
variant: 'destructive',
});
return;
}
if (mode === 'add') {
// Save as new provider
const newProviderId = ProviderManagementService.addProviderProfile({
name: providerName,
baseUrl: currentBaseUrl,
apiKey: currentApiKey,
modelName: currentModelName,
});
// Auto-select newly created provider
ProviderManagementService.setActiveProvider(newProviderId);
toast({
title: 'Provider Added',
description: `"${providerName}" has been added and selected as active.`,
});
} else if (mode === 'edit' && provider) {
// Update existing provider
ProviderManagementService.updateProviderProfile(provider.id, {
name: providerName,
baseUrl: currentBaseUrl,
apiKey: currentApiKey,
modelName: currentModelName,
});
toast({
title: 'Provider Updated',
description: `"${providerName}" has been updated.`,
});
} else {
// Legacy mode - save to store directly
actions.setApiKey(currentApiKey);
actions.setBaseUrl(currentBaseUrl);
actions.setModelName(currentModelName);
toast({
title: 'Settings Saved',
description: 'Your API credentials have been verified and saved.',
});
}
onSave?.();
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'An error occurred.',
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
if (!mounted) return <div className="p-4">Loading settings...</div>;
const currentBaseUrl = mode ? formBaseUrl : baseUrl;
const currentModelName = mode ? formModelName : modelName;
const currentApiKey = mode ? formApiKey : apiKey;
const handleBaseUrlChange = mode ? (e: React.ChangeEvent<HTMLInputElement>) => setFormBaseUrl(e.target.value) : (e: React.ChangeEvent<HTMLInputElement>) => actions.setBaseUrl(e.target.value);
const handleModelNameChange = mode ? (e: React.ChangeEvent<HTMLInputElement>) => setFormModelName(e.target.value) : (e: React.ChangeEvent<HTMLInputElement>) => actions.setModelName(e.target.value);
const handleApiKeyChange = mode ? (e: React.ChangeEvent<HTMLInputElement>) => setFormApiKey(e.target.value) : (e: React.ChangeEvent<HTMLInputElement>) => actions.setApiKey(e.target.value);
const cardTitle = mode === 'edit' ? 'Edit Provider' : mode === 'add' ? 'Add New Provider' : 'AI Provider Settings';
const buttonText = mode === 'edit' ? 'Update Provider' : mode === 'add' ? 'Save as New Provider' : isSaving ? 'Validating...' : 'Save & Validate';
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>{cardTitle}</CardTitle>
<CardDescription>
{mode
? 'Configure your provider profile. Your API Key is stored locally in your browser.'
: 'Configure your own LLM provider. Your API Key is stored locally in your browser and sent directly to the provider.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Provider Name - only for add/edit modes */}
{mode && (
<div className="space-y-2">
<Label htmlFor="providerName">Provider Name</Label>
<Input
id="providerName"
value={providerName}
onChange={(e) => setProviderName(e.target.value)}
placeholder="My OpenAI Key"
/>
<p className="text-xs text-muted-foreground">
A label to identify this provider (e.g., "My OpenAI Key")
</p>
</div>
)}
{/* Provider Presets - hide in edit mode */}
{mode !== 'edit' && (
<div className="space-y-2">
<Label>Quick Setup</Label>
<div className="flex flex-wrap gap-2">
{PROVIDER_PRESETS.map((preset) => (
<button
key={preset.name}
type="button"
onClick={() => handlePresetSelect(preset)}
className="px-3 py-1 text-sm border rounded-md hover:bg-muted transition-colors"
title={preset.description}
>
{preset.name}
</button>
))}
</div>
</div>
)}
{/* Base URL */}
<div className="space-y-2">
<Label htmlFor="baseUrl">Base URL</Label>
<Input
id="baseUrl"
value={currentBaseUrl}
onChange={handleBaseUrlChange}
placeholder="https://api.openai.com/v1"
type="url"
/>
<p className="text-xs text-muted-foreground">
API endpoint URL (e.g., https://api.openai.com/v1)
</p>
</div>
{/* Model Name */}
<div className="space-y-2">
<Label htmlFor="modelName">Model Name</Label>
<Input
id="modelName"
value={currentModelName}
onChange={handleModelNameChange}
placeholder="gpt-4o"
/>
<p className="text-xs text-muted-foreground">
Model identifier (e.g., gpt-4o, deepseek-chat)
</p>
</div>
{/* API Key */}
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<div className="relative">
<Input
id="apiKey"
type={showKey ? "text" : "password"}
value={currentApiKey}
onChange={handleApiKeyChange}
placeholder="sk-..."
autoComplete="off"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500 hover:text-gray-700"
onClick={() => setShowKey(!showKey)}
aria-label={showKey ? "Hide API key" : "Show API key"}
>
{showKey ? "Hide" : "Show"}
</button>
</div>
<p className="text-xs text-muted-foreground">
Stored locally in your browser with basic encoding. Never sent to our servers.
</p>
</div>
{/* Save Button with Validation */}
<div className="flex gap-2 pt-2">
{onCancel && (
<Button
onClick={onCancel}
variant="outline"
disabled={isSaving}
className="flex-1"
>
Cancel
</Button>
)}
<Button
onClick={handleSaveWithValidation}
disabled={isSaving}
className={onCancel ? 'flex-1' : 'flex-1'}
>
{isSaving ? 'Validating...' : buttonText}
</Button>
</div>
{/* Connection Status - hide in add/edit modes */}
{!mode && <ConnectionStatus />}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,139 @@
/**
* Tests for Provider Form component validation enhancement (Story 4.2)
*
* Tests the debounced validation, visual indicators, and save integration.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProviderForm } from './provider-form';
// Create stable mock functions
const mockActions = {
setApiKey: vi.fn(),
setBaseUrl: vi.fn(),
setModelName: vi.fn(),
clearSettings: vi.fn(),
};
// Mock the settings store with stable references
vi.mock('@/store/use-settings', () => ({
useApiKey: vi.fn(() => 'sk-test-key'),
useBaseUrl: vi.fn(() => 'https://api.openai.com/v1'),
useModelName: vi.fn(() => 'gpt-4o'),
useSettingsActions: vi.fn(() => mockActions),
}));
// Mock the connection status component
vi.mock('./connection-status', () => ({
ConnectionStatus: vi.fn(() => <div data-testid="connection-status" />),
}));
// Mock SettingsService to avoid real API calls
vi.mock('@/services/settings-service', () => ({
SettingsService: {
saveProviderSettingsWithValidation: vi.fn(),
},
}));
// Mock toast
vi.mock('@/hooks/use-toast', () => ({
toast: vi.fn(),
}));
describe('ProviderForm Component - Validation Enhancement (Story 4.2)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render all form fields with correct labels', () => {
render(<ProviderForm />);
expect(screen.getByLabelText(/base url/i)).toBeInTheDocument();
expect(screen.getByLabelText(/model name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^api key$/i)).toBeInTheDocument();
});
it('should update values when user types', async () => {
render(<ProviderForm />);
const urlInput = screen.getByLabelText(/base url/i);
const modelInput = screen.getByLabelText(/model name/i);
const keyInput = screen.getByLabelText(/^api key$/i);
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'https://api.custom.com/v1');
await userEvent.clear(modelInput);
await userEvent.type(modelInput, 'custom-model');
await userEvent.clear(keyInput);
await userEvent.type(keyInput, 'sk-custom-key');
// Verify actions were called (form uses controlled inputs from store)
expect(mockActions.setBaseUrl).toHaveBeenCalled();
expect(mockActions.setModelName).toHaveBeenCalled();
expect(mockActions.setApiKey).toHaveBeenCalled();
});
it('should show/hide API key when toggle is clicked', async () => {
render(<ProviderForm />);
const keyInput = screen.getByLabelText(/^api key$/i) as HTMLInputElement;
const toggleButton = screen.getByRole('button', { name: /show/i });
// Initially password type
expect(keyInput.type).toBe('password');
await userEvent.click(toggleButton);
// Now text type
expect(keyInput.type).toBe('text');
// Click again to hide
const hideButton = screen.getByRole('button', { name: /hide/i });
await userEvent.click(hideButton);
expect(keyInput.type).toBe('password');
});
it('should display provider preset buttons', () => {
render(<ProviderForm />);
expect(screen.getByRole('button', { name: 'OpenAI' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'DeepSeek' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'OpenRouter' })).toBeInTheDocument();
});
it('should fill form when provider preset is selected', async () => {
render(<ProviderForm />);
const deepseekButton = screen.getByRole('button', { name: 'DeepSeek' });
await userEvent.click(deepseekButton);
// Verify preset selection calls the correct actions
expect(mockActions.setBaseUrl).toHaveBeenCalledWith('https://api.deepseek.com/v1');
expect(mockActions.setModelName).toHaveBeenCalledWith('deepseek-chat');
});
it('should display connection status component', () => {
render(<ProviderForm />);
expect(screen.getByTestId('connection-status')).toBeInTheDocument();
});
it('should show helper text for each field', () => {
render(<ProviderForm />);
expect(screen.getByText(/api endpoint url/i)).toBeInTheDocument();
expect(screen.getByText(/model identifier/i)).toBeInTheDocument();
// "stored locally" appears in both helper text and card description
expect(screen.getAllByText(/stored locally/i).length).toBeGreaterThan(0);
});
it('should handle empty API key gracefully', () => {
render(<ProviderForm />);
// When API key is empty, ConnectionStatus should still render
expect(screen.getByTestId('connection-status')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,194 @@
/**
* Tests for ProviderList Component
* Story 4.4: Provider Switching
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProviderList } from './provider-list';
import { useSettingsStore } from '@/store/use-settings';
import type { ProviderProfile } from '@/types/settings';
describe('ProviderList Component', () => {
beforeEach(() => {
// Reset store state before each test
useSettingsStore.getState().actions.clearSettings();
useSettingsStore.setState({
providerMigrationState: {
hasMigrated: false,
},
savedProviders: [],
activeProviderId: null,
});
});
const mockProviders: ProviderProfile[] = [
{
id: 'provider-1',
name: 'OpenAI GPT-4',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-openai-key',
modelName: 'gpt-4o',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
},
{
id: 'provider-2',
name: 'DeepSeek Chat',
baseUrl: 'https://api.deepseek.com/v1',
apiKey: 'sk-deepseek-key',
modelName: 'deepseek-chat',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
},
];
const renderProviderList = (props = {}) => {
return render(<ProviderList {...props} />);
};
describe('rendering', () => {
it('renders empty state when no providers exist', () => {
renderProviderList();
// Should show empty state message
expect(screen.getByText(/no providers configured/i)).toBeTruthy();
});
it('renders all saved providers', () => {
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList();
expect(screen.getByText('OpenAI GPT-4')).toBeTruthy();
expect(screen.getByText('DeepSeek Chat')).toBeTruthy();
});
it('highlights active provider', () => {
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList();
const activeProvider = screen.getByText('OpenAI GPT-4').closest('[data-active]');
expect(activeProvider).toHaveAttribute('data-active', 'true');
});
it('shows non-active provider without active highlight', () => {
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList();
const inactiveProvider = screen.getByText('DeepSeek Chat').closest('[data-active]');
expect(inactiveProvider).toHaveAttribute('data-active', 'false');
});
});
describe('user interactions', () => {
it('calls onSelectProvider when provider is clicked', async () => {
const onSelectProvider = vi.fn();
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList({ onSelectProvider });
fireEvent.click(screen.getByText('DeepSeek Chat'));
expect(onSelectProvider).toHaveBeenCalledWith('provider-2');
});
it('calls onEditProvider when edit button is clicked', () => {
const onEditProvider = vi.fn();
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList({ onEditProvider });
const editButtons = screen.getAllByRole('button', { name: /edit/i });
expect(editButtons).toHaveLength(2);
});
it('calls onDeleteProvider when delete button is clicked', () => {
const onDeleteProvider = vi.fn();
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList({ onDeleteProvider });
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
expect(deleteButtons).toHaveLength(2);
});
it('shows Add New Provider button when provided', () => {
const onAddProvider = vi.fn();
renderProviderList({ onAddProvider });
expect(screen.getByRole('button', { name: /add new provider/i })).toBeTruthy();
});
});
describe('provider information display', () => {
it('shows provider name as primary label', () => {
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList();
expect(screen.getByText('OpenAI GPT-4')).toBeTruthy();
});
it('shows model name as secondary info', () => {
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
});
renderProviderList();
expect(screen.getByText(/gpt-4o/i)).toBeTruthy();
});
});
describe('atomic selectors usage', () => {
it('uses atomic selectors to prevent re-renders', () => {
let renderCount = 0;
const TestWrapper = () => {
const savedProviders = useSettingsStore((s) => s.savedProviders);
renderCount++;
return <ProviderList savedProviders={savedProviders} />;
};
render(<TestWrapper />);
const initialCount = renderCount;
// Update unrelated state - should not cause re-render of TestWrapper
act(() => {
useSettingsStore.getState().actions.setBaseUrl('https://different.com');
});
expect(renderCount).toBe(initialCount);
});
});
});

View File

@@ -0,0 +1,112 @@
/**
* ProviderList Component
* Story 4.4: Provider Switching
*
* Displays a list of saved provider profiles with active state highlighting
*/
import { useSettingsStore, useSavedProviders, useActiveProviderId } from '@/store/use-settings';
import type { ProviderProfile } from '@/types/settings';
interface ProviderListProps {
savedProviders?: ProviderProfile[];
activeProviderId?: string | null;
onSelectProvider?: (providerId: string) => void;
onEditProvider?: (providerId: string) => void;
onDeleteProvider?: (providerId: string) => void;
onAddProvider?: () => void;
}
export function ProviderList({
savedProviders,
activeProviderId,
onSelectProvider,
onEditProvider,
onDeleteProvider,
onAddProvider,
}: ProviderListProps) {
// Use atomic selectors from store if props not provided
const storeProviders = useSavedProviders();
const storeActiveId = useActiveProviderId();
const providers = savedProviders ?? storeProviders;
const activeId = activeProviderId ?? storeActiveId;
if (providers.length === 0) {
return (
<div className="text-center py-10 border-2 border-dashed border-slate-200 rounded-xl bg-slate-50/50">
<p className="text-muted-foreground font-medium mb-4">No providers configured</p>
{onAddProvider && (
<button
onClick={onAddProvider}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
Add New Provider
</button>
)}
</div>
);
}
return (
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.id}
data-active={provider.id === activeId ? 'true' : 'false'}
className={`p-4 rounded-xl border transition-all duration-200 bg-white ${provider.id === activeId
? 'border-primary shadow-sm ring-1 ring-primary/20'
: 'border-slate-200 hover:border-primary/30'
}`}
onClick={() => onSelectProvider?.(provider.id)}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0 w-full sm:w-auto">
<h3 className="font-semibold text-foreground text-base mb-0.5">{provider.name}</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-xs">{provider.modelName}</span>
<span className="truncate text-xs opacity-70">{provider.baseUrl}</span>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto justify-end">
{onEditProvider && (
<button
onClick={(e) => {
e.stopPropagation();
onEditProvider(provider.id);
}}
aria-label="Edit provider"
className="px-3 py-1.5 text-sm font-medium bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 hover:text-slate-900 transition-colors"
>
Edit
</button>
)}
{onDeleteProvider && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteProvider(provider.id);
}}
aria-label="Delete provider"
className="px-3 py-1.5 text-sm font-medium bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
Delete
</button>
)}
</div>
</div>
</div>
))}
{onAddProvider && (
<button
onClick={onAddProvider}
className="w-full px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-muted-foreground font-medium hover:border-primary/50 hover:text-primary hover:bg-primary/5 transition-all duration-200 flex items-center justify-center gap-2"
>
<span>+</span> Add New Provider
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
/**
* Tests for ProviderSelector Component
* Story 4.4: Provider Switching
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProviderSelector } from './provider-selector';
import { useSettingsStore } from '@/store/use-settings';
import { ProviderManagementService } from '@/services/provider-management-service';
import type { ProviderProfile } from '@/types/settings';
// Mock the service
vi.mock('@/services/provider-management-service', () => ({
ProviderManagementService: {
setActiveProvider: vi.fn(),
getActiveProvider: vi.fn(),
getAllProviders: vi.fn(),
},
}));
describe('ProviderSelector Component', () => {
const mockProviders: ProviderProfile[] = [
{
id: 'provider-1',
name: 'OpenAI GPT-4',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-openai-key',
modelName: 'gpt-4o',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
},
{
id: 'provider-2',
name: 'DeepSeek Chat',
baseUrl: 'https://api.deepseek.com/v1',
apiKey: 'sk-deepseek-key',
modelName: 'deepseek-chat',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
},
{
id: 'provider-3',
name: 'Claude',
baseUrl: 'https://api.anthropic.com/v1',
apiKey: 'sk-claude-key',
modelName: 'claude-3-opus',
createdAt: '2024-01-24T00:00:00.000Z',
updatedAt: '2024-01-24T00:00:00.000Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
// Reset store state before each test
useSettingsStore.setState({
savedProviders: mockProviders,
activeProviderId: 'provider-1',
providerMigrationState: {
hasMigrated: false,
},
});
// Setup default mock returns
vi.mocked(ProviderManagementService.getAllProviders).mockReturnValue(mockProviders);
vi.mocked(ProviderManagementService.getActiveProvider).mockReturnValue(mockProviders[0]);
});
describe('rendering', () => {
it('renders all providers as options', () => {
render(<ProviderSelector />);
expect(screen.getByText('OpenAI GPT-4')).toBeTruthy();
expect(screen.getByText('DeepSeek Chat')).toBeTruthy();
expect(screen.getByText('Claude')).toBeTruthy();
});
it('shows visual indicator for active provider', () => {
render(<ProviderSelector />);
const activeLabel = screen.getByText('OpenAI GPT-4').closest('[data-active]');
expect(activeLabel).toHaveAttribute('data-active', 'true');
});
it('shows empty state when no providers exist', () => {
useSettingsStore.setState({ savedProviders: [] });
vi.mocked(ProviderManagementService.getAllProviders).mockReturnValue([]);
render(<ProviderSelector />);
expect(screen.getByText(/no providers available/i)).toBeTruthy();
});
});
describe('user interactions', () => {
it('calls setActiveProvider when a provider is selected', async () => {
render(<ProviderSelector />);
const deepseekOption = screen.getByText('DeepSeek Chat');
fireEvent.click(deepseekOption);
await waitFor(() => {
expect(ProviderManagementService.setActiveProvider).toHaveBeenCalledWith('provider-2');
});
});
it('immediately switches active provider on selection', async () => {
render(<ProviderSelector />);
// Initially provider-1 is active
expect(useSettingsStore.getState().activeProviderId).toBe('provider-1');
// Click on provider-2
fireEvent.click(screen.getByText('DeepSeek Chat'));
await waitFor(() => {
expect(ProviderManagementService.setActiveProvider).toHaveBeenCalledWith('provider-2');
});
});
it('highlights newly selected provider after switch', async () => {
// Mock setActiveProvider to update the store
vi.mocked(ProviderManagementService.setActiveProvider).mockImplementation((id) => {
useSettingsStore.setState({ activeProviderId: id as string });
});
const { rerender } = render(<ProviderSelector />);
// Initially provider-1 is active
expect(useSettingsStore.getState().activeProviderId).toBe('provider-1');
// Click on provider-2
fireEvent.click(screen.getByText('DeepSeek Chat'));
await waitFor(() => {
expect(useSettingsStore.getState().activeProviderId).toBe('provider-2');
});
// Rerender to see the updated UI
rerender(<ProviderSelector />);
// Provider-2 should now be highlighted
const activeOption = screen.getByText('DeepSeek Chat').closest('[data-active]');
expect(activeOption).toHaveAttribute('data-active', 'true');
});
});
describe('accessibility', () => {
it('has proper role for accessibility', () => {
render(<ProviderSelector />);
const radioGroup = screen.getByRole('radiogroup');
expect(radioGroup).toBeTruthy();
expect(radioGroup).toHaveAttribute('aria-label', 'Select AI provider');
});
it('has proper labels for screen readers', () => {
render(<ProviderSelector />);
const radioGroup = screen.getByRole('radiogroup');
expect(radioGroup).toHaveAttribute('aria-label', 'Select AI provider');
});
});
});

View File

@@ -0,0 +1,80 @@
/**
* ProviderSelector Component
* Story 4.4: Provider Switching
*
* Provides a radio-button style interface for switching between providers
*/
import { useSavedProviders, useActiveProviderId } from '@/store/use-settings';
import { ProviderManagementService } from '@/services/provider-management-service';
export function ProviderSelector() {
const providers = useSavedProviders();
const activeId = useActiveProviderId();
const handleSelectProvider = (providerId: string) => {
ProviderManagementService.setActiveProvider(providerId);
};
if (providers.length === 0) {
return (
<div className="text-center py-4 text-gray-500" role="status">
<p>No providers available</p>
<p className="text-sm">Add a provider to get started.</p>
</div>
);
}
return (
<div role="radiogroup" aria-label="Select AI provider" className="grid gap-3">
{providers.map((provider) => (
<label
key={provider.id}
className={`flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-4 rounded-xl border cursor-pointer transition-all duration-200 bg-white ${provider.id === activeId
? 'border-primary shadow-sm ring-1 ring-primary/20'
: 'border-slate-200 hover:border-primary/30'
}`}
data-active={provider.id === activeId ? 'true' : 'false'}
>
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className={`flex items-center justify-center w-5 h-5 rounded-full border transition-colors shrink-0 ${provider.id === activeId
? 'border-primary bg-primary text-primary-foreground'
: 'border-slate-300 bg-white'
}`}>
{provider.id === activeId && <div className="w-2 h-2 rounded-full bg-white" />}
</div>
<div className="flex-1 min-w-0 sm:hidden">
<div className="font-semibold text-foreground text-sm">{provider.name}</div>
</div>
{provider.id === activeId && (
<span className="sm:hidden text-xs text-primary font-bold bg-primary/10 px-2 py-1 rounded-full ml-auto">
Active
</span>
)}
</div>
<div className="flex-1 min-w-0 w-full pl-8 sm:pl-0 hidden sm:block">
<div className="font-semibold text-foreground">{provider.name}</div>
<div className="text-sm text-muted-foreground truncate font-mono mt-0.5">
{provider.modelName}
</div>
</div>
<div className="flex-1 min-w-0 w-full pl-8 sm:pl-0 sm:hidden">
<div className="text-sm text-muted-foreground truncate font-mono">
{provider.modelName}
</div>
</div>
{provider.id === activeId && (
<span className="hidden sm:inline-block text-xs text-primary font-bold bg-primary/10 px-2 py-1 rounded-full shrink-0">
Active
</span>
)}
</label>
))}
</div>
);
}