feat(ux): Finalize advanced UX with plot zoom, export, and shadcn UI consistency.

This commit is contained in:
Yunxiao Xu
2026-02-13 03:58:55 -08:00
parent e16b19ed66
commit 69c75bd4fe
9 changed files with 578 additions and 131 deletions

View File

@@ -9,6 +9,7 @@ import { AuthService, type UserResponse } from "./services/auth"
import { ChatService, type MessageResponse } from "./services/chat"
import { type Conversation } from "./components/layout/HistorySidebar"
import { registerUnauthorizedCallback } from "./services/api"
import { Button } from "./components/ui/button"
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -179,12 +180,13 @@ function App() {
Welcome, {user?.display_name || user?.email || "User"}!
</h1>
</div>
<button
<Button
variant="link"
onClick={handleLogout}
className="text-sm text-muted-foreground hover:text-primary underline"
className="text-sm text-muted-foreground hover:text-primary h-auto p-0 font-normal underline"
>
Logout
</button>
</Button>
</div>
<div className="flex-1 min-h-0">
@@ -218,12 +220,12 @@ function App() {
<p className="text-sm text-muted-foreground">
Create a new conversation in the sidebar to start asking questions.
</p>
<button
<Button
onClick={handleCreateConversation}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90"
className="mt-4"
>
Start New Chat
</button>
</Button>
</div>
</div>
)}

View File

@@ -15,6 +15,8 @@ import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useState } from "react"
import { Separator } from "@/components/ui/separator"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AlertCircle } from "lucide-react"
import axios from "axios"
interface LoginFormProps {
@@ -70,9 +72,10 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-md">
{error}
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
@@ -121,13 +124,13 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
<div className="mt-4 text-center text-sm">
Don't have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
<Button
variant="link"
className="p-0 h-auto font-normal"
onClick={onToggleMode}
>
Register
</button>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -14,6 +14,8 @@ import {
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useState } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AlertCircle } from "lucide-react"
import axios from "axios"
interface RegisterFormProps {
@@ -62,9 +64,10 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-md">
{error}
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
@@ -112,13 +115,13 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) {
</Form>
<div className="mt-4 text-center text-sm">
Already have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
<Button
variant="link"
className="p-0 h-auto font-normal"
onClick={onToggleMode}
>
Login
</button>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -1,6 +1,10 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { type MessageResponse } from "@/services/chat"
import { MarkdownContent } from "./MarkdownContent"
import { PlotModal } from "./PlotModal"
import { Maximize2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
interface MessageBubbleProps {
message: MessageResponse
@@ -8,6 +12,7 @@ interface MessageBubbleProps {
export function MessageBubble({ message }: MessageBubbleProps) {
const isAssistant = message.role === "assistant"
const [selectedPlot, setSelectedPlot] = React.useState<string | null>(null)
return (
<div
@@ -44,31 +49,57 @@ export function MessageBubble({ message }: MessageBubbleProps) {
{(message.plots || message.plot_ids) && ((message.plots?.length || 0) > 0 || (message.plot_ids?.length || 0) > 0) && (
<div className="mt-4 grid grid-cols-1 gap-2">
{message.plots?.map((plot, index) => (
<img
key={`stream-${index}`}
src={`data:image/png;base64,${plot}`}
alt="Analysis Plot"
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
// TODO: Open in modal (Phase 5)
}}
/>
))}
{message.plot_ids?.map((plotId, index) => (
<img
key={`history-${index}`}
src={`${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}`}
alt="Historical Analysis Plot"
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
// TODO: Open in modal (Phase 5)
}}
/>
))}
{message.plots?.map((plot, index) => {
const src = `data:image/png;base64,${plot}`
return (
<Button
key={`stream-${index}`}
variant="ghost"
className="relative group p-0 h-auto w-full overflow-hidden hover:bg-transparent"
onClick={() => setSelectedPlot(src)}
>
<img
src={src}
alt="Analysis Plot"
className="rounded-md border bg-white w-full h-auto transition-all group-hover:brightness-95"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
<Maximize2Icon className="h-6 w-6 text-white drop-shadow-md" />
</div>
</Button>
)
})}
{message.plot_ids?.map((plotId, index) => {
const src = `${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}`
return (
<Button
key={`history-${index}`}
variant="ghost"
className="relative group p-0 h-auto w-full overflow-hidden hover:bg-transparent"
onClick={() => setSelectedPlot(src)}
>
<img
src={src}
alt="Historical Analysis Plot"
className="rounded-md border bg-white w-full h-auto transition-all group-hover:brightness-95"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
<Maximize2Icon className="h-6 w-6 text-white drop-shadow-md" />
</div>
</Button>
)
})}
</div>
)}
</div>
{selectedPlot && (
<PlotModal
src={selectedPlot}
isOpen={true}
onClose={() => setSelectedPlot(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { DownloadIcon } from "lucide-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
interface PlotModalProps {
src: string
isOpen: boolean
onClose: () => void
title?: string
}
export function PlotModal({ src, isOpen, onClose, title = "Analysis Plot" }: PlotModalProps) {
const handleDownload = () => {
const link = document.createElement("a")
link.href = src
link.download = `${title.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="p-4 border-b bg-muted/30">
<div className="flex items-center justify-between gap-4">
<div>
<DialogTitle className="text-base">{title}</DialogTitle>
<DialogDescription className="text-xs">
Generated analysis visualization
</DialogDescription>
</div>
<Button size="sm" variant="outline" className="gap-2 shrink-0" onClick={handleDownload}>
<DownloadIcon className="h-4 w-4" />
Download PNG
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto bg-white flex items-center justify-center p-4">
<img
src={src}
alt={title}
className="max-w-full h-auto object-contain shadow-sm border rounded-sm"
/>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -27,6 +27,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -55,6 +65,7 @@ export function HistorySidebar({
}: HistorySidebarProps) {
const [editingId, setEditingId] = React.useState<string | null>(null)
const [editValue, setEditValue] = React.useState("")
const [deleteId, setDeleteId] = React.useState<string | null>(null)
const handleStartRename = (conv: Conversation) => {
setEditingId(conv.id)
@@ -72,95 +83,115 @@ export function HistorySidebar({
setEditingId(null)
}
const handleDelete = (id: string) => {
if (confirm("Are you sure you want to delete this conversation?")) {
onDelete(id)
const handleDelete = () => {
if (deleteId) {
onDelete(deleteId)
setDeleteId(null)
}
}
return (
<Sidebar role="complementary">
<SidebarHeader className="border-b p-4">
<Button
onClick={onCreate}
className="w-full justify-start gap-2"
variant="outline"
>
<PlusIcon className="h-4 w-4" />
New Chat
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recent Conversations</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{conversations.map((conv) => (
<SidebarMenuItem key={conv.id}>
{editingId === conv.id ? (
<div className="flex items-center gap-1 p-1 w-full">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename()
if (e.key === "Escape") handleCancelRename()
}}
/>
<Button size="icon-xs" variant="ghost" onClick={handleConfirmRename}>
<CheckIcon className="h-3 w-3" />
</Button>
<Button size="icon-xs" variant="ghost" onClick={handleCancelRename}>
<XIcon className="h-3 w-3" />
</Button>
</div>
) : (
<>
<SidebarMenuButton
isActive={selectedId === conv.id}
onClick={() => onSelect(conv.id)}
tooltip={conv.name}
>
<MessageSquareIcon className="h-4 w-4" />
<span>{conv.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreVerticalIcon className="h-4 w-4" />
<span className="sr-only">Conversation actions</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleStartRename(conv)}>
<PencilIcon className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(conv.id)}
className="text-destructive focus:text-destructive"
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-4">
<div className="text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
<>
<Sidebar role="complementary">
<SidebarHeader className="border-b p-4">
<Button
onClick={onCreate}
className="w-full justify-start gap-2"
variant="outline"
>
<PlusIcon className="h-4 w-4" />
New Chat
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recent Conversations</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{conversations.map((conv) => (
<SidebarMenuItem key={conv.id}>
{editingId === conv.id ? (
<div className="flex items-center gap-1 p-1 w-full">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleConfirmRename()
if (e.key === "Escape") handleCancelRename()
}}
/>
<Button size="icon-xs" variant="ghost" onClick={handleConfirmRename}>
<CheckIcon className="h-3 w-3" />
</Button>
<Button size="icon-xs" variant="ghost" onClick={handleCancelRename}>
<XIcon className="h-3 w-3" />
</Button>
</div>
) : (
<>
<SidebarMenuButton
isActive={selectedId === conv.id}
onClick={() => onSelect(conv.id)}
tooltip={conv.name}
>
<MessageSquareIcon className="h-4 w-4" />
<span>{conv.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreVerticalIcon className="h-4 w-4" />
<span className="sr-only">Conversation actions</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleStartRename(conv)}>
<PencilIcon className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteId(conv.id)}
className="text-destructive focus:text-destructive"
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-4">
<div className="text-xs text-muted-foreground">© 2026 Election Analytics</div>
</SidebarFooter>
</Sidebar>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the conversation history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,194 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-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 AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
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 group/alert-dialog-content 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 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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/80",
className
)}
{...props}
/>
))
DialogOverlay.displayName = "DialogOverlay"
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg transition-all duration-200 sm:rounded-lg md:w-full",
className
)}
{...props}
>
{children}
<DialogPrimitive.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-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = "DialogContent"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = "DialogTitle"
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
))
DialogDescription.displayName = "DialogDescription"
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,10 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"
import api from "./api"
import { ChatService } from "./chat"
import { AxiosResponse } from "axios"
import type { AxiosResponse } from "axios"
vi.mock("./api")
const mockedApi = vi.mocked(api)
const mockedApi = api as unknown as {
get: MockInstance<typeof api.get>
post: MockInstance<typeof api.post>
patch: MockInstance<typeof api.patch>
delete: MockInstance<typeof api.delete>
}
describe("ChatService History Management", () => {
beforeEach(() => {