fix: ChatBubble crash and DeepSeek API compatibility

- Fix ChatBubble to handle non-string content with String() wrapper
- Fix API route to use generateText for non-streaming requests
- Add @ai-sdk/openai-compatible for non-OpenAI providers (DeepSeek, etc.)
- Use Chat Completions API instead of Responses API for compatible providers
- Update ChatBubble tests and fix component exports to kebab-case
- Remove stale PascalCase ChatBubble.tsx file
This commit is contained in:
Max
2026-01-26 16:55:05 +07:00
parent 6b113e0392
commit e9e6fadb1d
544 changed files with 113077 additions and 427 deletions

View File

@@ -1,103 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { ChatBubble } from './ChatBubble';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChatBubble } from './chat-bubble';
describe('ChatBubble', () => {
it('renders user variant correctly', () => {
it('renders user message 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();
expect(screen.getByText('Hello world')).toBeInTheDocument();
// Check for user-specific classes (ShadCN primary color usually implies dark text on light or vice versa depending on theme, but we check justification)
expect(container.querySelector('.justify-end')).toBeInTheDocument();
expect(container.querySelector('.bg-primary')).toBeInTheDocument();
});
it('renders ai variant correctly', () => {
it('renders assistant message correctly', () => {
const { container } = render(
<ChatBubble
role="ai"
role="assistant"
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();
expect(screen.getByText('AI response')).toBeInTheDocument();
expect(container.querySelector('.justify-start')).toBeInTheDocument();
expect(container.querySelector('.bg-card')).toBeInTheDocument();
});
it('renders system variant correctly', () => {
const { container } = render(
it('renders system message correctly', () => {
// System isn't explicitly handled differently in class logic other than being treated as "not user" (so left aligned),
// but let's verify it renders.
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();
expect(screen.getByText('System message')).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()}
role="assistant"
content={"Check this code:\n\n```\nconst x = 1;\n```"}
/>
);
// 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();
expect(screen.getByText('const x = 1;')).toBeInTheDocument();
// Check for pre tag
expect(container.querySelector('pre')).toBeInTheDocument();
});
it('displays timestamp for non-system messages', () => {
const timestamp = Date.now();
const { container } = render(
it('handles non-string content gracefully', () => {
// Imitate the bug where content is an object (cast to any to bypass TS)
const badContent = { foo: 'bar' } as any;
// This should NOT throw "Unexpected value" error
render(
<ChatBubble
role="user"
content="Test"
timestamp={timestamp}
role="assistant"
content={badContent}
/>
);
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();
// It should render "[object Object]" literally
expect(screen.getByText('[object Object]')).toBeInTheDocument();
});
});

View File

@@ -1,61 +0,0 @@
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

@@ -1,122 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChatWindow } from './chat-window';
// 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;
},
// Mock store hooks
vi.mock('@/store/use-session', () => ({
useTeacherStatus: vi.fn(() => 'idle'),
}));
import { ChatWindow } from './ChatWindow';
// Mock Dexie hooks
const mockMessages = [
{ id: 1, role: 'user', content: 'Hello', timestamp: 1000 },
{ id: 2, role: 'assistant', content: 'Hi there!', timestamp: 2000 },
];
vi.mock('dexie-react-hooks', () => ({
useLiveQuery: vi.fn((cb) => {
// If we wanted to test the callback, we'd mock db. But for UI testing,
// we can just return what we want the hook to return.
// However, existing check calls the callback.
// Let's rely on a variable we can change, or just mock return value.
// For simplicity in this file, let's assume it returns the global mockMessages var
// initialized in test blocks.
return (globalThis as any).mockLiveQueryValue;
}),
}));
// Mock db to avoid runtime errors if useLiveQuery callback is executed (though we mocked useLiveQuery)
vi.mock('@/lib/db/db', () => ({
db: {},
}));
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(),
};
(globalThis as any).mockLiveQueryValue = [];
});
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() },
];
it('renders loading state when no sessionId is provided', () => {
render(<ChatWindow sessionId={null} />);
expect(screen.getByText(/loading session/i)).toBeInTheDocument();
});
render(<ChatWindow />);
it('renders empty state when sessionId is provided but no messages', () => {
(globalThis as any).mockLiveQueryValue = [];
render(<ChatWindow sessionId="123" />);
// Updated text expectation
expect(screen.getByText(/what do you want to record?/i)).toBeInTheDocument();
expect(screen.getByText(/let me help you summarize your day/i)).toBeInTheDocument();
// Verify theme class
expect(screen.getByText(/what do you want to record?/i)).toHaveClass('text-foreground');
});
it('renders messages when they exist', () => {
(globalThis as any).mockLiveQueryValue = mockMessages;
render(<ChatWindow sessionId="123" />);
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();
});
it('scrolls to bottom on new messages', () => {
(globalThis as any).mockLiveQueryValue = mockMessages;
render(<ChatWindow sessionId="123" />);
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
});
});

View File

@@ -1,100 +0,0 @@
'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

@@ -18,8 +18,8 @@ export function ChatBubble({ role, content }: ChatBubbleProps) {
<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"
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-card border border-border text-card-foreground rounded-tl-sm"
)}>
{/* Render Markdown safely */}
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
@@ -44,7 +44,7 @@ export function ChatBubble({ role, content }: ChatBubbleProps) {
)
}}
>
{content}
{String(content)}
</ReactMarkdown>
</div>
</div>

View File

@@ -36,22 +36,22 @@ export function ChatInput({ onSend, isLoading }: ChatInputProps) {
};
return (
<div className="p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 sticky bottom-0">
<div className="p-4 bg-card/80 backdrop-blur-md border-t border-border 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"
placeholder="Record your thoughts..."
className="resize-none min-h-[44px] max-h-[120px] py-3 rounded-xl border-input focus:ring-ring"
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"
className="h-11 w-11 rounded-xl shrink-0 bg-slate-800 hover:bg-slate-700 transition-colors"
>
{isLoading ? <StopCircle className="h-5 w-5 animate-pulse" /> : <Send className="h-5 w-5" />}
</Button>

View File

@@ -6,6 +6,7 @@ import { db } from '@/lib/db/db';
import { ChatBubble } from './chat-bubble';
import { TypingIndicator } from './typing-indicator';
import { useTeacherStatus } from '@/store/use-session';
import { BookOpen, Sparkles } from 'lucide-react';
interface ChatWindowProps {
sessionId: string | null;
@@ -38,17 +39,28 @@ export function ChatWindow({ sessionId }: ChatWindowProps) {
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 className="flex-1 flex flex-col items-center justify-center text-center p-8 space-y-6">
<div className="relative">
<div className="w-32 h-32 bg-gradient-to-br from-secondary to-muted rounded-full flex items-center justify-center">
<BookOpen className="w-16 h-16 text-muted-foreground/50" aria-hidden="true" />
</div>
<Sparkles className="w-8 h-8 text-amber-400 absolute -top-2 -right-2" aria-hidden="true" />
</div>
<div className="space-y-2 max-w-md">
<h2 className="text-2xl font-bold font-serif text-foreground">
What do you want to record?
</h2>
<p className="text-muted-foreground font-sans">
Let me help you summarize your day.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-4 py-6 scroll-smooth">
<div className="h-full 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} />

View File

@@ -1,6 +1,6 @@
export { ChatBubble } from './ChatBubble';
export { ChatInput } from './ChatInput';
export { ChatWindow } from './ChatWindow';
export { TypingIndicator } from './TypingIndicator';
export { ChatBubble } from './chat-bubble';
export { ChatInput } from './chat-input';
export { ChatWindow } from './chat-window';
export { TypingIndicator } from './typing-indicator';
export { RefinementModeBadge } from './RefinementModeBadge';
export { RefinementIndicator } from './RefinementIndicator';

View File

@@ -60,7 +60,7 @@ export function DraftActions({ onApprove, onReject, onCopyOnly }: DraftActionsPr
<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"
className="flex-1 min-h-[44px] px-4 py-3 bg-slate-800 hover:bg-slate-700 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" />

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Trash2 } from 'lucide-react';
import { useChatStore } from '@/lib/store/chat-store';
import { Sheet } from './Sheet';
@@ -40,6 +40,11 @@ export function DraftViewSheet() {
const [toastShow, setToastShow] = useState(false);
const [toastMessage, setToastMessage] = useState('');
// Fix: Reset toast when opening a new draft
useEffect(() => {
setToastShow(false);
}, [currentDraft, showDraftView]);
const showCopyToast = (message: string = 'Copied to clipboard!') => {
setToastMessage(message);
setToastShow(true);

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { Copy, Check, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Copy, Check, X, Trash2 } 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';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
/**
* HistoryDetailSheet Component
@@ -17,11 +18,13 @@ import { Sheet } from '@/components/features/draft/Sheet';
* - Sheet component from DraftViewSheet (Story 2.2)
* - DraftContent component (Story 2.2)
* - CopyButton functionality (Story 2.4)
* - Delete functionality (Story 3.2.1)
*
* Features:
* - Displays full draft with Merriweather font
* - Copy button for clipboard export
* - Close button
* - Delete button
* - Swipe-to-dismiss support (via Sheet)
*
* Architecture Compliance:
@@ -31,14 +34,23 @@ import { Sheet } from '@/components/features/draft/Sheet';
export function HistoryDetailSheet() {
const selectedDraft = useHistoryStore((s) => s.selectedDraft);
const closeDetail = useHistoryStore((s) => s.closeDetail);
const deleteDraft = useHistoryStore((s) => s.deleteDraft);
// Reuse copy action from ChatStore
const copyDraftToClipboard = useChatStore((s) => s.copyDraftToClipboard);
// Dialog state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Toast state
const [toastShow, setToastShow] = useState(false);
const [toastMessage, setToastMessage] = useState('');
// Fix: Reset toast when opening a new draft
useEffect(() => {
setToastShow(false);
}, [selectedDraft]);
const showCopyToast = (message: string = 'Copied to clipboard!') => {
setToastMessage(message);
setToastShow(true);
@@ -51,6 +63,19 @@ export function HistoryDetailSheet() {
}
};
const handleDelete = async () => {
if (selectedDraft) {
const success = await deleteDraft(selectedDraft.id);
if (success) {
setShowDeleteDialog(false);
showCopyToast('Post deleted successfully');
} else {
setShowDeleteDialog(false);
showCopyToast('Failed to delete post');
}
}
};
const handleClose = () => {
closeDetail();
};
@@ -64,8 +89,19 @@ export function HistoryDetailSheet() {
<Sheet open={!!selectedDraft} onClose={handleClose}>
<DraftContent draft={selectedDraft} />
{/* Footer with copy and close buttons */}
{/* Footer with copy, delete and close buttons */}
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
{/* Delete button (Story 3.2.1) */}
<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 className="sr-only">Delete</span>
</button>
{/* Copy button */}
<button
onClick={handleCopy}
@@ -90,7 +126,15 @@ export function HistoryDetailSheet() {
</nav>
</Sheet>
{/* Toast for copy feedback */}
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
draftTitle={selectedDraft.title}
/>
{/* Toast for feedack */}
<CopySuccessToast
show={toastShow}
message={toastMessage}