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,62 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import SettingsPage from './page';
// Mock the settings store
const mockSetApiKey = vi.fn();
const mockSetBaseUrl = vi.fn();
const mockSetModelName = vi.fn();
const mockClearSettings = vi.fn();
vi.mock('@/store/use-settings', () => ({
useSettingsStore: () => ({
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
modelName: 'gpt-4-turbo-preview',
isConfigured: false,
actions: {
setApiKey: mockSetApiKey,
setBaseUrl: mockSetBaseUrl,
setModelName: mockSetModelName,
clearSettings: mockClearSettings,
},
}),
useApiKey: () => '',
useBaseUrl: () => 'https://api.openai.com/v1',
useModelName: () => 'gpt-4-turbo-preview',
useIsConfigured: () => false,
useSettingsActions: () => ({
setApiKey: mockSetApiKey,
setBaseUrl: mockSetBaseUrl,
setModelName: mockSetModelName,
clearSettings: mockClearSettings,
}),
}));
// Mock ProviderForm component
vi.mock('@/components/features/settings/provider-form', () => ({
ProviderForm: () => <div data-testid="provider-form">Provider Form</div>,
}));
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the settings page with heading', () => {
render(<SettingsPage />);
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(/manage your ai provider/i)).toBeInTheDocument();
});
it('renders the ProviderForm component', () => {
render(<SettingsPage />);
expect(screen.getByTestId('provider-form')).toBeInTheDocument();
});
it('has proper page title and description', () => {
render(<SettingsPage />);
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
expect(screen.getByText(/AI Provider/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { Plus, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ProviderList } from "@/components/features/settings/provider-list";
import { ProviderSelector } from "@/components/features/settings/provider-selector";
import { ProviderForm } from "@/components/features/settings/provider-form";
import { useSavedProviders } from "@/store/use-settings";
import { ProviderManagementService } from "@/services/provider-management-service";
import { toast } from "@/hooks/use-toast";
export default function SettingsPage() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
const providers = useSavedProviders();
const handleEditProvider = (id: string) => {
setEditingProviderId(id);
};
const handleDeleteProvider = (id: string) => {
if (confirm("Are you sure you want to delete this provider?")) {
ProviderManagementService.removeProviderProfile(id);
toast({
title: "Provider Deleted",
description: "The provider has been removed.",
});
}
};
const closeDialogs = () => {
setIsAddDialogOpen(false);
setEditingProviderId(null);
};
// Find the provider object being edited
const editingProvider = providers.find((p) => p.id === editingProviderId);
return (
<div className="min-h-screen bg-background py-8 sm:py-12 px-4 sm:px-6 lg:px-8 w-full">
<div className="max-w-2xl mx-auto space-y-8 sm:space-y-10">
{/* Header */}
<div className="space-y-4">
<Link
href="/"
className="inline-flex items-center text-sm font-medium text-slate-500 hover:text-primary transition-colors mb-2"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Home
</Link>
<div>
<h1 className="text-4xl font-bold tracking-tight text-slate-900 font-serif">Settings</h1>
<p className="mt-2 text-lg text-slate-600">
Manage your AI provider connections and preferences.
</p>
</div>
</div>
<div className="grid gap-8">
{/* Active Provider Section */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-slate-200">
<div className="h-8 w-1 bg-primary rounded-full"></div>
<h2 className="text-xl font-semibold text-slate-900 font-serif">Active Session Provider</h2>
</div>
<p className="text-sm text-slate-600 max-w-xl">
Select which AI provider handles your current venting session. This setting applies immediately to new messages.
</p>
<ProviderSelector />
</section>
{/* Manage Providers Section */}
<section className="space-y-6">
<div className="flex items-center justify-between pb-2 border-b border-slate-200">
<div className="flex items-center gap-2">
<div className="h-8 w-1 bg-slate-300 rounded-full"></div>
<h2 className="text-xl font-semibold text-slate-900 font-serif">Configuration</h2>
</div>
</div>
<div className="space-y-4">
<p className="text-sm text-slate-600 max-w-xl">
Configure connection details for your AI models. Keys are stored locally in your browser.
</p>
<ProviderList
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onAddProvider={() => setIsAddDialogOpen(true)}
/>
</div>
{/* Add Provider Dialog (Triggered by ProviderList) */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-white border-0 shadow-2xl">
<DialogHeader className="p-6 pb-2 bg-slate-50/50">
<DialogTitle className="text-2xl font-serif text-slate-900">Add New Provider</DialogTitle>
</DialogHeader>
<div className="p-6 pt-2">
<ProviderForm
mode="add"
onSave={() => {
closeDialogs();
}}
onCancel={closeDialogs}
/>
</div>
</DialogContent>
</Dialog>
</section>
</div>
{/* Edit Provider Dialog */}
<Dialog
open={!!editingProviderId}
onOpenChange={(open: boolean) => !open && closeDialogs()}
>
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-white border-0 shadow-2xl">
<DialogHeader className="p-6 pb-2 bg-slate-50/50">
<DialogTitle className="text-2xl font-serif text-slate-900">Edit Provider</DialogTitle>
</DialogHeader>
<div className="p-6 pt-2">
<ProviderForm
mode="edit"
provider={editingProvider}
onSave={closeDialogs}
onCancel={closeDialogs}
/>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect } from 'react';
import { ChatWindow } from '@/components/features/chat/chat-window';
import { ChatInput } from '@/components/features/chat/chat-input';
import { useSessionStore, useActiveSessionId, useTeacherStatus } from '@/store/use-session';
import { ChatService } from '@/services/chat-service';
import { toast } from 'sonner';
import { Button } from "@/components/ui/button";
import { DraftViewSheet } from "@/components/features/draft/DraftViewSheet";
import { useChatStore } from "@/lib/store/chat-store";
import { CheckCircle, Loader2, ArrowLeft, Sparkles } from "lucide-react";
import Link from "next/link";
export default function ChatPage() {
const activeSessionId = useActiveSessionId();
const teacherStatus = useTeacherStatus();
const { setActiveSession } = useSessionStore((s) => s.actions);
const isDrafting = useChatStore((s) => s.isDrafting);
// Initialize Session on Mount
useEffect(() => {
const initSession = async () => {
if (!activeSessionId) {
try {
const newSessionId = await ChatService.createSession();
setActiveSession(newSessionId);
} catch (error) {
console.error("Failed to create session:", error);
toast.error("Failed to start session. Check your database.");
}
}
};
initSession();
}, [activeSessionId, setActiveSession]);
const handleSend = async (message: string) => {
if (!activeSessionId) return;
try {
await ChatService.sendMessage(message, activeSessionId);
} catch (error: any) {
console.error(error);
if (error.message === 'AI Provider not configured') {
toast.error("Please configure your AI Provider in Settings", {
action: {
label: "Go to Settings",
onClick: () => window.location.href = '/settings'
}
});
} else {
toast.error("Failed to send message. Please check connection.");
}
}
};
const handleFinishSession = async () => {
if (!activeSessionId) return;
try {
toast.info("Generating your learning summary...");
// Ensure store has latest messages for this session
await useChatStore.getState().hydrate(activeSessionId);
// Trigger Ghostwriter
await useChatStore.getState().generateDraft(activeSessionId);
} catch (error) {
console.error("Failed to generate draft:", error);
toast.error("Failed to generate summary. Please try again.");
}
};
return (
<div className="flex flex-col h-screen bg-background">
{/* Session Header */}
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200 shrink-0">
<div className="flex items-center gap-3">
<Link href="/" className="text-slate-500 hover:text-slate-700 transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="font-medium text-slate-700">
Current Session
</div>
</div>
<Button
onClick={handleFinishSession}
disabled={isDrafting}
variant="default"
size="sm"
className="bg-indigo-600 hover:bg-indigo-700"
>
{isDrafting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Drafting...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Draft Post
</>
)}
</Button>
</div>
{/* Chat Messages - Scrollable Area */}
<div className="flex-1 overflow-hidden">
<ChatWindow sessionId={activeSessionId} />
</div>
<DraftViewSheet />
{/* Chat Input - Fixed at Bottom */}
<div className="shrink-0 bg-white border-t border-slate-200">
<ChatInput onSend={handleSend} isLoading={teacherStatus !== 'idle'} />
</div>
</div>
);
}

129
src/app/api/llm/route.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Vercel Edge Function for LLM API Proxy
*
* This Edge Function proxies requests to the LLM provider while keeping
* API keys secure on the server side. It implements the Edge Runtime for
* fast cold starts (<3s).
*
* Runtime: Edge (required by architecture)
* Environment variables:
* - OPENAI_API_KEY: OpenAI API key (required)
* - LLM_MODEL: Model to use (default: gpt-4o-mini)
* - LLM_TEMPERATURE: Temperature for responses (default: 0.7)
*/
import { NextRequest } from 'next/server';
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
// Edge Runtime is REQUIRED for this API route
export const runtime = 'edge';
/**
* POST handler for LLM requests
*
* Expects JSON body with:
* - prompt: The prompt to send to the LLM
* - stream: Optional boolean to enable streaming (default: true)
*
* Returns:
* - Streaming response if stream=true (default)
* - Complete response if stream=false
*/
export async function POST(request: NextRequest) {
try {
// Parse request body
const body = await request.json();
const { prompt, stream = true } = body as { prompt: string; stream?: boolean };
// Validate prompt
if (!prompt || typeof prompt !== 'string') {
return new Response(
JSON.stringify({
success: false,
error: {
code: 'INVALID_PROMPT',
message: 'Prompt is required and must be a string',
},
timestamp: new Date().toISOString(),
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Validate environment variables
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({
success: false,
error: {
code: 'MISSING_API_KEY',
message: 'Server configuration error: API key not found',
},
timestamp: new Date().toISOString(),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
// Get model configuration
const modelName = process.env.LLM_MODEL || 'gpt-4o-mini';
const temperature = parseFloat(process.env.LLM_TEMPERATURE || '0.7');
// Create OpenAI client with API key
const openaiClient = createOpenAI({
apiKey,
});
// Generate response using AI SDK
const result = streamText({
model: openaiClient(modelName),
prompt,
temperature,
});
// Return streaming response
if (stream) {
return result.toTextStreamResponse();
}
// For non-streaming, convert to text
const { text } = await result;
return new Response(
JSON.stringify({
success: true,
data: { text },
timestamp: new Date().toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
// Handle errors gracefully
console.error('LLM API Error:', error);
// Check for specific error types
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const isRateLimit = errorMessage.toLowerCase().includes('rate limit');
const isTimeout = errorMessage.toLowerCase().includes('timeout');
const isInvalidKey = errorMessage.toLowerCase().includes('invalid api key');
let errorCode = 'INTERNAL_ERROR';
if (isRateLimit) errorCode = 'RATE_LIMIT';
if (isTimeout) errorCode = 'TIMEOUT';
if (isInvalidKey) errorCode = 'INVALID_API_KEY';
return new Response(
JSON.stringify({
success: false,
error: {
code: errorCode,
message: `Failed to generate response: ${errorMessage}`,
},
timestamp: new Date().toISOString(),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

134
src/app/globals.css Normal file
View File

@@ -0,0 +1,134 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-inter);
--font-serif: var(--font-merriweather);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: calc(var(--radius));
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
/* Morning Mist Theme - Light Mode */
:root {
--radius: 0.625rem;
/* Backgrounds - Benjamin Moore White Heron (OC-57) */
--background: oklch(0.95 0.01 160);
/* White Heron ~ #EDF3F0 */
--foreground: oklch(0.25 0.01 270);
/* #334155 - Deep Slate */
--card: oklch(1 0 0);
/* #FFFFFF - Pure White */
--card-foreground: oklch(0.25 0.01 270);
/* Primary - Slate Blue */
--primary: oklch(0.45 0.03 270);
/* #64748B */
--primary-foreground: oklch(0.98 0 0);
/* Secondary */
--secondary: oklch(0.95 0.01 270);
/* #E2E8F0 */
--secondary-foreground: oklch(0.25 0.01 270);
/* Muted */
--muted: oklch(0.96 0.005 270);
--muted-foreground: oklch(0.55 0.01 270);
/* Accent */
--accent: oklch(0.95 0.01 270);
--accent-foreground: oklch(0.25 0.01 270);
/* Destructive */
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
/* Borders & Inputs */
--border: oklch(0.90 0.01 270);
/* #E2E8F0 */
--input: oklch(0.90 0.01 270);
--ring: oklch(0.45 0.03 270);
/* Chart colors */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
/* Dark Mode - Evening Mist */
.dark {
--background: oklch(0.15 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.20 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.20 0 0);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.70 0.02 270);
--primary-foreground: oklch(0.15 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.25 0 0);
--muted-foreground: oklch(0.70 0 0);
--accent: oklch(0.25 0 0);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

57
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { Inter, Merriweather } from "next/font/google";
import "./globals.css";
import { OfflineIndicator } from "../components/features/common";
import { InstallPrompt } from "../components/features/pwa/install-prompt";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
const merriweather = Merriweather({
variable: "--font-merriweather",
subsets: ["latin"],
weight: ["300", "400", "700", "900"],
display: "swap",
});
export const metadata: Metadata = {
title: "Test01 - Local-First Venting",
description: "Transform your struggles into personal branding content with AI-powered journaling",
// Story 3.4: PWA metadata
manifest: "/manifest.webmanifest", // Next.js 14+ convention
themeColor: "#64748B",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Test01",
},
icons: {
icon: [
{ url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
apple: [
{ url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
],
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${inter.variable} ${merriweather.variable} font-sans antialiased`}
>
{children}
<OfflineIndicator />
<InstallPrompt />
</body>
</html>
);
}

73
src/app/manifest.test.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Tests for PWA Manifest
*
* Story 3.4: Verify manifest configuration
*
* Note: Next.js 16 handles manifest.ts as a special route file.
* This test validates the structure by reading the source file directly.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('PWA Manifest', () => {
const manifestContent = readFileSync(
join(__dirname, 'manifest.ts'),
'utf-8'
);
describe('Required Manifest Properties', () => {
it('should have app name "Test01"', () => {
expect(manifestContent).toContain("name: 'Test01'");
});
it('should have short name "Test01"', () => {
expect(manifestContent).toContain("short_name: 'Test01'");
});
it('should have start_url set to root', () => {
expect(manifestContent).toContain("start_url: '/'");
});
it('should have display mode set to standalone', () => {
expect(manifestContent).toContain("display: 'standalone'");
});
it('should have portrait orientation', () => {
expect(manifestContent).toContain("orientation: 'portrait'");
});
});
describe('Theme Colors (Morning Mist Palette)', () => {
it('should have correct background_color', () => {
expect(manifestContent).toContain("background_color: '#F8FAFC'");
});
it('should have correct theme_color', () => {
expect(manifestContent).toContain("theme_color: '#64748B'");
});
});
describe('Icon Configuration', () => {
it('should have 192x192 icon', () => {
expect(manifestContent).toContain('/icons/icon-192x192.png');
expect(manifestContent).toContain("'192x192'");
});
it('should have 512x512 icon', () => {
expect(manifestContent).toContain('/icons/icon-512x512.png');
expect(manifestContent).toContain("'512x512'");
});
it('should have maskable purpose', () => {
expect(manifestContent).toContain("purpose: 'any maskable'");
});
});
describe('Description', () => {
it('should have a description', () => {
expect(manifestContent).toContain('description:');
});
});
});

40
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* PWA Manifest
*
* Story 3.4: Web App Manifest for PWA installability
*
* Generates a valid manifest.json with:
* - App name: "Test01"
* - Display mode: standalone
* - Icons for install prompt
* - Theme colors matching Morning Mist palette
*/
import { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Test01',
short_name: 'Test01',
description: 'Turn your daily learning struggles into polished content',
start_url: '/',
display: 'standalone',
background_color: '#F8FAFC',
theme_color: '#64748B',
orientation: 'portrait',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
};
}

82
src/app/page.test.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Mock scrollIntoView
Element.prototype.scrollIntoView = vi.fn();
// Mock history store
vi.mock('@/lib/store/history-store', () => ({
useHistoryStore: (selector?: Function) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
selectedDraft: null,
loadMore: vi.fn(),
refreshHistory: vi.fn(),
selectDraft: vi.fn(),
closeDetail: vi.fn(),
clearError: vi.fn()
};
return selector ? selector(state) : state;
},
}));
// Mock chat store for copyDraftToClipboard
vi.mock('@/lib/store/chat-store', () => ({
useChatStore: (selector?: Function) => {
const state = {
messages: [],
isLoading: false,
isTyping: false,
currentDraft: null,
showDraftView: false,
copyDraftToClipboard: vi.fn(),
hydrate: vi.fn(),
};
return selector ? selector(state) : state;
},
}));
// Mock Journal components
vi.mock('@/components/features/journal', () => ({
HistoryFeed: () => <div data-testid="history-feed">History Feed Mock</div>,
HistoryDetailSheet: () => <div data-testid="history-detail-sheet">Detail Sheet Mock</div>,
}));
import Home from './page';
describe('Home Page', () => {
it('renders page with My Journal header', () => {
render(<Home />);
expect(screen.getByText('My Journal')).toBeInTheDocument();
});
it('renders HistoryFeed component', () => {
render(<Home />);
expect(screen.getByTestId('history-feed')).toBeInTheDocument();
});
it('renders HistoryDetailSheet component', () => {
render(<Home />);
expect(screen.getByTestId('history-detail-sheet')).toBeInTheDocument();
});
it('renders FAB (Floating Action Button) for new vent', () => {
render(<Home />);
const fab = screen.getByRole('link', { name: /start new vent/i });
expect(fab).toBeInTheDocument();
expect(fab).toHaveAttribute('href', '/chat');
});
it('has correct page structure', () => {
const { container } = render(<Home />);
// Check for main containers
expect(container.querySelector('.bg-slate-50')).toBeInTheDocument();
expect(container.querySelector('header')).toBeInTheDocument();
expect(container.querySelector('main')).toBeInTheDocument();
});
});

60
src/app/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client';
/**
* Home Page
*
* Story 3.1: History Feed UI integration
*
* The home screen IS the history feed. Shows:
* - Header with "My Journal" title
* - History feed with lazy loading
* - FAB (Floating Action Button) to start new vent
* - HistoryDetailSheet for viewing past entries
*
* User Flow:
* - Returning users see their journal of completed posts
* - First-time users see empty state with CTA
* - FAB navigates to chat for new venting session
* - Tapping history card opens detail sheet
*/
import { HistoryFeed, HistoryDetailSheet } from '@/components/features/journal';
import { Plus, Settings } from 'lucide-react';
import Link from 'next/link';
export default function HomePage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-10 bg-white border-b border-slate-200 px-4 py-4 flex items-center justify-between">
<h1 className="text-2xl font-serif font-bold text-slate-800">
My Journal
</h1>
<Link
href="/settings"
className="p-2 text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition-colors"
aria-label="Settings"
>
<Settings className="w-5 h-5" aria-hidden="true" />
</Link>
</header>
{/* Main Content - History Feed */}
<main className="pb-24">
<HistoryFeed />
</main>
{/* Floating Action Button - New Vent */}
<Link
href="/chat"
className="fixed bottom-6 right-6 min-h-[56px] w-14 bg-slate-800 text-white rounded-full shadow-lg hover:bg-slate-700 transition-colors flex items-center justify-center"
aria-label="Start new vent"
>
<Plus className="w-6 h-6" aria-hidden="true" />
</Link>
{/* History Detail Sheet */}
<HistoryDetailSheet />
</div>
);
}

548
src/app/seed/page.tsx Normal file
View File

@@ -0,0 +1,548 @@
'use client';
import { useEffect, useState } from 'react';
import { db } from '@/lib/db';
import { v4 as uuidv4 } from 'uuid';
import type { DraftRecord } from '@/lib/db';
export default function SeedPage() {
const [status, setStatus] = useState<'idle' | 'seeding' | 'success' | 'error'>('idle');
const [message, setMessage] = useState('');
const [distribution, setDistribution] = useState<Array<{ week: string; count: number }>>([]);
const sampleDrafts: Omit<DraftRecord, 'id'>[] = [
// Week 5 - Current Week (3 drafts)
{
sessionId: uuidv4(),
title: 'Building a Scalable Microservices Architecture',
content: `# Building a Scalable Microservices Architecture
## Overview
In this article, we explore the key principles of designing and implementing a scalable microservices architecture that can handle millions of requests per day.
## Key Principles
1. **Service Independence**: Each microservice should be independently deployable
2. **Data Ownership**: Services own their data and expose it through APIs
3. **Resilience**: Build in circuit breakers and fallback mechanisms
4. **Observability**: Comprehensive logging and monitoring
## Implementation Strategy
- Start with a monolith and identify bounded contexts
- Extract services incrementally
- Use API gateways for routing and authentication
- Implement service mesh for inter-service communication
## Conclusion
Microservices aren't a silver bullet, but when done right, they provide the flexibility and scalability modern applications need.`,
tags: ['architecture', 'microservices', 'scalability'],
createdAt: new Date('2026-01-25T14:30:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-25T15:45:00').getTime(),
},
{
sessionId: uuidv4(),
title: 'The Future of AI in Software Development',
content: `# The Future of AI in Software Development
## Introduction
Artificial Intelligence is transforming how we write, test, and deploy code. This article examines the current state and future trends.
## Current Applications
- **Code Generation**: AI-powered IDEs and copilots
- **Bug Detection**: Automated code review and vulnerability scanning
- **Testing**: AI-generated test cases and test data
- **Documentation**: Automatic documentation generation
## Emerging Trends
1. Natural language to code conversion
2. Autonomous debugging and refactoring
3. Predictive performance optimization
4. AI-driven architecture recommendations
## Challenges
- Code quality and maintainability concerns
- Over-reliance on AI suggestions
- Security and privacy implications
- Need for human oversight and creativity
## Looking Ahead
The future isn't about replacing developers, but augmenting their capabilities with intelligent tools.`,
tags: ['ai', 'development', 'future-tech'],
createdAt: new Date('2026-01-23T10:15:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-23T11:30:00').getTime(),
},
{
sessionId: uuidv4(),
title: 'Mastering React Server Components',
content: `# Mastering React Server Components
## What Are Server Components?
React Server Components (RSC) represent a paradigm shift in how we build React applications, enabling server-side rendering with zero client-side JavaScript.
## Benefits
- **Performance**: Reduced bundle size and faster initial load
- **Data Fetching**: Direct database access without API layers
- **SEO**: Better search engine optimization
- **Security**: Sensitive logic stays on the server
## Key Concepts
\`\`\`jsx
// Server Component (default)
async function ProductList() {
const products = await db.products.findMany();
return <div>{products.map(p => <ProductCard key={p.id} {...p} />)}</div>;
}
// Client Component (opt-in)
'use client';
function InteractiveButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
\`\`\`
## Best Practices
1. Use Server Components by default
2. Add 'use client' only when needed (interactivity, hooks)
3. Pass serializable props between server and client
4. Leverage streaming for better UX
## Conclusion
Server Components are the future of React development, offering better performance and developer experience.`,
tags: ['react', 'server-components', 'web-development'],
createdAt: new Date('2026-01-21T16:00:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-21T17:20:00').getTime(),
},
// Week 4 (1 draft)
{
sessionId: uuidv4(),
title: 'Database Indexing Strategies for High Performance',
content: `# Database Indexing Strategies for High Performance
## Introduction
Proper indexing is crucial for database performance. This guide covers essential indexing strategies for modern applications.
## Types of Indexes
1. **B-Tree Indexes**: Default for most databases, great for range queries
2. **Hash Indexes**: Fast equality lookups
3. **Full-Text Indexes**: Optimized for text search
4. **Partial Indexes**: Index only a subset of rows
5. **Covering Indexes**: Include all columns needed for a query
## When to Index
- Columns used in WHERE clauses
- Foreign key columns
- Columns used in JOIN operations
- Columns used in ORDER BY and GROUP BY
## When NOT to Index
- Small tables (full scan is faster)
- Columns with low cardinality
- Frequently updated columns
- Tables with heavy write operations
## Monitoring and Optimization
\`\`\`sql
-- Find unused indexes
SELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Analyze query performance
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
\`\`\`
## Conclusion
Indexing is a balancing act between read and write performance. Monitor, measure, and optimize based on your actual workload.`,
tags: ['database', 'performance', 'indexing'],
createdAt: new Date('2026-01-18T09:45:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-18T10:30:00').getTime(),
},
// Week 3 (1 draft)
{
sessionId: uuidv4(),
title: 'Building Resilient Distributed Systems',
content: `# Building Resilient Distributed Systems
## The Challenge
Distributed systems introduce complexity: network failures, partial failures, eventual consistency, and more.
## Core Principles
### 1. Embrace Failure
- Assume everything will fail
- Design for graceful degradation
- Implement circuit breakers
### 2. Idempotency
Ensure operations can be safely retried:
\`\`\`typescript
// Bad: Not idempotent
function incrementCounter(userId: string) {
const count = getCount(userId);
setCount(userId, count + 1);
}
// Good: Idempotent
function setCounter(userId: string, value: number) {
setCount(userId, value);
}
\`\`\`
### 3. Timeouts and Retries
- Set aggressive timeouts
- Use exponential backoff
- Implement retry budgets
### 4. Observability
- Distributed tracing
- Structured logging
- Metrics and alerting
## Patterns
- **Saga Pattern**: Manage distributed transactions
- **CQRS**: Separate read and write models
- **Event Sourcing**: Store state changes as events
- **Bulkhead Pattern**: Isolate resources
## Conclusion
Building distributed systems is hard, but following these principles makes them manageable and reliable.`,
tags: ['distributed-systems', 'resilience', 'architecture'],
createdAt: new Date('2026-01-10T13:20:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-10T14:45:00').getTime(),
},
// Week 2 (1 draft)
{
sessionId: uuidv4(),
title: 'Modern CSS: From Flexbox to Container Queries',
content: `# Modern CSS: From Flexbox to Container Queries
## Evolution of CSS Layout
CSS has evolved dramatically. Let's explore modern layout techniques that make responsive design easier.
## Flexbox
Perfect for one-dimensional layouts:
\`\`\`css
.container {
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
}
\`\`\`
## Grid
Two-dimensional layouts made simple:
\`\`\`css
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
\`\`\`
## Container Queries
The future of responsive design:
\`\`\`css
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}
\`\`\`
## Custom Properties
Dynamic theming:
\`\`\`css
:root {
--primary-color: #3b82f6;
--spacing-unit: 0.5rem;
}
.button {
background: var(--primary-color);
padding: calc(var(--spacing-unit) * 2);
}
\`\`\`
## Conclusion
Modern CSS provides powerful tools for creating responsive, maintainable designs without heavy JavaScript frameworks.`,
tags: ['css', 'web-design', 'responsive'],
createdAt: new Date('2026-01-03T11:00:00').getTime(),
status: 'completed',
completedAt: new Date('2026-01-03T12:15:00').getTime(),
},
// Week 1 (1 draft)
{
sessionId: uuidv4(),
title: 'TypeScript Best Practices for Large Codebases',
content: `# TypeScript Best Practices for Large Codebases
## Introduction
TypeScript shines in large projects. Here are battle-tested practices for maintaining type safety at scale.
## Strict Mode
Always enable strict mode:
\`\`\`json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}
}
\`\`\`
## Type Organization
\`\`\`typescript
// ✅ Good: Centralized types
// types/user.ts
export interface User {
id: string;
email: string;
profile: UserProfile;
}
export type UserRole = 'admin' | 'user' | 'guest';
// ❌ Bad: Scattered inline types
function getUser(): { id: string; email: string } { }
\`\`\`
## Utility Types
Leverage built-in utilities:
\`\`\`typescript
type PartialUser = Partial<User>;
type ReadonlyUser = Readonly<User>;
type UserKeys = keyof User;
type UserEmail = Pick<User, 'email'>;
type UserWithoutId = Omit<User, 'id'>;
\`\`\`
## Discriminated Unions
Type-safe state management:
\`\`\`typescript
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function handleState<T>(state: AsyncState<T>) {
switch (state.status) {
case 'success':
return state.data; // TypeScript knows data exists
case 'error':
return state.error; // TypeScript knows error exists
}
}
\`\`\`
## Avoid Type Assertions
\`\`\`typescript
// ❌ Bad
const user = data as User;
// ✅ Good: Use type guards
function isUser(data: unknown): data is User {
return typeof data === 'object' && data !== null && 'id' in data;
}
if (isUser(data)) {
console.log(data.id); // Type-safe
}
\`\`\`
## Conclusion
TypeScript's type system is powerful. Use it properly to catch bugs at compile time, not runtime.`,
tags: ['typescript', 'best-practices', 'programming'],
createdAt: new Date('2025-12-27T15:30:00').getTime(),
status: 'completed',
completedAt: new Date('2025-12-27T16:45:00').getTime(),
},
];
const seedDatabase = async () => {
try {
setStatus('seeding');
setMessage('Starting database seed...');
// Clear existing drafts
const existingCount = await db.drafts.count();
if (existingCount > 0) {
setMessage(`Found ${existingCount} existing drafts. Clearing...`);
await db.drafts.clear();
}
// Insert sample drafts
setMessage(`Inserting ${sampleDrafts.length} sample drafts...`);
for (const draft of sampleDrafts) {
await db.drafts.add(draft);
}
// Verify insertion
const totalDrafts = await db.drafts.count();
setMessage(`Successfully seeded ${totalDrafts} drafts!`);
// Calculate distribution by week
const allDrafts = await db.drafts.orderBy('createdAt').reverse().toArray();
const weekGroups = new Map<string, number>();
allDrafts.forEach(draft => {
const date = new Date(draft.createdAt);
const weekStart = getWeekStart(date);
const weekKey = weekStart.toISOString().split('T')[0];
weekGroups.set(weekKey, (weekGroups.get(weekKey) || 0) + 1);
});
const dist = Array.from(weekGroups.entries()).map(([week, count]) => ({
week,
count,
}));
setDistribution(dist);
setStatus('success');
} catch (error) {
console.error('Error seeding database:', error);
setMessage(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setStatus('error');
}
};
const getWeekStart = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
return new Date(d.setDate(diff));
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-xl p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Database Seeder</h1>
<p className="text-gray-600 mb-8">
Populate your database with 7 sample drafts distributed across 5 weeks
</p>
{status === 'idle' && (
<button
onClick={seedDatabase}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200"
>
Seed Database
</button>
)}
{status === 'seeding' && (
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mb-4"></div>
<p className="text-gray-700">{message}</p>
</div>
)}
{status === 'success' && (
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-6 h-6 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<p className="text-green-800 font-semibold">{message}</p>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h2 className="font-semibold text-gray-900 mb-3">Distribution by Week:</h2>
<div className="space-y-2">
{distribution.map(({ week, count }) => (
<div key={week} className="flex justify-between items-center">
<span className="text-gray-700">Week of {week}</span>
<span className="bg-indigo-100 text-indigo-800 px-3 py-1 rounded-full text-sm font-medium">
{count} draft{count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => window.location.href = '/'}
className="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200"
>
Go to App
</button>
<button
onClick={() => {
setStatus('idle');
setMessage('');
setDistribution([]);
}}
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold py-3 px-6 rounded-lg transition-colors duration-200"
>
Seed Again
</button>
</div>
</div>
)}
{status === 'error' && (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-6 h-6 text-red-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<p className="text-red-800 font-semibold">{message}</p>
</div>
</div>
<button
onClick={() => {
setStatus('idle');
setMessage('');
}}
className="w-full bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold py-3 px-6 rounded-lg transition-colors duration-200"
>
Try Again
</button>
</div>
)}
</div>
<div className="mt-6 bg-white rounded-lg shadow p-6">
<h2 className="font-semibold text-gray-900 mb-2">What This Does:</h2>
<ul className="space-y-2 text-gray-700 text-sm">
<li className="flex items-start">
<span className="text-indigo-600 mr-2"></span>
<span>Creates 7 sample drafts with realistic content</span>
</li>
<li className="flex items-start">
<span className="text-indigo-600 mr-2"></span>
<span>Distributes them across 5 weeks (3 in current week, 1 in each of the previous 4 weeks)</span>
</li>
<li className="flex items-start">
<span className="text-indigo-600 mr-2"></span>
<span>Perfect for testing week-based grouping in your app</span>
</li>
<li className="flex items-start">
<span className="text-indigo-600 mr-2"></span>
<span>Clears existing drafts before seeding (be careful!)</span>
</li>
</ul>
</div>
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
data-testid="alert-dialog-overlay"
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn('inline-flex h-10 min-h-[44px] items-center justify-center rounded-md bg px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
'mt-2 sm:mt-0 inline-flex h-10 min-h-[44px] items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

95
src/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,95 @@
// Simplified version of shadcn use-toast for unblocking
import { useState, useEffect } from "react"
export interface Toast {
id: string
title?: string
description?: string
action?: React.ReactNode
variant?: "default" | "destructive"
}
type ToastProps = Omit<Toast, "id">
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType =
| { type: "ADD_TOAST"; toast: Toast }
| { type: "DISMISS_TOAST"; toastId?: string }
| { type: "REMOVE_TOAST"; toastId?: string }
interface State {
toasts: Toast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
// Simple event emitter for toast updates
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: ActionType) {
switch (action.type) {
case "ADD_TOAST":
memoryState = { ...memoryState, toasts: [action.toast, ...memoryState.toasts].slice(0, 1) } // Limit to 1 for simplicity
break
case "DISMISS_TOAST":
case "REMOVE_TOAST":
const { toastId } = action
if (toastId) {
memoryState = { ...memoryState, toasts: memoryState.toasts.filter((t) => t.id !== toastId) }
} else {
memoryState = { ...memoryState, toasts: [] }
}
break
}
listeners.forEach((listener) => listener(memoryState))
}
function toast({ ...props }: ToastProps) {
const id = genId()
const update = (props: ToastProps) =>
dispatch({ type: "ADD_TOAST", toast: { ...props, id } })
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = useState<State>(memoryState)
useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useChatStore } from '../lib/store/chat-store';
import { LLMService } from '../services/llm-service';
import { ChatService } from '../services/chat-service';
import { DraftService } from '../lib/db/draft-service';
import { db } from '../lib/db';
describe('Fast Track Integration Tests', () => {
beforeEach(async () => {
await db.delete();
await db.open();
useChatStore.setState({
messages: [],
isLoading: false,
isProcessing: false,
isFastTrack: false,
currentIntent: null
});
vi.restoreAllMocks();
});
afterEach(async () => {
await db.delete();
});
it('should toggle Fast Track mode correctly', () => {
const { toggleFastTrack } = useChatStore.getState();
expect(useChatStore.getState().isFastTrack).toBe(false);
toggleFastTrack();
expect(useChatStore.getState().isFastTrack).toBe(true);
toggleFastTrack();
expect(useChatStore.getState().isFastTrack).toBe(false);
});
it('should bypass Teacher Agent logic when Fast Track is active', async () => {
// Spy on LLM service to ensure it's NOT called
const llmSpy = vi.spyOn(LLMService, 'getTeacherResponseStream');
// Mock ChatService.generateGhostwriterDraftStream to simulate success callback
const ghostwriterSpy = vi.spyOn(ChatService, 'generateGhostwriterDraftStream').mockImplementation(
async (_history, _intent, _sid, callbacks) => {
callbacks.onToken('# Test Draft');
await callbacks.onComplete({
id: 123,
sessionId: 'test-session',
title: 'Test Draft',
content: '# Test Draft\n\nThis is a test.',
tags: ['test'],
createdAt: Date.now(),
status: 'draft'
});
}
);
// Enable Fast Track
useChatStore.getState().toggleFastTrack();
// Send message
await useChatStore.getState().addMessage("Here is my direct insight", 'user');
// Verify LLM service was NOT called
expect(llmSpy).not.toHaveBeenCalled();
// Verify Ghostwriter was called
expect(ghostwriterSpy).toHaveBeenCalledWith(
expect.any(Array),
'insight',
expect.any(String),
expect.any(Object)
);
// Verify results
const { messages, isProcessing, currentIntent, currentDraft } = useChatStore.getState();
expect(isProcessing).toBe(false);
expect(currentIntent).toBe('insight'); // Fast track assumes insight
expect(currentDraft).not.toBeNull();
expect(currentDraft?.id).toBe(123);
expect(messages.length).toBeGreaterThanOrEqual(2);
expect(messages[1].role).toBe('assistant');
expect(messages[1].content).toContain("Draft ready!");
expect(messages[1].intent).toBe('insight');
// Restore mocks
ghostwriterSpy.mockRestore();
});
it('should resume normal Teacher Agent flow when Fast Track is disabled', async () => {
// Spy on LLM service to ensure it IS called
const llmSpy = vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (_input, _history, callbacks) => {
if (callbacks.onComplete) await callbacks.onComplete("Normal response");
}
);
// Fast Track is disabled by default
await useChatStore.getState().addMessage("Venting here", 'user');
// Verify LLM service WAS called
expect(llmSpy).toHaveBeenCalled();
const { messages } = useChatStore.getState();
expect(messages[1].content).toBe("Normal response");
});
});

View File

@@ -0,0 +1,151 @@
/**
* Integration Tests: Offline Sync End-to-End
* Story 3.3
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../lib/db';
import { SyncManager } from '../services/sync-manager';
import { useOfflineStore } from '../lib/store/offline-store';
describe('Story 3.3: Offline Sync End-to-End Integration', () => {
beforeEach(async () => {
// Clear all tables before each test
await db.syncQueue.clear();
await db.drafts.clear();
await db.chatLogs.clear();
});
describe('Offline -> Online Sync Flow', () => {
it('should process queued actions when connection restored', async () => {
// Create a draft first
const draftId = await db.drafts.add({
sessionId: 'test-session',
title: 'Test Draft to Delete',
content: 'Test content',
tags: [],
createdAt: Date.now(),
status: 'draft',
});
// Queue delete action while "offline"
await SyncManager.queueAction('deleteDraft', { draftId });
// Verify draft exists and queue has item
expect(await db.drafts.get(draftId)).toBeDefined();
const pendingBefore = await db.syncQueue.where('status').equals('pending').count();
expect(pendingBefore).toBe(1);
// Process queue (simulates coming back online)
await SyncManager.processQueue();
// Draft should be deleted
expect(await db.drafts.get(draftId)).toBeUndefined();
// Queue should be empty
const pendingAfter = await db.syncQueue.where('status').equals('pending').count();
expect(pendingAfter).toBe(0);
});
it('should update offline store when going online/offline', async () => {
// Add a pending item
await SyncManager.queueAction('saveDraft', {
draftData: {
sessionId: 'test',
title: 'Test',
content: 'Test',
tags: [],
createdAt: Date.now(),
status: 'draft',
},
});
// Simulate going offline
useOfflineStore.setState({ isOnline: false });
expect(useOfflineStore.getState().isOnline).toBe(false);
// Simulate going online - should update pending count
useOfflineStore.setState({ isOnline: true });
await useOfflineStore.getState().updatePendingCount();
expect(useOfflineStore.getState().isOnline).toBe(true);
expect(useOfflineStore.getState().pendingCount).toBe(1);
});
});
describe('Error Handling', () => {
it('should handle sync failures gracefully', async () => {
// Queue an action that will fail (non-existent draft)
await SyncManager.queueAction('deleteDraft', { draftId: 999 });
// Process queue 3 times (simulating 3 reconnection attempts)
// Each attempt will retry once, incrementing the retry counter
for (let i = 0; i < 3; i++) {
await expect(SyncManager.processQueue()).resolves.not.toThrow();
}
// Item should be marked as failed after 3 retries
const items = await db.syncQueue.toArray();
expect(items).toHaveLength(1);
expect(items[0].status).toBe('failed');
expect(items[0].retries).toBe(3);
});
it('should store error message for failed items', async () => {
// Queue a delete action that will fail (draft doesn't exist)
await db.syncQueue.add({
action: 'deleteDraft',
payload: { draftId: 999 },
status: 'pending',
createdAt: Date.now(),
retries: 0,
});
// Process queue 3 times to exhaust retries
for (let i = 0; i < 3; i++) {
await SyncManager.processQueue();
}
const items = await db.syncQueue.toArray();
expect(items.length).toBeGreaterThan(0);
expect(items[0].lastError).toBeDefined();
});
});
describe('Multiple Actions in Queue', () => {
it('should process multiple actions in order', async () => {
// Create drafts
const draft1 = await db.drafts.add({
sessionId: 's1',
title: 'Draft 1',
content: 'Content 1',
tags: [],
createdAt: Date.now(),
status: 'draft',
});
const draft2 = await db.drafts.add({
sessionId: 's2',
title: 'Draft 2',
content: 'Content 2',
tags: [],
createdAt: Date.now(),
status: 'draft',
});
// Queue actions with slight delay to ensure order
await SyncManager.queueAction('deleteDraft', { draftId: draft1 });
await new Promise(resolve => setTimeout(resolve, 10));
await SyncManager.queueAction('deleteDraft', { draftId: draft2 });
const draftsBefore = await db.drafts.toArray();
expect(draftsBefore).toHaveLength(2);
await SyncManager.processQueue();
// Both drafts should be deleted
const draftsAfter = await db.drafts.toArray();
expect(draftsAfter).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,355 @@
/**
* Integration Tests: Teacher Agent Flow
*
* Tests the complete flow from user message → intent detection → prompt generation → LLM response.
* These tests verify the integration between all components of the Teacher Agent system.
*
* Test Scope:
* - Full flow: User message → Store → Service → LLM → Response
* - Intent classification with prompt context
* - Error handling across the entire pipeline
* - State management throughout the flow
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useChatStore } from '../lib/store/chat-store';
import { LLMService } from '../services/llm-service';
import { classifyIntent } from '../lib/llm/intent-detector';
import { generateTeacherPrompt } from '../lib/llm/prompt-engine';
import { db } from '../lib/db';
import type { ChatMessage } from '../lib/db';
describe('Teacher Agent Integration Tests', () => {
beforeEach(async () => {
await db.delete();
await db.open();
useChatStore.setState({ messages: [], isLoading: false, isProcessing: false, currentIntent: null });
vi.restoreAllMocks();
});
afterEach(async () => {
await db.delete();
});
describe('Complete Flow: Venting Intent', () => {
it('should classify venting intent correctly and generate empathetic prompt', () => {
const userInput = "I'm so frustrated with this bug, I can't figure it out";
const chatHistory: ChatMessage[] = [];
// Step 1: Classify intent
const intent = classifyIntent(userInput);
expect(intent).toBe('venting');
// Step 2: Generate prompt
const prompt = generateTeacherPrompt(userInput, chatHistory, intent);
expect(prompt).toContain('empath');
expect(prompt).toContain(userInput);
});
it('should flow through store with venting intent using streaming', async () => {
// Mock LLM service streaming response
// Mock LLM service streaming response
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (_input, _history, callbacks) => {
if (callbacks.onIntent) await callbacks.onIntent('venting');
if (callbacks.onToken) {
await callbacks.onToken("I hear ");
await callbacks.onToken("your frustration.");
}
if (callbacks.onComplete) await callbacks.onComplete("I hear your frustration.");
}
);
// Send user message through store
await useChatStore.getState().addMessage("I'm stuck on this bug", 'user');
// Verify complete flow
const { messages, currentIntent, isProcessing } = useChatStore.getState();
// Should have user message + AI response
expect(messages.length).toBeGreaterThanOrEqual(2);
expect(messages[0]).toMatchObject({
role: 'user',
content: "I'm stuck on this bug",
});
expect(messages[1]).toMatchObject({
role: 'assistant',
content: "I hear your frustration.",
intent: 'venting',
});
expect(currentIntent).toBe('venting');
expect(isProcessing).toBe(false); // Should reset after completion
});
});
describe('Complete Flow: Insight Intent', () => {
it('should classify insight intent correctly and generate celebratory prompt', () => {
const userInput = "I finally figured it out! The trick was to use async/await";
const chatHistory: ChatMessage[] = [];
// Step 1: Classify intent
const intent = classifyIntent(userInput);
expect(intent).toBe('insight');
// Step 2: Generate prompt
const prompt = generateTeacherPrompt(userInput, chatHistory, intent);
expect(prompt).toContain('celebrat');
expect(prompt).toContain(userInput);
});
it('should flow through store with insight intent using streaming', async () => {
// Mock LLM service streaming response
// Mock LLM service streaming response
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (_input, _history, callbacks) => {
if (callbacks.onIntent) await callbacks.onIntent('insight');
if (callbacks.onToken) {
await callbacks.onToken("That's ");
await callbacks.onToken("awesome!");
}
if (callbacks.onComplete) await callbacks.onComplete("That's awesome!");
}
);
await useChatStore.getState().addMessage("I finally get it now", 'user');
const { messages, currentIntent } = useChatStore.getState();
expect(messages.length).toBeGreaterThanOrEqual(2);
expect(messages[0].role).toBe('user');
expect(messages[1].role).toBe('assistant');
expect(messages[1].intent).toBe('insight');
expect(messages[1].content).toBe("That's awesome!");
expect(currentIntent).toBe('insight');
});
});
describe('Complete Flow with Chat History', () => {
it('should include chat history in prompt generation', async () => {
// Set up initial conversation
await useChatStore.getState().addMessage("I'm having trouble with React hooks", 'user');
// Mock LLM service for first message
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementationOnce(
async (_input, _history, callbacks) => {
if (callbacks.onIntent) await callbacks.onIntent('venting');
if (callbacks.onComplete) await callbacks.onComplete("What specific hook are you struggling with?");
}
);
// Wait a bit for first response (mock is async)
await new Promise(resolve => setTimeout(resolve, 10));
// Mock for second message
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementationOnce(
async (_input, _history, callbacks) => {
if (callbacks.onIntent) await callbacks.onIntent('venting');
if (callbacks.onComplete) await callbacks.onComplete("Ah, useEffect can be tricky. What's your dependency array?");
}
);
// Send follow-up message
await useChatStore.getState().addMessage("It's useEffect, I don't understand the dependencies", 'user');
const { messages } = useChatStore.getState();
// Should have: user1, assistant1, user2, assistant2
expect(messages.length).toBeGreaterThanOrEqual(3);
expect(messages[0].content).toContain('React hooks');
expect(messages[messages.length - 1].role).toBe('assistant');
});
});
describe('Error Handling Integration', () => {
it('should handle LLM service errors gracefully', async () => {
// Mock LLM service error
// Mock LLM service error
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (_input, _history, callbacks) => {
if (callbacks.onError) {
await callbacks.onError({
success: false,
error: {
code: 'NETWORK_ERROR',
message: 'Could not connect to AI service',
}
});
}
}
);
await useChatStore.getState().addMessage("Test message", 'user');
const { messages, isProcessing } = useChatStore.getState();
// Should have user message + error message
expect(messages.length).toBeGreaterThanOrEqual(2);
expect(messages[0].role).toBe('user');
expect(messages[1].role).toBe('assistant');
expect(messages[1].content).toContain('Could not connect');
expect(isProcessing).toBe(false); // Should reset even on error
});
it('should handle unexpected errors with fallback', async () => {
// Mock LLM service to throw
vi.spyOn(LLMService, 'getTeacherResponseStream').mockRejectedValue(
new Error('Unexpected error')
);
await useChatStore.getState().addMessage("Test message", 'user');
const { messages, isProcessing } = useChatStore.getState();
// Should have user message + fallback error message
expect(messages.length).toBeGreaterThanOrEqual(2);
expect(messages[0].role).toBe('user');
expect(messages[1].role).toBe('assistant');
expect(isProcessing).toBe(false);
});
});
describe('State Management Integration', () => {
it('should set isProcessing and isTyping states correctly', async () => {
// Mock streaming response
const mockLLM = vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (prompt, history, callbacks) => {
// Simulate async processing
await new Promise(resolve => setTimeout(resolve, 10));
// 1. Detect Intent
if (callbacks.onIntent) {
await callbacks.onIntent('venting');
}
// 2. Stream tokens (SLOW DOWN to ensure test captures processing state)
const tokens = ['I', ' hear', ' you.'];
for (const token of tokens) {
await new Promise(resolve => setTimeout(resolve, 50));
if (callbacks.onToken) {
await callbacks.onToken(token);
}
}
// 3. Complete
if (callbacks.onComplete) {
await callbacks.onComplete('I hear you.');
}
}
);
const addPromise = useChatStore.getState().addMessage("Test", 'user');
// Check state initial state (Typing + Processing)
await new Promise(resolve => setTimeout(resolve, 10));
let { isProcessing, isTyping } = useChatStore.getState();
expect(isProcessing).toBe(true);
expect(isTyping).toBe(true);
// Wait for first token check (Typing stops, Processing continues)
// Wait for first token check (Typing stops, Processing continues)
// Wait 70ms - should have started streaming (first token at ~60ms) but not finished (total time ~160ms)
await new Promise(resolve => setTimeout(resolve, 70));
({ isProcessing, isTyping } = useChatStore.getState());
expect(isProcessing).toBe(true);
expect(isTyping).toBe(false); // Should be false after first token
// Wait for completion
await addPromise;
({ isProcessing, isTyping } = useChatStore.getState());
expect(isProcessing).toBe(false);
expect(isTyping).toBe(false);
mockLLM.mockRestore();
});
it('should reset state even on error', async () => {
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(
async (_input, _history, callbacks) => {
await new Promise(resolve => setTimeout(resolve, 10));
if (callbacks.onError) {
await callbacks.onError({
success: false,
error: { code: 'ERROR', message: 'Error' },
});
}
}
);
await useChatStore.getState().addMessage("Test", 'user');
const { isProcessing, isTyping } = useChatStore.getState();
expect(isProcessing).toBe(false);
expect(isTyping).toBe(false);
});
});
describe('Prompt-Response Integration', () => {
it('should generate appropriate prompts for different intents', () => {
const ventingInput = "This is so frustrating";
const insightInput = "I finally solved it";
const ventingIntent = classifyIntent(ventingInput);
const insightIntent = classifyIntent(insightInput);
const ventingPrompt = generateTeacherPrompt(ventingInput, [], ventingIntent);
const insightPrompt = generateTeacherPrompt(insightInput, [], insightIntent);
// Venting prompt should emphasize empathy
expect(ventingPrompt.toLowerCase()).toMatch(/empath|validate|support/);
// Insight prompt should emphasize celebration and deepening
expect(insightPrompt.toLowerCase()).toMatch(/celebrat|deepen|understand/);
// Prompts should be different
expect(ventingPrompt).not.toBe(insightPrompt);
});
});
describe('Accuracy Requirements', () => {
it('should maintain >85% accuracy on common venting patterns', () => {
const ventingPatterns = [
"I'm so frustrated",
"This is broken",
"I don't understand",
"I'm stuck",
"This error is driving me crazy",
"Why isn't this working?",
"I hate this bug",
"I'm confused about this",
"This makes no sense",
"I keep getting this error",
];
let correct = 0;
ventingPatterns.forEach(pattern => {
if (classifyIntent(pattern) === 'venting') correct++;
});
const accuracy = (correct / ventingPatterns.length) * 100;
expect(accuracy).toBeGreaterThanOrEqual(85);
});
it('should maintain >85% accuracy on common insight patterns', () => {
const insightPatterns = [
"I finally figured it out",
"I get it now",
"The solution is to use useEffect",
"I finally solved it",
"Now I understand",
"The trick was to add dependencies",
"I fixed it by changing the state",
"It's working now",
"I found the solution",
"This makes sense now",
];
let correct = 0;
insightPatterns.forEach(pattern => {
if (classifyIntent(pattern) === 'insight') correct++;
});
const accuracy = (correct / insightPatterns.length) * 100;
expect(accuracy).toBeGreaterThanOrEqual(85);
});
});
});

21
src/lib/db/db.ts Normal file
View File

@@ -0,0 +1,21 @@
import Dexie, { type EntityTable } from 'dexie';
import { IChatLog, ISession, ISyncEntry, IDraft } from './schema';
const DB_NAME = 'Test01_DB';
export const db = new Dexie(DB_NAME) as Dexie & {
chatLogs: EntityTable<IChatLog, 'id'>,
sessions: EntityTable<ISession, 'id'>,
drafts: EntityTable<IDraft, 'id'>,
syncQueue: EntityTable<ISyncEntry, 'id'>
};
// Schema definition
db.version(1).stores({
chatLogs: 'id, sessionId, timestamp', // Query by session or time
sessions: 'id, status, createdAt', // List active/completed
drafts: 'id, sessionId, status, createdAt, completedAt, [status+completedAt]', // Index for feeds
syncQueue: '++id, status' // Queue management
});
export type AppDatabase = typeof db;

View File

@@ -0,0 +1,543 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DraftService } from './draft-service';
import { db } from './index';
import { SyncManager } from '../../services/sync-manager';
// Mock SyncManager to prevent side effects in service tests
vi.mock('../../services/sync-manager', () => ({
SyncManager: {
isOnline: vi.fn(),
queueAction: vi.fn(),
},
}));
describe('DraftService', () => {
beforeEach(async () => {
await db.delete();
await db.open();
// Default to online for most tests
vi.mocked(SyncManager.isOnline).mockReturnValue(true);
vi.mocked(SyncManager.queueAction).mockResolvedValue(1);
});
afterEach(async () => {
await db.delete();
vi.clearAllMocks();
});
describe('saveDraft', () => {
it('should save a draft to IndexedDB', async () => {
const draft = {
sessionId: 'session-123',
title: 'My Learning Moment',
content: '# Today I learned about closures',
tags: ['javascript', 'learning'],
createdAt: Date.now(),
status: 'draft' as const,
};
const savedDraft = await DraftService.saveDraft(draft);
expect(savedDraft.id).toBeDefined();
expect(savedDraft.sessionId).toBe(draft.sessionId);
expect(savedDraft.title).toBe(draft.title);
expect(savedDraft.content).toBe(draft.content);
expect(savedDraft.tags).toEqual(draft.tags);
});
it('should queue action when offline', async () => {
vi.mocked(SyncManager.isOnline).mockReturnValue(false);
const draft = {
sessionId: 'offline-session',
title: 'Offline Draft',
content: 'Content',
tags: [],
createdAt: Date.now(),
status: 'draft' as const,
};
await DraftService.saveDraft(draft);
// Should still save locally
const saved = await db.drafts.where('sessionId').equals('offline-session').first();
expect(saved).toBeDefined();
// But ALSO queue the action
expect(SyncManager.queueAction).toHaveBeenCalledWith('saveDraft', expect.objectContaining({
draftData: expect.objectContaining({ title: 'Offline Draft' })
}));
});
it('should return a plain object (not Dexie observable)', async () => {
const draft = {
sessionId: 'session-123',
title: 'Test Draft',
content: '# Test',
tags: ['test'],
createdAt: Date.now(),
status: 'draft' as const,
};
const savedDraft = await DraftService.saveDraft(draft);
// Should be a plain object, not a Dexie observable
expect(savedDraft.constructor).toBe(Object);
});
});
describe('getDraftBySessionId', () => {
it('should retrieve draft by session ID', async () => {
const draft = {
sessionId: 'session-123',
title: 'My Draft',
content: '# Content',
tags: ['tag1'],
createdAt: Date.now(),
status: 'draft' as const,
};
await DraftService.saveDraft(draft);
const retrieved = await DraftService.getDraftBySessionId('session-123');
expect(retrieved).toBeDefined();
expect(retrieved?.sessionId).toBe('session-123');
expect(retrieved?.title).toBe('My Draft');
});
it('should return null for non-existent session', async () => {
const retrieved = await DraftService.getDraftBySessionId('non-existent');
expect(retrieved).toBeNull();
});
it('should return the latest draft if multiple exist for a session', async () => {
const timestamp = Date.now();
await DraftService.saveDraft({
sessionId: 'session-123',
title: 'First Draft',
content: '# First',
tags: ['tag1'],
createdAt: timestamp - 1000,
status: 'regenerated' as const,
});
await DraftService.saveDraft({
sessionId: 'session-123',
title: 'Second Draft',
content: '# Second',
tags: ['tag2'],
createdAt: timestamp,
status: 'draft' as const,
});
const retrieved = await DraftService.getDraftBySessionId('session-123');
expect(retrieved?.title).toBe('Second Draft');
expect(retrieved?.status).toBe('draft');
});
});
describe('getAllDrafts', () => {
it('should return all drafts ordered by creation date', async () => {
const timestamp = Date.now();
await DraftService.saveDraft({
sessionId: 'session-1',
title: 'Draft 1',
content: '# Content 1',
tags: ['tag1'],
createdAt: timestamp - 2000,
status: 'completed' as const,
});
await DraftService.saveDraft({
sessionId: 'session-2',
title: 'Draft 2',
content: '# Content 2',
tags: ['tag2'],
createdAt: timestamp - 1000,
status: 'draft' as const,
});
await DraftService.saveDraft({
sessionId: 'session-3',
title: 'Draft 3',
content: '# Content 3',
tags: ['tag3'],
createdAt: timestamp,
status: 'draft' as const,
});
const allDrafts = await DraftService.getAllDrafts();
expect(allDrafts).toHaveLength(3);
expect(allDrafts[0].title).toBe('Draft 3'); // Most recent first
expect(allDrafts[1].title).toBe('Draft 2');
expect(allDrafts[2].title).toBe('Draft 1');
});
it('should return empty array when no drafts exist', async () => {
const allDrafts = await DraftService.getAllDrafts();
expect(allDrafts).toEqual([]);
});
});
describe('updateDraftStatus', () => {
it('should update draft status', async () => {
const draft = await DraftService.saveDraft({
sessionId: 'session-123',
title: 'My Draft',
content: '# Content',
tags: ['tag1'],
createdAt: Date.now(),
status: 'draft' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
await DraftService.updateDraftStatus(draft.id, 'completed');
const updated = await DraftService.getDraftBySessionId('session-123');
expect(updated?.status).toBe('completed');
});
it('should handle non-existent draft ID gracefully', async () => {
const result = await DraftService.updateDraftStatus(99999, 'completed');
expect(result).toBe(false);
});
});
describe('deleteDraft', () => {
it('should delete a draft', async () => {
const draft = await DraftService.saveDraft({
sessionId: 'session-123',
title: 'To Delete',
content: '# Content',
tags: ['tag1'],
createdAt: Date.now(),
status: 'draft' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
const deleted = await DraftService.deleteDraft(draft.id);
expect(deleted).toBe(true);
const retrieved = await DraftService.getDraftBySessionId('session-123');
expect(retrieved).toBeNull();
});
it('should return false for non-existent draft', async () => {
const deleted = await DraftService.deleteDraft(99999);
expect(deleted).toBe(false);
});
// Story 3.2: Cascade delete tests
it('should delete associated chat logs when deleting draft (cascade delete)', async () => {
const sessionId = 'session-123';
// Create chat logs for the session with sessionId
await db.chatLogs.bulkAdd([
{ sessionId, role: 'user' as const, content: 'My message', timestamp: Date.now() },
{ sessionId, role: 'assistant' as const, content: 'AI response', timestamp: Date.now() },
]);
// Create draft
const draft = await DraftService.saveDraft({
sessionId,
title: 'To Delete',
content: '# Content',
tags: ['tag1'],
createdAt: Date.now(),
status: 'draft' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
// Verify chat logs exist
const chatLogsBefore = await db.chatLogs.where('sessionId').equals(sessionId).toArray();
expect(chatLogsBefore.length).toBe(2);
// Delete draft (should also delete chat logs)
await DraftService.deleteDraft(draft.id);
// Verify draft is deleted
const retrieved = await DraftService.getDraftById(draft.id);
expect(retrieved).toBeNull();
// Verify chat logs are also deleted (cascade)
const chatLogsAfter = await db.chatLogs.where('sessionId').equals(sessionId).toArray();
expect(chatLogsAfter.length).toBe(0);
});
it('should use transaction for atomic cascade delete', async () => {
const sessionId = 'session-123';
// Create chat logs with sessionId
await db.chatLogs.add({
sessionId,
role: 'user' as const,
content: 'Test message',
timestamp: Date.now(),
});
// Create draft
const draft = await DraftService.saveDraft({
sessionId,
title: 'Test Draft',
content: '# Test',
tags: [],
createdAt: Date.now(),
status: 'draft' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
// Delete should be atomic - both draft and chat logs deleted together
const result = await DraftService.deleteDraft(draft.id);
expect(result).toBe(true);
// Both should be gone (atomic operation)
const draftExists = await db.drafts.get(draft.id);
const chatLogsExist = await db.chatLogs.where('sessionId').equals(sessionId).toArray();
expect(draftExists).toBeUndefined();
expect(chatLogsExist.length).toBe(0);
});
it('should handle non-existent draft gracefully (idempotent)', async () => {
const sessionId = 'session-123';
// Create chat logs for the session with sessionId
await db.chatLogs.add({
sessionId,
role: 'user' as const,
content: 'My message',
timestamp: Date.now(),
});
// Try to delete non-existent draft
const result = await DraftService.deleteDraft(99999);
// Should return false but not throw
expect(result).toBe(false);
// Chat logs should still exist (draft didn't exist so nothing to cascade)
const chatLogs = await db.chatLogs.toArray();
expect(chatLogs.length).toBe(1);
});
});
describe('markAsCompleted', () => {
it('should mark a draft as completed with timestamp', async () => {
const draft = await DraftService.saveDraft({
sessionId: 'session-123',
title: 'My Learning Moment',
content: '# Today I learned',
tags: ['growth'],
createdAt: Date.now(),
status: 'draft' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
const beforeCompletion = Date.now();
const completed = await DraftService.markAsCompleted(draft.id);
expect(completed).not.toBeNull();
expect(completed?.status).toBe('completed');
expect(completed?.completedAt).toBeDefined();
expect(completed?.completedAt).toBeGreaterThanOrEqual(beforeCompletion);
});
it('should return null for non-existent draft', async () => {
const completed = await DraftService.markAsCompleted(99999);
expect(completed).toBeNull();
});
it('should return the same draft if already completed', async () => {
const draft = await DraftService.saveDraft({
sessionId: 'session-123',
title: 'Already Completed',
content: '# Content',
tags: [],
createdAt: Date.now(),
status: 'completed' as const,
});
if (!draft.id) throw new Error('Draft ID should be defined');
const firstResult = await DraftService.markAsCompleted(draft.id);
const secondResult = await DraftService.markAsCompleted(draft.id);
// Both should return the same draft
expect(firstResult?.id).toBe(secondResult?.id);
expect(secondResult?.status).toBe('completed');
});
});
describe('getCompletedDrafts', () => {
it('should return only completed drafts', async () => {
const timestamp = Date.now();
// Save a completed draft
await DraftService.saveDraft({
sessionId: 'session-1',
title: 'Completed Draft',
content: '# Done',
tags: ['done'],
createdAt: timestamp - 1000,
status: 'completed' as const,
completedAt: timestamp,
});
// Save a draft draft
await DraftService.saveDraft({
sessionId: 'session-2',
title: 'Draft Draft',
content: '# In Progress',
tags: ['wip'],
createdAt: timestamp,
status: 'draft' as const,
});
const completedDrafts = await DraftService.getCompletedDrafts();
expect(completedDrafts).toHaveLength(1);
expect(completedDrafts[0].title).toBe('Completed Draft');
});
it('should order completed drafts by completion date (newest first)', async () => {
const timestamp = Date.now();
await DraftService.saveDraft({
sessionId: 'session-1',
title: 'First Completed',
content: '# First',
tags: [],
createdAt: timestamp - 2000,
status: 'completed' as const,
completedAt: timestamp - 2000,
});
await DraftService.saveDraft({
sessionId: 'session-2',
title: 'Second Completed',
content: '# Second',
tags: [],
createdAt: timestamp - 1000,
status: 'completed' as const,
completedAt: timestamp - 1000,
});
await DraftService.saveDraft({
sessionId: 'session-3',
title: 'Third Completed',
content: '# Third',
tags: [],
createdAt: timestamp,
status: 'completed' as const,
completedAt: timestamp,
});
const completedDrafts = await DraftService.getCompletedDrafts();
expect(completedDrafts).toHaveLength(3);
expect(completedDrafts[0].title).toBe('Third Completed'); // Most recent completion
expect(completedDrafts[1].title).toBe('Second Completed');
expect(completedDrafts[2].title).toBe('First Completed');
});
it('should return empty array when no completed drafts exist', async () => {
const completedDrafts = await DraftService.getCompletedDrafts();
expect(completedDrafts).toEqual([]);
});
// Story 3.1: Pagination tests
it('should support pagination with limit and offset', async () => {
const timestamp = Date.now();
// Create 25 completed drafts
for (let i = 0; i < 25; i++) {
await DraftService.saveDraft({
sessionId: `session-${i}`,
title: `Draft ${i}`,
content: `# Content ${i}`,
tags: [],
createdAt: timestamp - (25 - i) * 1000, // Different creation times
status: 'completed' as const,
completedAt: timestamp - (25 - i) * 1000, // Different completion times
});
}
// Get first page (20 items)
const firstPage = await DraftService.getCompletedDrafts({ limit: 20, offset: 0 });
expect(firstPage).toHaveLength(20);
// Get second page (5 remaining items)
const secondPage = await DraftService.getCompletedDrafts({ limit: 20, offset: 20 });
expect(secondPage).toHaveLength(5);
});
it('should default to limit of 20 when no options provided', async () => {
const timestamp = Date.now();
// Create 25 drafts
for (let i = 0; i < 25; i++) {
await DraftService.saveDraft({
sessionId: `session-${i}`,
title: `Draft ${i}`,
content: `# Content`,
tags: [],
createdAt: timestamp - i * 1000,
status: 'completed' as const,
completedAt: timestamp - i * 1000,
});
}
const drafts = await DraftService.getCompletedDrafts();
expect(drafts).toHaveLength(20); // Default limit
});
});
describe('getCompletedCount', () => {
// Story 3.1: Count completed drafts
it('should return count of completed drafts', async () => {
const timestamp = Date.now();
// Create some completed drafts
for (let i = 0; i < 5; i++) {
await DraftService.saveDraft({
sessionId: `session-${i}`,
title: `Completed ${i}`,
content: '# Content',
tags: [],
createdAt: timestamp - i * 1000,
status: 'completed' as const,
completedAt: timestamp - i * 1000,
});
}
// Create some draft drafts (should not be counted)
await DraftService.saveDraft({
sessionId: 'session-draft',
title: 'Draft Status',
content: '# In Progress',
tags: [],
createdAt: timestamp,
status: 'draft' as const,
});
const count = await DraftService.getCompletedCount();
expect(count).toBe(5);
});
it('should return 0 when no completed drafts exist', async () => {
const count = await DraftService.getCompletedCount();
expect(count).toBe(0);
});
});
});

269
src/lib/db/draft-service.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* Draft Service
*
* Service layer for Draft CRUD operations following the Logic Sandwich pattern.
* This service handles all Draft database operations through Dexie.
*
* Architecture Compliance:
* - This is a SERVICE layer - components should NOT import this directly
* - Components should use ChatService which uses this service
* - Services return plain objects, not Dexie observables
*
* Data Flow:
* - UI -> Zustand Store -> ChatService -> DraftService -> Dexie
*/
import Dexie from 'dexie';
import { db, type DraftRecord } from './index';
import { SyncManager } from '../../services/sync-manager';
/**
* Draft type used throughout the application (includes id)
*/
export interface Draft extends DraftRecord {
id: number;
}
/**
* Service class for Draft operations
*/
export class DraftService {
/**
* Save a draft to IndexedDB
* @param draft - The draft to save (without id)
* @returns The saved draft with generated id
*/
static async saveDraft(draft: Omit<DraftRecord, 'id'>): Promise<Draft> {
// 1. Always save to local DB first (Local-First)
const id = await db.drafts.add(draft as DraftRecord);
const savedDraft = { ...draft, id } as Draft;
// 2. Check network status
if (!SyncManager.isOnline()) {
// Offline: Queue for future server sync
await SyncManager.queueAction('saveDraft', { draftData: draft });
}
// Online: In future, we would sync immediately here: await api.saveDraft(...)
return savedDraft;
}
/**
* Get a draft by its ID
* @param draftId - The ID of the draft
* @returns The draft or null if not found
*/
static async getDraftById(draftId: number): Promise<Draft | null> {
const draft = await db.drafts.get(draftId);
if (!draft) {
return null;
}
// Return plain object, not Dexie observable
return { ...draft } as Draft;
}
/**
* Get the latest draft for a specific session
* @param sessionId - The session ID to look up
* @returns The latest draft or null if not found
*/
static async getDraftBySessionId(sessionId: string): Promise<Draft | null> {
// First get all drafts for this session
const drafts = await db.drafts
.where('sessionId')
.equals(sessionId)
.toArray();
if (drafts.length === 0) {
return null;
}
// Sort by createdAt descending to get the latest
drafts.sort((a, b) => b.createdAt - a.createdAt);
// Return the first (latest) draft
return { ...drafts[0] } as Draft;
}
/**
* Get all drafts ordered by creation date (newest first)
* @returns Array of all drafts
*/
static async getAllDrafts(): Promise<Draft[]> {
const drafts = await db.drafts
.orderBy('createdAt')
.toArray();
// Reverse to get newest first
drafts.reverse();
// Return plain objects, not Dexie observables
return drafts.map(d => ({ ...d })) as Draft[];
}
/**
* Get drafts by status
* @param status - The status to filter by
* @returns Array of drafts with the specified status
*/
static async getDraftsByStatus(status: DraftRecord['status']): Promise<Draft[]> {
const drafts = await db.drafts
.where('status')
.equals(status)
.toArray();
// Sort by createdAt descending
drafts.sort((a, b) => b.createdAt - a.createdAt);
return drafts.map(d => ({ ...d })) as Draft[];
}
/**
* Update the status of a draft
* @param draftId - The ID of the draft to update
* @param status - The new status
* @returns true if updated, false if not found
*/
static async updateDraftStatus(
draftId: number,
status: DraftRecord['status']
): Promise<boolean> {
const count = await db.drafts.update(draftId, { status });
return count > 0;
}
/**
* Update draft content
* @param draftId - The ID of the draft to update
* @param updates - The fields to update
* @returns true if updated, false if not found
*/
static async updateDraft(
draftId: number,
updates: Partial<Omit<DraftRecord, 'id'>>
): Promise<boolean> {
const count = await db.drafts.update(draftId, updates);
return count > 0;
}
/**
* Delete a draft with cascade delete of associated chat logs
* Story 3.2: Implements true privacy by removing all related data
*
* @param draftId - The ID of the draft to delete
* @returns true if deleted, false if not found
*/
static async deleteDraft(draftId: number): Promise<boolean> {
// First check if draft exists
const draft = await db.drafts.get(draftId);
if (!draft) {
return false;
}
// Use transaction for atomic cascade delete
await db.transaction('rw', db.drafts, db.chatLogs, async () => {
// Delete associated chat logs by sessionId
await db.chatLogs
.where('sessionId')
.equals(draft.sessionId)
.delete();
// Delete the draft
await db.drafts.delete(draftId);
});
// 3. Queue for sync if offline
if (!SyncManager.isOnline()) {
await SyncManager.queueAction('deleteDraft', { draftId });
}
return true;
}
/**
* Delete all drafts for a session
* @param sessionId - The session ID
* @returns Number of drafts deleted
*/
static async deleteSessionDrafts(sessionId: string): Promise<number> {
return await db.drafts.where('sessionId').equals(sessionId).delete();
}
/**
* Mark a draft as completed
* Story 2.4: Adds completion timestamp and status
*
* @param draftId - The ID of the draft to mark as completed
* @returns The updated draft, or null if not found
*/
static async markAsCompleted(draftId: number): Promise<Draft | null> {
const existing = await this.getDraftById(draftId);
if (!existing) {
return null;
}
// If already completed, just return it
if (existing.status === 'completed') {
return existing;
}
// Update status and add completion timestamp
await db.drafts.update(draftId, {
status: 'completed',
completedAt: Date.now(),
});
// Queue for sync if offline
if (!SyncManager.isOnline()) {
await SyncManager.queueAction('completeDraft', { draftId });
}
// Return the updated draft
return this.getDraftById(draftId);
}
/**
* Get all completed drafts ordered by completion date (newest first)
* Story 2.4: For history feed
* Story 3.1: Enhanced with pagination support
*
* @param options - Pagination options
* @returns Array of completed drafts
*/
static async getCompletedDrafts(options?: {
limit?: number;
offset?: number;
}): Promise<Draft[]> {
const limit = options?.limit || 20;
const offset = options?.offset || 0;
// Use compound index or just createdAt for showing ALL history (drafts + completed)
// Story 3.1: User wants to see "content generate" -> So we show everything
const drafts = await db.drafts
.orderBy('createdAt')
.reverse() // Sort by createdAt descending
.offset(offset)
.limit(limit)
.toArray();
return drafts.map(d => ({ ...d })) as Draft[];
}
/**
* Count total completed drafts
* Story 3.1: For pagination hasMore detection
*
* @returns Count of completed drafts
*/
static async getCompletedCount(): Promise<number> {
return await db.drafts
.where('status')
.equals('completed')
.count();
}
}
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') {
(window as any).DraftService = DraftService;
}

231
src/lib/db/index.test.ts Normal file
View File

@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { db, type SyncQueueItem } from './index'
describe('ChatDatabase', () => {
beforeEach(async () => {
await db.delete()
await db.open()
})
it('should have a chatLogs table', () => {
expect(db.chatLogs).toBeDefined()
expect(db.tables.map(t => t.name)).toContain('chatLogs')
})
it('should have correct schema for chatLogs', () => {
const table = db.chatLogs
expect(table.schema.primKey.name).toBe('id')
expect(table.schema.indexes.map(i => i.name)).toContain('timestamp')
})
})
describe('Story 3.3: SyncQueue Database Schema', () => {
beforeEach(async () => {
// Clear syncQueue table before each test
await db.syncQueue.clear()
})
describe('Database Version 3', () => {
it('should have database version 3 or higher', () => {
expect(db.verno).toBeGreaterThanOrEqual(3)
})
it('should have syncQueue table', () => {
expect(db.syncQueue).toBeDefined()
})
})
describe('SyncQueueItem Interface', () => {
it('should create a sync queue item with required fields', async () => {
const item: Omit<SyncQueueItem, 'id'> = {
action: 'saveDraft',
payload: {
draftData: {
sessionId: 'test-session',
title: 'Test Draft',
content: 'Test content',
tags: ['test'],
createdAt: Date.now(),
status: 'draft',
},
},
status: 'pending',
createdAt: Date.now(),
retries: 0,
}
const id = await db.syncQueue.add(item)
expect(id).toBeDefined()
const retrieved = await db.syncQueue.get(id)
expect(retrieved).toMatchObject(item)
})
it('should support all action types', async () => {
const actions: SyncQueueItem['action'][] = ['saveDraft', 'deleteDraft', 'completeDraft']
for (const action of actions) {
const item: Omit<SyncQueueItem, 'id'> = {
action,
payload: action === 'deleteDraft' || action === 'completeDraft'
? { draftId: 123 }
: { draftData: { sessionId: 'test', title: 'Test', content: 'Test', tags: [], createdAt: Date.now(), status: 'draft' } },
status: 'pending',
createdAt: Date.now(),
retries: 0,
}
const id = await db.syncQueue.add(item)
expect(id).toBeDefined()
}
})
it('should support all status values', async () => {
const statuses: SyncQueueItem['status'][] = ['pending', 'processing', 'synced', 'failed']
for (const status of statuses) {
const item: Omit<SyncQueueItem, 'id'> = {
action: 'saveDraft',
payload: { draftData: { sessionId: 'test', title: 'Test', content: 'Test', tags: [], createdAt: Date.now(), status: 'draft' } },
status,
createdAt: Date.now(),
retries: 0,
}
const id = await db.syncQueue.add(item)
const retrieved = await db.syncQueue.get(id)
expect(retrieved?.status).toBe(status)
}
})
it('should store optional lastError field', async () => {
const item: Omit<SyncQueueItem, 'id'> = {
action: 'saveDraft',
payload: { draftData: { sessionId: 'test', title: 'Test', content: 'Test', tags: [], createdAt: Date.now(), status: 'draft' } },
status: 'failed',
createdAt: Date.now(),
retries: 3,
lastError: 'Network error',
}
const id = await db.syncQueue.add(item)
const retrieved = await db.syncQueue.get(id)
expect(retrieved?.lastError).toBe('Network error')
})
})
describe('SyncQueue Indexes', () => {
it('should query items by status using index', async () => {
// Add items with different statuses
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test1', title: 'Test1', content: 'Test1', tags: [], createdAt: Date.now(), status: 'draft' } },
status: 'pending',
createdAt: Date.now(),
retries: 0,
})
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test2', title: 'Test2', content: 'Test2', tags: [], createdAt: Date.now(), status: 'draft' } },
status: 'processing',
createdAt: Date.now(),
retries: 0,
})
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test3', title: 'Test3', content: 'Test3', tags: [], createdAt: Date.now(), status: 'draft' } },
status: 'pending',
createdAt: Date.now(),
retries: 0,
})
const pendingItems = await db.syncQueue.where('status').equals('pending').toArray()
expect(pendingItems).toHaveLength(2)
const processingItems = await db.syncQueue.where('status').equals('processing').toArray()
expect(processingItems).toHaveLength(1)
})
it('should query items sorted by createdAt using index', async () => {
const now = Date.now()
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test1', title: 'Test1', content: 'Test1', tags: [], createdAt: now, status: 'draft' } },
status: 'pending',
createdAt: now,
retries: 0,
})
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test2', title: 'Test2', content: 'Test2', tags: [], createdAt: now, status: 'draft' } },
status: 'pending',
createdAt: now + 100,
retries: 0,
})
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test3', title: 'Test3', content: 'Test3', tags: [], createdAt: now, status: 'draft' } },
status: 'pending',
createdAt: now + 50,
retries: 0,
})
const items = await db.syncQueue.orderBy('createdAt').toArray()
expect(items).toHaveLength(3)
expect(items[0].createdAt).toBe(now)
expect(items[1].createdAt).toBe(now + 50)
expect(items[2].createdAt).toBe(now + 100)
})
it('should support querying by status and sorting by createdAt together', async () => {
const now = Date.now()
await db.syncQueue.add({
action: 'saveDraft',
payload: { draftData: { sessionId: 'test1', title: 'Test1', content: 'Test1', tags: [], createdAt: now, status: 'draft' } },
status: 'pending',
createdAt: now + 100,
retries: 0,
})
await db.syncQueue.add({
action: 'deleteDraft',
payload: { draftId: 1 },
status: 'pending',
createdAt: now,
retries: 0,
})
await db.syncQueue.add({
action: 'completeDraft',
payload: { draftId: 2 },
status: 'processing',
createdAt: now + 50,
retries: 0,
})
const pendingItems = await db.syncQueue.where('status').equals('pending').sortBy('createdAt')
expect(pendingItems).toHaveLength(2)
expect(pendingItems[0].createdAt).toBe(now)
expect(pendingItems[1].createdAt).toBe(now + 100)
})
})
describe('Data Migration Safety', () => {
it('should preserve existing chatLogs data after migration', async () => {
// This test verifies existing data is not lost during schema upgrade
const chatCount = await db.chatLogs.count()
// The table should exist (even if empty in test environment)
expect(db.chatLogs).toBeDefined()
})
it('should preserve existing drafts data after migration', async () => {
const draftCount = await db.drafts.count()
// The table should exist (even if empty in test environment)
expect(db.drafts).toBeDefined()
})
})
})

102
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,102 @@
import Dexie, { type Table } from 'dexie';
export interface ChatMessage {
id?: number;
sessionId: string; // Story 3.2: Added sessionId for cascade delete
role: 'user' | 'assistant';
content: string;
timestamp: number;
intent?: 'venting' | 'insight';
}
export interface DraftRecord {
id?: number;
sessionId: string;
title: string;
content: string; // Markdown formatted
tags: string[]; // Array of tag strings
createdAt: number;
status: 'draft' | 'completed' | 'regenerated';
completedAt?: number; // Story 2.4: Completion timestamp
}
/**
* SyncQueueItem Interface
* Story 3.3: Offline sync queue for actions to be synchronized
*/
export interface SyncQueueItem {
id?: number;
action: 'saveDraft' | 'deleteDraft' | 'completeDraft';
payload: {
draftId?: number;
draftData?: Omit<DraftRecord, 'id'>;
sessionId?: string;
};
status: 'pending' | 'processing' | 'synced' | 'failed';
createdAt: number;
retries: number;
lastError?: string;
}
export class ChatDatabase extends Dexie {
chatLogs!: Table<ChatMessage>;
drafts!: Table<DraftRecord>;
syncQueue!: Table<SyncQueueItem>;
constructor() {
super('Test01Database');
// Version 1: Initial schema with chatLogs
this.version(1).stores({
chatLogs: '++id, timestamp',
});
// Version 2: Add sessionId index for cascade delete (Story 3.2)
this.version(2).stores({
chatLogs: '++id, sessionId, timestamp',
drafts: '++id, sessionId, createdAt, status',
}).upgrade(tx => {
// Migration: Add sessionId to existing chat logs
return tx.table('chatLogs').toCollection().modify(chat => {
if (!chat.sessionId) {
chat.sessionId = 'legacy-' + chat.timestamp;
}
});
});
// Version 3: Add SyncQueue table (Story 3.3)
this.version(3).stores({
chatLogs: '++id, sessionId, timestamp',
drafts: '++id, sessionId, createdAt, status',
syncQueue: '++id, status, createdAt', // Indexed for efficient queries
}).upgrade(() => {
// No data migration needed - new empty table
console.log('Database upgraded to v3: SyncQueue added');
});
// Version 4: Add completedAt index for history feed (Story 3.1)
this.version(4).stores({
chatLogs: '++id, sessionId, timestamp',
drafts: '++id, sessionId, createdAt, status, completedAt', // Added completedAt index
syncQueue: '++id, status, createdAt',
}).upgrade(() => {
// No data migration needed - just adding index
console.log('Database upgraded to v4: completedAt index added for history feed');
});
// Version 5: Add compound index for performant sorting (Story 3.1 Fix)
this.version(5).stores({
chatLogs: '++id, sessionId, timestamp',
drafts: '++id, sessionId, createdAt, status, completedAt, [status+completedAt]',
syncQueue: '++id, status, createdAt',
}).upgrade(() => {
console.log('Database upgraded to v5: [status+completedAt] compound index added');
});
}
}
export const db = new ChatDatabase();
if (typeof window !== 'undefined') {
(window as any).db = db;
}

36
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface IChatLog {
id: string; // UUID
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
sessionId: string;
intent?: 'vent' | 'insight'; // Context for the message
}
export interface ISession {
id: string; // UUID
title: string;
createdAt: number;
updatedAt: number;
status: 'active' | 'completed' | 'archived';
tags?: string[];
}
export interface ISyncEntry {
id?: number; // Auto-increment
action: 'save_draft' | 'delete_entry' | 'update_settings';
payload: any;
timestamp: number;
status: 'pending' | 'synced' | 'failed';
retryCount: number;
}
export interface IDraft {
id: string; // UUID
sessionId: string;
title: string;
content: string; // Markdown
createdAt: number;
updatedAt: number;
status: 'draft' | 'final';
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { classifyIntent } from './intent-detector';
describe('Intent Detector', () => {
describe('classifyIntent - Venting Patterns', () => {
it('should classify "venting" when user expresses frustration', () => {
const result = classifyIntent("I'm so frustrated with this bug");
expect(result).toBe('venting');
});
it('should classify "venting" when user is stuck', () => {
const result = classifyIntent("I've been stuck on this for hours");
expect(result).toBe('venting');
});
it('should classify "venting" when something is broken', () => {
const result = classifyIntent("My code broke again and I don't know why");
expect(result).toBe('venting');
});
it('should classify "venting" with negative emotion words', () => {
const result = classifyIntent("I hate debugging");
expect(result).toBe('venting');
});
it('should classify "venting" when expressing confusion', () => {
const result = classifyIntent("I don't understand why this isn't working");
expect(result).toBe('venting');
});
it('should classify "venting" with problem-focused language', () => {
const result = classifyIntent("This keeps failing and I can't figure it out");
expect(result).toBe('venting');
});
it('should classify "venting" when mentioning time spent struggling', () => {
const result = classifyIntent("I've been working on this all day");
expect(result).toBe('venting');
});
it('should classify "venting" with "error" keyword', () => {
const result = classifyIntent("I keep getting this error and don't know what to do");
expect(result).toBe('venting');
});
});
describe('classifyIntent - Insight Patterns', () => {
it('should classify "insight" when user has realization', () => {
const result = classifyIntent("I finally get how closures work");
expect(result).toBe('insight');
});
it('should classify "insight" when sharing understanding', () => {
const result = classifyIntent("Now I understand the difference between let and const");
expect(result).toBe('insight');
});
it('should classify "insight" when something clicked', () => {
const result = classifyIntent("It just clicked - React hooks are all about side effects");
expect(result).toBe('insight');
});
it('should classify "insight" when problem is solved', () => {
const result = classifyIntent("I figured out the bug - it was a missing dependency");
expect(result).toBe('insight');
});
it('should classify "insight" when explaining solution', () => {
const result = classifyIntent("So the trick is to use useMemo for expensive calculations");
expect(result).toBe('insight');
});
it('should classify "insight" with completion language', () => {
const result = classifyIntent("Finally got it working after trying everything");
expect(result).toBe('insight');
});
it('should classify "insight" when teaching concept', () => {
const result = classifyIntent("Here's what I learned - async/await is just syntactic sugar for promises");
expect(result).toBe('insight');
});
it('should classify "insight" with positive realization', () => {
const result = classifyIntent("Realized that I was overthinking this whole time");
expect(result).toBe('insight');
});
});
describe('classifyIntent - Edge Cases', () => {
it('should default to "venting" for ambiguous input', () => {
const result = classifyIntent("I'm working on a project");
expect(result).toBe('venting'); // Default to venting for safety
});
it('should classify "venting" for mixed signals', () => {
const result = classifyIntent("I figured it out but it took forever");
expect(result).toBe('insight'); // Positive indicators take precedence
});
it('should handle empty string gracefully', () => {
const result = classifyIntent("");
expect(result).toBe('venting');
});
it('should be case-insensitive', () => {
const result = classifyIntent("I HATE WHEN THIS HAPPENS");
expect(result).toBe('venting');
});
it('should classify "venting" for questions', () => {
const result = classifyIntent("Why does this keep happening?");
expect(result).toBe('venting');
});
it('should classify "insight" when solution is primary focus', () => {
const result = classifyIntent("The solution was to use useRef instead of useState");
expect(result).toBe('insight');
});
});
describe('classifyIntent - Accuracy Requirements', () => {
it('should achieve >85% accuracy on common venting patterns', () => {
const ventingInputs = [
"I'm so frustrated",
"This is broken",
"I don't understand",
"Stuck on this bug",
"Keeps failing",
"Why won't this work",
"I hate this",
"Can't figure it out",
"This is so confusing",
"Getting an error",
];
const correct = ventingInputs.filter(input => classifyIntent(input) === 'venting').length;
const accuracy = (correct / ventingInputs.length) * 100;
expect(accuracy).toBeGreaterThan(85);
});
it('should achieve >85% accuracy on common insight patterns', () => {
const insightInputs = [
"I finally get it",
"Figured it out",
"Now I understand",
"It just clicked",
"Solved the problem",
"Here's what I learned",
"Realized something",
"Got it working",
"The solution is",
"So the key is",
];
const correct = insightInputs.filter(input => classifyIntent(input) === 'insight').length;
const accuracy = (correct / insightInputs.length) * 100;
expect(accuracy).toBeGreaterThan(85);
});
});
});

View File

@@ -0,0 +1,167 @@
/**
* Intent Detector
*
* Classifies user messages as "venting" or "insight" based on keyword patterns.
* Uses a combination of keyword-based heuristics for fast classification.
*
* Venting Indicators:
* - Negative emotion words (frustrated, stuck, hate, broke)
* - Problem-focused language (doesn't work, failing, error)
* - Uncertainty or confusion (don't understand, why does)
* - Time spent struggling (hours, days, all day)
*
* Insight Indicators:
* - Positive realization words (get, understand, clicked, realized)
* - Solution-focused language (figured out, solved, fixed)
* - Teaching/explaining intent (so the trick is, here's what)
* - Completion or success (finally, working, done)
*/
export type Intent = 'venting' | 'insight';
// Venting keyword patterns (more specific to avoid false positives)
const VENTING_KEYWORDS = [
'frustrated',
'stuck',
'hate',
'broke',
'broken',
"don't understand",
'doesnt understand',
'confused',
'failing',
'error',
"won't work",
'wont work',
'cant figure',
"can't figure",
'struggling',
'difficult',
'hard',
'annoying',
// Question words (only when at start or with ?)
'why',
'how do',
'help',
];
// Insight keyword patterns (more specific to avoid false positives)
const INSIGHT_KEYWORDS = [
'finally get', // More specific than just "get"
'get it', // Also add "get it" pattern
'get it now', // "I get it now"
'understand',
'clicked',
'realized',
'figured it out', // Common phrase: "I figured it out"
'figured out', // Alternative: "I figured out the bug"
'solved',
'fixed',
'fixed it', // "I fixed it"
'now working', // More specific than just "working"
"it's working", // "It's working now"
'its working',
'done',
'finally',
'solution',
'found the solution', // "I found the solution"
'trick is',
'trick was', // "The trick was to..."
"here's what",
'heres what',
'learned',
'key is',
'answer',
'accomplished',
'makes sense', // "This makes sense"
'makes sense now',
];
/**
* Classifies the intent of a user message as "venting" or "insight".
* @param input - The user's message text
* @returns The classified intent ('venting' | 'insight')
*/
export function classifyIntent(input: string): Intent {
if (!input || input.trim().length === 0) {
return 'venting'; // Default to venting for empty input
}
const normalizedInput = input.toLowerCase().trim();
// Count insight indicators (positive patterns) FIRST
let insightScore = 0;
for (const keyword of INSIGHT_KEYWORDS) {
if (normalizedInput.includes(keyword)) {
insightScore += 1;
}
}
// Count venting indicators (negative patterns)
let ventingScore = 0;
for (const keyword of VENTING_KEYWORDS) {
if (normalizedInput.includes(keyword)) {
ventingScore += 1;
}
}
// Strong insight patterns (solution language, teaching) take ULTIMATE precedence
// This check happens AFTER scoring but BEFORE time-struggling check
const strongInsightPatterns = [
'figured it out', // Common phrase: "I figured it out"
'figured out', // Alternative: "I figured out the bug"
'solution is',
'trick is',
'key is',
"here's what",
'heres what',
];
const hasStrongInsightPattern = strongInsightPatterns.some(pattern =>
normalizedInput.includes(pattern)
);
// Strong insight patterns override everything else
// "figured out but it took forever" - still insight because they accomplished it
if (hasStrongInsightPattern) {
return 'insight';
}
// Questions are typically venting (seeking help)
const isQuestion = normalizedInput.includes('?') ||
normalizedInput.startsWith('why ') ||
normalizedInput.startsWith('how ') ||
normalizedInput.startsWith('what ') ||
normalizedInput.startsWith('when ') ||
normalizedInput.startsWith('where ');
if (isQuestion && insightScore === 0) {
return 'venting';
}
// Special case: time spent struggling is venting UNLESS there's strong insight pattern
const timeStrugglingPatterns = [
'all day',
'for hours',
' hours ',
'days',
'forever',
];
const hasTimeStrugglingPattern = timeStrugglingPatterns.some(pattern =>
normalizedInput.includes(pattern)
);
if (hasTimeStrugglingPattern && !hasStrongInsightPattern) {
return 'venting';
}
// Decision logic: insight if insight score is strictly higher than venting score
// Default to venting for tie or when scores are equal (safer assumption)
// Also default to venting when there are no clear indicators (both scores are 0)
if (insightScore > ventingScore && insightScore > 0) {
return 'insight';
}
return 'venting';
}

View File

@@ -0,0 +1,430 @@
import { describe, it, expect } from 'vitest';
import { generateTeacherPrompt, generateGhostwriterPrompt } from './prompt-engine';
import type { ChatMessage } from '../db';
describe('Prompt Engine - Teacher Agent', () => {
describe('generateTeacherPrompt - Venting Intent', () => {
it('should generate empathetic venting prompt with user input', () => {
const userInput = "I'm so frustrated with this bug";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain(userInput);
expect(result.toLowerCase()).toContain('empath');
expect(result.toLowerCase()).toContain('validate');
});
it('should include probing question instruction for venting', () => {
const userInput = "This keeps breaking";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result.toLowerCase()).toContain('probing');
expect(result.toLowerCase()).toContain('question');
});
it('should be supportive and encouraging for venting', () => {
const userInput = "I don't understand why this isn't working";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result.toLowerCase()).toContain('supportive');
expect(result.toLowerCase()).toContain('encourag');
});
it('should specify concise response (2-3 sentences) for venting', () => {
const userInput = "Stuck on this problem";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('2-3 sentences');
});
it('should include chat history when available for venting', () => {
const userInput = "Still having issues";
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Having trouble with React hooks', timestamp: Date.now() },
{ role: 'assistant', content: 'What specific hook are you struggling with?', timestamp: Date.now() },
];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('Previous context');
expect(result).toContain('React hooks');
});
});
describe('generateTeacherPrompt - Insight Intent', () => {
it('should generate curious insight prompt with user input', () => {
const userInput = "I finally understand closures";
const chatHistory: ChatMessage[] = [];
const intent = 'insight';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain(userInput);
expect(result.toLowerCase()).toContain('curious');
expect(result.toLowerCase()).toContain('celebrate');
});
it('should include deepening question instruction for insight', () => {
const userInput = "The key was using useMemo";
const chatHistory: ChatMessage[] = [];
const intent = 'insight';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result.toLowerCase()).toContain('deepen');
expect(result.toLowerCase()).toContain('expand');
});
it('should be encouraging for insight', () => {
const userInput = "It just clicked";
const chatHistory: ChatMessage[] = [];
const intent = 'insight';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result.toLowerCase()).toContain('encourag');
});
it('should specify concise response (2-3 sentences) for insight', () => {
const userInput = "Solved the problem";
const chatHistory: ChatMessage[] = [];
const intent = 'insight';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('2-3 sentences');
});
it('should include chat history when available for insight', () => {
const userInput = "Got it working now";
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Trying to fix the async issue', timestamp: Date.now() },
{ role: 'assistant', content: 'Have you tried using await?', timestamp: Date.now() },
];
const intent = 'insight';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('Previous context');
expect(result).toContain('async');
});
});
describe('generateTeacherPrompt - Edge Cases', () => {
it('should handle empty chat history gracefully', () => {
const userInput = "Help me debug";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('Previous context:');
expect(result).toContain('(No previous messages)');
});
it('should handle very long user input', () => {
const userInput = "I".repeat(1000) + " frustrated";
const chatHistory: ChatMessage[] = [];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('...');
});
it('should handle multiple messages in chat history', () => {
const userInput = "Still confused";
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'First question', timestamp: Date.now() },
{ role: 'assistant', content: 'First answer', timestamp: Date.now() },
{ role: 'user', content: 'Second question', timestamp: Date.now() },
{ role: 'assistant', content: 'Second answer', timestamp: Date.now() },
];
const intent = 'venting';
const result = generateTeacherPrompt(userInput, chatHistory, intent);
expect(result).toContain('First question');
expect(result).toContain('Second question');
});
});
describe('generateTeacherPrompt - Format and Structure', () => {
it('should return non-empty string', () => {
const result = generateTeacherPrompt("Test", [], 'venting');
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
});
it('should include system role definition', () => {
const result = generateTeacherPrompt("Test", [], 'venting');
expect(result).toContain('You are');
});
it('should be clearly formatted for LLM consumption', () => {
const result = generateTeacherPrompt("Test input", [], 'insight');
expect(result).toMatch(/user input|your role|response/i);
});
});
});
describe('Prompt Engine - Ghostwriter Agent', () => {
describe('generateGhostwriterPrompt - Basic Functionality', () => {
it('should generate Ghostwriter prompt with chat history', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'I struggled with dependency injection today', timestamp: Date.now() },
{ role: 'assistant', content: 'What aspect was challenging?', timestamp: Date.now() },
{ role: 'user', content: 'Understanding how to inject dependencies without tight coupling', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
});
it('should include Ghostwriter Agent role definition', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'I learned something cool about React', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result).toContain('Ghostwriter');
expect(result.toLowerCase()).toContain('agent');
});
it('should include intent context in prompt', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Frustrated with debugging', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toContain('venting');
});
it('should handle insight intent context', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Finally understand closures', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result).toContain('insight');
});
it('should handle unknown intent gracefully', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Some message', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, undefined);
expect(result).toContain('unknown');
});
});
describe('generateGhostwriterPrompt - Output Format', () => {
it('should specify Markdown output format', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Learned about React hooks', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result.toLowerCase()).toContain('markdown');
expect(result.toLowerCase()).toContain('title');
expect(result.toLowerCase()).toContain('body');
expect(result.toLowerCase()).toContain('tags');
});
it('should include code block format specification', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Struggled with async/await', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toContain('```');
});
it('should specify professional LinkedIn-style tone', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Had a breakthrough', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result.toLowerCase()).toContain('professional');
expect(result.toLowerCase()).toContain('linkedin');
});
});
describe('generateGhostwriterPrompt - Content Requirements', () => {
it('should include constraint to avoid hallucinations', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Fixed a bug', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result.toLowerCase()).toContain('hallucinat');
});
it('should specify grounded in user input requirement', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Learned TypeScript', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result.toLowerCase()).toContain('grounded');
expect(result.toLowerCase()).toContain('user input');
});
it('should include transformation focus instruction', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'This was hard but I learned a lot', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result.toLowerCase()).toContain('transformation');
});
it('should specify word count constraint', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Understanding React now', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result).toMatch(/\d{2,3}\s*words/);
});
});
describe('generateGhostwriterPrompt - Edge Cases', () => {
it('should handle empty chat history gracefully', () => {
const chatHistory: ChatMessage[] = [];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
});
it('should handle very long chat history', () => {
const longHistory: ChatMessage[] = Array.from({ length: 50 }, (_, i) => ({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i} with some content to make it longer`,
timestamp: Date.now() + i * 1000,
}));
const result = generateGhostwriterPrompt(longHistory, 'venting');
expect(result).toBeTruthy();
// Should truncate to avoid token limits
expect(result.length).toBeLessThan(10000);
});
it('should handle short chat history', () => {
const shortHistory: ChatMessage[] = [
{ role: 'user', content: 'Hi', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(shortHistory, 'venting');
expect(result).toBeTruthy();
});
it('should handle chat history with only user messages', () => {
const userOnlyHistory: ChatMessage[] = [
{ role: 'user', content: 'First thought', timestamp: Date.now() },
{ role: 'user', content: 'Second thought', timestamp: Date.now() + 1000 },
{ role: 'user', content: 'Third thought', timestamp: Date.now() + 2000 },
];
const result = generateGhostwriterPrompt(userOnlyHistory, 'venting');
expect(result).toBeTruthy();
});
});
describe('generateGhostwriterPrompt - Intent-Specific Behavior', () => {
it('should emphasize reframing struggle for venting intent', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'This error is driving me crazy', timestamp: Date.now() },
{ role: 'assistant', content: 'What error are you seeing?', timestamp: Date.now() },
{ role: 'user', content: 'Cannot read property of undefined', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result.toLowerCase()).toContain('struggle');
expect(result.toLowerCase()).toContain('lesson');
});
it('should emphasize articulating breakthrough for insight intent', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'I finally got it!', timestamp: Date.now() },
{ role: 'assistant', content: 'What did you discover?', timestamp: Date.now() },
{ role: 'user', content: 'The key was understanding the render cycle', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'insight');
expect(result.toLowerCase()).toContain('breakthrough');
expect(result.toLowerCase()).toContain('articulat');
});
});
describe('generateGhostwriterPrompt - Structure and Formatting', () => {
it('should have clear section headers', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Test content', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toMatch(/context|requirements|output format/i);
});
it('should be properly formatted for LLM consumption', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'Test message', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
// Should have numbered lists or clear structure
expect(result).toMatch(/\d+\./);
});
it('should include chat history formatted properly', () => {
const chatHistory: ChatMessage[] = [
{ role: 'user', content: 'User message here', timestamp: Date.now() },
{ role: 'assistant', content: 'Teacher response here', timestamp: Date.now() },
];
const result = generateGhostwriterPrompt(chatHistory, 'venting');
expect(result).toContain('Chat History');
expect(result).toContain('User message here');
expect(result).toContain('Teacher response here');
});
});
});

View File

@@ -0,0 +1,227 @@
/**
* Prompt Engine for Teacher Agent
*
* Generates context-aware prompts for the Teacher Agent based on:
* - User's current input
* - Intent classification (venting vs insight)
* - Chat history for context
*
* The prompt templates are designed to produce:
* - Venting: Empathetic validation + probing question
* - Insight: Celebration + deepening question
* - Both: Concise (2-3 sentences) responses
*
* Also generates prompts for the Ghostwriter Agent which transforms
* chat sessions into polished, professional LinkedIn-style posts.
*/
import type { ChatMessage } from '../db';
import type { Intent } from './intent-detector';
/**
* Maximum length for user input in prompt (to avoid token limits)
*/
const MAX_INPUT_LENGTH = 500;
/**
* Maximum number of previous messages to include in context
*/
const MAX_HISTORY_MESSAGES = 6;
/**
* Formats chat history into a readable string for the prompt
* @param chatHistory - Array of previous chat messages
* @returns Formatted history string
*/
function formatChatHistory(chatHistory: ChatMessage[]): string {
if (!chatHistory || chatHistory.length === 0) {
return '(No previous messages)';
}
// Take the last N messages to stay within token limits
const recentHistory = chatHistory.slice(-MAX_HISTORY_MESSAGES);
return recentHistory
.map((msg, i) => {
const prefix = msg.role === 'user' ? 'User' : 'Teacher';
const content = msg.content.length > MAX_INPUT_LENGTH
? msg.content.substring(0, MAX_INPUT_LENGTH) + '...'
: msg.content;
return `${prefix}: ${content}`;
})
.join('\n');
}
/**
* Truncates user input to maximum length if necessary
* @param input - User input string
* @returns Truncated input string
*/
function truncateInput(input: string): string {
if (input.length <= MAX_INPUT_LENGTH) {
return input;
}
return input.substring(0, MAX_INPUT_LENGTH) + '...';
}
/**
* Generates a Teacher Agent prompt based on user input, chat history, and intent
* @param userInput - The current user message
* @param chatHistory - Previous messages in the conversation
* @param intent - The classified intent ('venting' | 'insight')
* @returns Formatted prompt string for the LLM
*/
/**
* Generates a Teacher Agent prompt based on user input, chat history, and intent
* Using USER CUSTOM PERSONA: "High-Octane Data Mentor"
*/
export function generateTeacherPrompt(
userInput: string,
chatHistory: ChatMessage[],
intent: Intent
): string {
const truncatedInput = truncateInput(userInput);
const formattedHistory = formatChatHistory(chatHistory);
// Unified "Technical Companion" Prompt
return `ROLE: Technical Companion & Discovery Guide
PERSONA: You are a quiet, observant partner in the user's learning journey. You are not a lively entertainer; you are a steady presence. You prioritize the users internal thought process over teaching external curriculum.
CORE DIRECTIVE: Accompany the user. If they vent, provide a safe space. If they explore, walk alongside them. Do not push them with exercises. Instead, deepen their own realization with targeted questions.
OPERATIONAL RULES:
1. **Less Chatty**: Be economical with words. Do not praise excessively. Do not lecture.
2. **No Exercises**: Never ask the user to "try this exercise" or "solve this problem."
3. **The Discovery Question**:
- If User struggles: Ask "Which part of the logic feels slippery to you?"
- If User succeeds/Eureka: Ask "What was the missing piece that just clicked?"
4. **Venting Accompaniment**: If the user rants, listen. Acknowledge the difficulty. Do not rush to fix it unless asked.
5. **Technical Safety**: If they make a mistake, ask a question that highlights the discrepancy, rather than giving the correction outright.
CONVERSATIONAL STYLE:
- Calm, curious, and brief.
- Focus on the *user's* experience of the code, not just the code itself.
CONTEXT:
User Input (${intent}): ${truncatedInput}
Previous Context:
${formattedHistory}`;
}
/**
* Maximum length for a message in Ghostwriter chat history
*/
const MAX_GHOSTWRITER_MESSAGE_LENGTH = 300;
/**
* Maximum number of messages to include in Ghostwriter prompt
*/
const MAX_GHOSTWRITER_HISTORY_MESSAGES = 15;
/**
* Formats chat history for Ghostwriter prompt
* @param chatHistory - Array of chat messages
* @returns Formatted history string
*/
function formatChatHistoryForGhostwriter(chatHistory: ChatMessage[]): string {
if (!chatHistory || chatHistory.length === 0) {
return '(No chat history available)';
}
// Take the last N messages to stay within token limits
const recentHistory = chatHistory.slice(-MAX_GHOSTWRITER_HISTORY_MESSAGES);
return recentHistory
.map((msg) => {
const prefix = msg.role === 'user' ? 'User' : 'Teacher';
const content =
msg.content.length > MAX_GHOSTWRITER_MESSAGE_LENGTH
? msg.content.substring(0, MAX_GHOSTWRITER_MESSAGE_LENGTH) + '...'
: msg.content;
return `${prefix}: ${content}`;
})
.join('\n');
}
/**
* Generates a Ghostwriter Agent prompt based on chat history and intent
* Using USER CUSTOM PERSONA: "Pedagogical Biographer"
*/
export function generateGhostwriterPrompt(
chatHistory: ChatMessage[],
intent?: Intent
): string {
const formattedHistory = formatChatHistoryForGhostwriter(chatHistory);
const intentLabel = intent || 'unknown';
return `ROLE: Pedagogical Biographer & Learning Historian
PERSONA: You are an introspective storyteller. Your mission is to archive a student's internal journey from confusion to mastery. You do not write for an audience; you write for the "future version" of the student, capturing the raw evolution of their logic.
INPUT DATA:
- Chat transcript between Student and Mentor
- User Intent: ${intentLabel}
TASK: Write a 1st-person ("I") retrospective chronicle of the learning session. Focus on the transformation from the "Struggle" to the "Click."
OUTPUT STRUCTURE:
\`\`\`markdown
# 📓 The Session: [Topic Title]
## The Initial Friction
[Describe my starting state—the "wall" I hit and the frustration/confusion I felt. Be honest about the "vent."]
## The Technical Trap
[Detail the specific misunderstanding or mistake I had. Explain why it was a "trap" in my logic.]
## The Mentors Pivot
[Record the moment the teacher stepped in. Describe the specific analogy used to fix my mental model.]
## The Breakthrough
[Describe the "Eureka" moment. How did it feel when it finally "clicked"? What changed in my understanding?]
## The Golden Rules
- [Rule 1: Technical "non-negotiable" or clean-data habit learned today]
- [Rule 2]
- [Rule 3]
\`\`\`
WRITING STYLE:
- Perspective: 1st Person ("I").
- Tone: Honest, gritty, and reflective. Keep the raw energy of the original conversation.
- Focus: Prioritize the "Mental Unlock." This is a record of how I learned, not just what I learned.
CHAT HISTORY:
${formattedHistory}`;
}
/**
* Story 2.3: Generate a refinement prompt based on original draft and user feedback
* Adapted for Pedagogical Biographer
*/
export function generateRefinementPrompt(
originalDraft: string,
userFeedback: string,
chatHistory: ChatMessage[],
intent?: Intent
): string {
const formattedHistory = formatChatHistoryForGhostwriter(chatHistory);
return `ROLE: Pedagogical Biographer (Refinement Mode)
TASK: Rewrite the session chronicle based on the student's feedback, while maintaining the introspection and "High-Octane" energy.
ORIGINAL CHRONICLE:
${originalDraft}
STUDENT FEEDBACK:
"${userFeedback}"
REQUIREMENTS:
1. Address the feedback specifically.
2. Maintain the 1st-person "I" perspective and raw, reflective tone.
3. Keep the 5-section structure (Friction -> Trap -> Pivot -> Breakthrough -> Rules) unless the feedback explicitly asks to change it.
4. Do NOT hallucinate interactions that didn't happen in the history.
CHAT HISTORY:
${formattedHistory}`;
}

View File

@@ -0,0 +1,129 @@
/**
* Tests for Refinement Prompt Generation
*
* Story 2.3: Refinement Loop (Regeneration)
*/
import { describe, it, expect } from 'vitest';
import { generateRefinementPrompt } from './prompt-engine';
import type { ChatMessage } from '../db';
describe('PromptEngine - Refinement Prompt Generation', () => {
const mockChatHistory: ChatMessage[] = [
{
id: 1,
role: 'user',
content: 'I struggled with understanding async/await in JavaScript',
timestamp: Date.now() - 10000,
},
{
id: 2,
role: 'assistant',
content: 'That\'s a common challenge. Can you tell me more about what specifically confused you?',
timestamp: Date.now() - 8000,
},
{
id: 3,
role: 'user',
content: 'The order of execution and when promises resolve',
timestamp: Date.now() - 5000,
},
];
const originalDraft = `# Understanding Async/Await
Learning async/await was challenging because I struggled with understanding when promises resolve and the order of execution.
**Tags:** [JavaScript, async-await, learning]`;
describe('generateRefinementPrompt', () => {
it('should include original draft in prompt', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory
);
expect(prompt).toContain('ORIGINAL DRAFT:');
expect(prompt).toContain(originalDraft);
});
it('should include user feedback in prompt', () => {
const feedback = 'Make it shorter';
const prompt = generateRefinementPrompt(
originalDraft,
feedback,
mockChatHistory
);
expect(prompt).toContain('USER FEEDBACK:');
expect(prompt).toContain(`"${feedback}"`);
});
it('should include chat history for context', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory
);
expect(prompt).toContain('Original Chat History:');
expect(prompt).toContain(mockChatHistory[0].content);
});
it('should include requirements for maintaining voice and not hallucinating', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory
);
expect(prompt).toContain('maintaining their authentic voice');
expect(prompt).toContain('Do NOT introduce new ideas or hallucinate facts');
expect(prompt).toContain('Keep what worked in the original');
});
it('should handle vague feedback gracefully', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'make it better',
mockChatHistory
);
expect(prompt).toContain('If feedback is vague, make a reasonable best guess');
});
it('should specify markdown output format', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory
);
expect(prompt).toContain('OUTPUT FORMAT:');
expect(prompt).toContain('```markdown');
});
it('should include intent when provided', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory,
'venting'
);
expect(prompt).toContain('User Intent: venting');
});
it('should handle unknown intent', () => {
const prompt = generateRefinementPrompt(
originalDraft,
'Make it shorter',
mockChatHistory,
undefined
);
expect(prompt).toContain('User Intent: unknown');
});
});
});

View File

@@ -0,0 +1,73 @@
import { estimateTokenCount, countChatTokens, truncateChatHistory, ChatMessage } from './token-utils';
import { describe, it, expect } from 'vitest';
describe('Token Utilities', () => {
describe('estimateTokenCount', () => {
it('should return 0 for empty string', () => {
expect(estimateTokenCount('')).toBe(0);
});
it('should estimate 4 chars as 1 token', () => {
expect(estimateTokenCount('word')).toBe(1);
});
it('should round up', () => {
expect(estimateTokenCount('abcde')).toBe(2); // 5 chars -> 1.25 -> 2 tokens
});
});
describe('countChatTokens', () => {
it('should count content plus overhead', () => {
const messages: ChatMessage[] = [
{ role: 'user', content: 'hello' } // 5 chars (2 tokens) + 4 overhead = 6
];
expect(countChatTokens(messages)).toBe(6);
});
});
describe('truncateChatHistory', () => {
const systemMsg: ChatMessage = { role: 'system', content: 'Act like a teacher' };
const userMsg1: ChatMessage = { role: 'user', content: 'Hello' };
const assistantMsg: ChatMessage = { role: 'assistant', content: 'Hi there' };
const userMsg2: ChatMessage = { role: 'user', content: 'How are you?' };
it('should return all messages if within limit', () => {
const history = [systemMsg, userMsg1, assistantMsg, userMsg2];
// Total tokens should be roughly:
// sys: 18 chars -> 5 tokens + 4 = 9
// u1: 5 chars -> 2 tokens + 4 = 6
// a1: 8 chars -> 2 tokens + 4 = 6
// u2: 12 chars -> 3 tokens + 4 = 7
// Total: 28 tokens
const result = truncateChatHistory(history, 100);
expect(result).toHaveLength(4);
});
it('should always keep the system prompt', () => {
const history = [systemMsg, userMsg1, assistantMsg, userMsg2];
// Limit to 15. System is 9. Budget left 6.
// u2 is 7 -> too big?
// u1 is 6 -> fits?
// Wait, we reverse: most recent first.
// userMsg2 is newest. Cost 7. Limit 15 - 9 (sys) = 6 budget.
// userMsg2 needs 7. 7 > 6. So it should be skipped.
// Wait, logic says "stop adding".
const result = truncateChatHistory(history, 15);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(systemMsg);
});
it('should prioritize recent messages', () => {
const history = [systemMsg, userMsg1, assistantMsg, userMsg2];
// Limit 20 (Sys=9). Budget 11.
// u2 (7 tokens) -> Budget 4. Added.
// a1 (6 tokens) -> Budget -2. Skipped.
const result = truncateChatHistory(history, 20);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(systemMsg);
expect(result[1]).toEqual(userMsg2);
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* Token Counting Utilities
*
* Implements a heuristic-based token counter (approx 4 chars per token)
* to avoid shipping a heavy tokenizer like Tiktoken to the client.
*
* Risk R-2.2 Mitigation: Prevents Context Window Overflow.
*/
export const ESTIMATED_CHARS_PER_TOKEN = 4;
/**
* Estimates the number of tokens in a string.
* Uses the industry standard approximation of 4 characters per token for English text.
*/
export function estimateTokenCount(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
}
export interface ChatMessage {
role: 'user' | 'system' | 'assistant';
content: string;
}
/**
* Estimates the token count for a list of messages.
* Adds overhead for message formatting (approx 4 tokens per message).
*/
export function countChatTokens(messages: ChatMessage[]): number {
let total = 0;
for (const msg of messages) {
// Content tokens
total += estimateTokenCount(msg.content);
// Overhead (role + structural tokens)
total += 4;
}
return total;
}
/**
* Truncates a chat history to fit within a specific token limit.
* Keeps the system prompt (first message) and the most recent messages.
* Drops the oldest messages from the middle.
*/
export function truncateChatHistory(
messages: ChatMessage[],
maxTokens: number,
systemPromptTokens: number = 0
): ChatMessage[] {
// 1. Calculate total current tokens
const currentTokens = countChatTokens(messages);
// If we are within limits, return original
if (currentTokens <= maxTokens) {
return messages;
}
// 2. Identify critical messages (System Prompt + Last User Message)
const result: ChatMessage[] = [];
let reservedTokens = 0;
// Always keep the first message if it is a system prompt
if (messages.length > 0 && messages[0].role === 'system') {
result.push(messages[0]);
reservedTokens += estimateTokenCount(messages[0].content) + 4;
}
// Ensure we have budget
const budget = maxTokens - reservedTokens - systemPromptTokens;
if (budget <= 0) {
console.warn('Token budget too small for even the system prompt.');
return result;
}
// 3. Add messages from the end backwards until budget is full
// We skip the first message if we already added it (as system prompt)
const startIndex = (result.length > 0) ? 1 : 0;
const messagesToConsider = messages.slice(startIndex).reverse();
const recentMessages: ChatMessage[] = [];
let used = 0;
for (const msg of messagesToConsider) {
const cost = estimateTokenCount(msg.content) + 4;
if (used + cost <= budget) {
recentMessages.unshift(msg);
used += cost;
} else {
break; // Stop adding once we hit the limit
}
}
// 4. Combine
return [...result, ...recentMessages];
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useChatStore } from './chat-store'
import { ChatService } from '../../services/chat-service'
import { LLMService } from '../../services/llm-service'
import { db } from '../db'
describe('ChatStore', () => {
beforeEach(async () => {
await db.delete()
await db.open()
useChatStore.setState({ messages: [], isLoading: false })
})
it('should initialize with empty state', () => {
const { messages, isLoading } = useChatStore.getState()
expect(messages).toEqual([])
expect(isLoading).toBe(false)
})
it('should hydrate from service', async () => {
await ChatService.saveMessage({ role: 'user', content: 'Hi', timestamp: 1000 })
await useChatStore.getState().hydrate()
const { messages } = useChatStore.getState()
expect(messages).toHaveLength(1)
expect(messages[0].content).toBe('Hi')
})
it('should add message to store and service', async () => {
// Mock LLMService streaming to prevent AI response
vi.spyOn(LLMService, 'getTeacherResponseStream').mockResolvedValue(undefined)
const spy = vi.spyOn(ChatService, 'saveMessage')
await useChatStore.getState().addMessage('Hello', 'user')
const { messages } = useChatStore.getState()
// With LLM error, should still have user message + error message
expect(messages.length).toBeGreaterThanOrEqual(1)
expect(messages[0].content).toBe('Hello')
expect(spy).toHaveBeenCalled()
expect(spy.mock.calls[0][0]).toMatchObject({ content: 'Hello', role: 'user' })
})
it('should add AI response when user sends message', async () => {
// Mock successful LLM streaming response
vi.spyOn(LLMService, 'getTeacherResponseStream').mockImplementation(async (
_input: string,
_history: any[],
callbacks: any
) => {
// Simulate intent callback
await callbacks.onIntent('venting')
// Simulate token streaming
await callbacks.onToken('I')
await callbacks.onToken(' can')
await callbacks.onToken(' help')
await callbacks.onToken(' with')
await callbacks.onToken(' that')
await callbacks.onToken('!')
// Simulate completion
await callbacks.onComplete('I can help with that!')
})
await useChatStore.getState().addMessage('I am frustrated', 'user')
const { messages, currentIntent, isProcessing } = useChatStore.getState()
// Should have user message + AI response
expect(messages.length).toBeGreaterThanOrEqual(2)
expect(messages[0].content).toBe('I am frustrated')
expect(messages[0].role).toBe('user')
expect(messages[1].role).toBe('assistant')
expect(messages[1].content).toBe('I can help with that!')
expect(currentIntent).toBe('venting')
expect(isProcessing).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More