feat(ui): enhance chat UI with new components and styling

refactor(chat): improve message display and actions
feat(chat): add high contrast mode toggle
style(chat): update spacing and sizing for better readability
feat(chat): add new Section and Stack layout components
docs: add UI style guide documentation
fix(chat): improve browser connection handling in scraper
feat(settings): expand default sources and improve UI
refactor: consolidate chat page layout and components
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-20 15:13:54 -03:00
1 parent c06e7ae5cf
commit 4006a72eec
20 files changed
+741 -235

No files matched your search

+71 -12
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Square, Globe, WifiOff } from "lucide-react";
import { Send, Square, Globe, WifiOff, ChevronUp } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import {
Tooltip,
@@ -15,6 +15,8 @@ interface ChatInputProps {
isLoading: boolean;
webSearchEnabled?: boolean;
onWebSearchToggle?: (enabled: boolean) => void;
categories?: SearchCategory[];
onToggleCategory?: (id: string, enabled: boolean) => void;
}
export function ChatInput({
@@ -23,9 +25,12 @@ export function ChatInput({
isLoading,
webSearchEnabled = true,
onWebSearchToggle,
categories = [],
onToggleCategory,
}: ChatInputProps) {
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [webDialogOpen, setWebDialogOpen] = useState(false);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -49,8 +54,8 @@ export function ChatInput({
}, [input]);
return (
<div className="p-4 border-t bg-background">
<div className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
<div className="p-6 bg-transparent">
<div className="relative max-w-3xl mx-auto w-full">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
@@ -58,7 +63,7 @@ export function ChatInput({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
className="min-h-[48px] resize-none overflow-hidden py-4 pr-12 rounded-xl border shadow-lg"
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center gap-2">
@@ -71,16 +76,9 @@ export function ChatInput({
variant={webSearchEnabled ? "default" : "ghost"}
onClick={() => {
if (!webSearchEnabled) {
// Ao ativar, não precisa de confirmação
onWebSearchToggle(true);
} else {
// Ao desativar, mostrar aviso
const confirmed = window.confirm(
"Atenção: Desativar a pesquisa web pode reduzir a qualidade das respostas para fatos recentes, especialmente em modelos menores. Deseja continuar?"
);
if (confirmed) {
onWebSearchToggle(false);
}
setWebDialogOpen(true);
}
}}
className="h-8 w-8"
@@ -103,6 +101,28 @@ export function ChatInput({
</Tooltip>
</TooltipProvider>
)}
{onToggleCategory && categories && categories.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" className="h-8 w-8" title="Gerenciar fontes">
<ChevronUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
<DropdownMenuSeparator />
{categories.map((cat) => (
<DropdownMenuCheckboxItem
key={cat.id}
checked={cat.enabled}
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
>
{cat.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
@@ -120,6 +140,26 @@ export function ChatInput({
</div>
</div>
</div>
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Desativar pesquisa web?</DialogTitle>
<DialogDescription>
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes, preços, notícias e conteúdo em rápida mudança.
Use com cautela — modelos locais sem contexto web tendem a alucinar mais nesses cenários.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setWebDialogOpen(false)}>Manter ativado</Button>
<Button
variant="destructive"
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
>
Desativar pesquisa web
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="text-xs text-center text-muted-foreground mt-2">
OllaHub pode cometer erros. Verifique informações importantes.
</div>
@@ -128,4 +168,23 @@ export function ChatInput({
}
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { SearchCategory } from "@/store/settings-store";
+13 -9
View File
@@ -1,29 +1,35 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useState } from 'react';
import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react";
import { markdownRenderers } from "./markdown-renderers";
import { MessageActions } from "./message-actions";
import { MessageBackground } from "./message-background";
export function ChatMessage({ message }: { message: Message }) {
const isUser = message.role === 'user';
const [selected, setSelected] = useState(false);
return (
<div className={cn(
"group flex w-full gap-4 p-6 relative",
isUser ? "bg-background" : "bg-muted/30"
)}>
<MessageBackground
role={isUser ? 'user' : 'assistant'}
state={selected ? 'selected' : 'normal'}
className={cn("cursor-pointer")}
onClick={() => setSelected((v) => !v)}
tabIndex={0}
>
<div className="flex-shrink-0 mt-1">
<div className={cn(
"w-8 h-8 rounded-sm flex items-center justify-center",
"w-8 h-8 rounded-md flex items-center justify-center",
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
)}>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
</div>
<div className="flex-1 overflow-hidden min-w-0">
<div className="prose dark:prose-invert max-w-none break-words">
<div className="prose dark:prose-invert max-w-none break-words leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
@@ -55,13 +61,11 @@ export function ChatMessage({ message }: { message: Message }) {
</ReactMarkdown>
</div>
</div>
{/* Actions Toolbar - Absolute positioned or flex end */}
{!isUser && (
<div className="absolute top-4 right-4">
<MessageActions content={message.content} role={message.role} />
</div>
)}
</div>
</MessageBackground>
);
}
+28 -2
View File
@@ -5,8 +5,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Copy, FileJson, FileText, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { Copy, FileJson, FileText, MoreHorizontal, Contrast } from "lucide-react";
import { useEffect, useState } from "react";
interface MessageActionsProps {
content: string;
@@ -15,6 +15,16 @@ interface MessageActionsProps {
export function MessageActions({ content, role }: MessageActionsProps) {
const [copied, setCopied] = useState(false);
const [highContrast, setHighContrast] = useState(false);
useEffect(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('highContrast') === 'true' : false;
setHighContrast(saved);
if (typeof document !== 'undefined') {
if (saved) document.documentElement.setAttribute('data-high-contrast', 'true');
else document.documentElement.removeAttribute('data-high-contrast');
}
}, []);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
@@ -73,6 +83,22 @@ export function MessageActions({ content, role }: MessageActionsProps) {
<FileJson className="mr-2 h-4 w-4" />
<span>Copiar JSON</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const next = !highContrast;
setHighContrast(next);
if (typeof document !== 'undefined') {
if (next) document.documentElement.setAttribute('data-high-contrast', 'true');
else document.documentElement.removeAttribute('data-high-contrast');
}
if (typeof window !== 'undefined') {
localStorage.setItem('highContrast', String(next));
}
}}
>
<Contrast className="mr-2 h-4 w-4" />
<span>{highContrast ? 'Desativar Alto Contraste' : 'Ativar Alto Contraste'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type MessageRole = "user" | "assistant";
type MessageVisualState = "normal" | "highlighted" | "selected";
interface MessageBackgroundProps extends React.ComponentProps<"div"> {
role: MessageRole;
state?: MessageVisualState;
highContrast?: boolean;
}
export function MessageBackground({
role,
state = "normal",
highContrast = false,
className,
children,
...props
}: MessageBackgroundProps) {
const base = "group flex w-full gap-3 md:gap-4 relative transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
const roleCl = role === "user"
? "rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-3"
: "bg-transparent px-0 py-0";
const stateCl = state === "selected" ? "ring-2 ring-primary/40" : state === "highlighted" ? "ring-1 ring-accent/30" : "";
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
return (
<div
data-selected={state === "selected"}
className={cn(base, roleCl, stateCl, hc, className)}
{...props}
>
{children}
</div>
);
}
+1 -1
View File
@@ -130,7 +130,7 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
className="min-h-[150px]"
className="min-h-[160px]"
disabled={isGenerating}
/>
</div>
+21 -24
View File
@@ -1,6 +1,5 @@
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ScrapedContent } from '@/services/webSearch';
import { SearchStatus } from '@/hooks/use-web-search';
@@ -73,24 +72,23 @@ export function SearchProgress({
const showExpandButton = status === 'completed' && sources.length > 0;
return (
<Card className={cn(
"mx-4 mb-2 border border-border/50 bg-muted/30 transition-all",
status === 'completed' && "opacity-60 hover:opacity-100",
status === 'error' && "border-destructive/30 bg-destructive/5"
<div className={cn(
"mx-6 mb-2 transition-all",
status === 'completed' && "opacity-70 hover:opacity-100",
status === 'error' && "text-destructive"
)}>
<CardContent className="p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{isActive ? (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
) : (
<div className="w-3 h-3">
{getStatusIcon()}
</div>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
<div className="p-2 pl-3 border-l-2 border-muted flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{isActive ? (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
) : (
<div className="w-3 h-3">
{getStatusIcon()}
</div>
)}
</div>
<span className="text-muted-foreground truncate">
{getStatusText()}
</span>
</div>
@@ -100,7 +98,7 @@ export function SearchProgress({
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-7 w-7 p-0 flex-shrink-0"
className="h-8 w-8 p-0 flex-shrink-0"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
@@ -109,15 +107,15 @@ export function SearchProgress({
)}
</Button>
)}
</div>
</div>
{/* Lista de fontes expandida */}
{isExpanded && sources.length > 0 && (
<div className="mt-3 pt-3 border-t space-y-2">
<div className="mt-2 ml-6 pl-3 border-l space-y-2">
{sources.map((source, idx) => (
<div
key={idx}
className="flex items-start gap-2 p-2 rounded-md bg-background/50 hover:bg-background/80 transition-colors"
className="flex items-start gap-2 p-2 rounded-md bg-muted/30 hover:bg-muted/50 transition-colors"
>
<img
src={getFaviconUrl(source.url)}
@@ -145,8 +143,7 @@ export function SearchProgress({
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+2 -2
View File
@@ -59,8 +59,8 @@ export function ThinkingIndicator({
}
return (
<div className="px-6 py-3 border-b border-muted/50 bg-muted/20">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="px-6 py-4 border-b border-muted/50 bg-muted/20">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
+1 -1
View File
@@ -74,7 +74,7 @@ export function WebSearchStatus({
status === 'error' && "border-l-red-500",
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
)}>
<CardContent className="p-3">
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">