fix: fix loading states, flickering and positioning on onboarding (#323)

* Removed background from dog

* Changed loading providers to be on tab

* Fixed case on add a document button

* fixed dog icon flickering

* Changed size of animated processing icon on tab trigger

* fixed max height on onboarding

* Fixed animated steps size

* Changed skip overview design

* disable autocomplete
This commit is contained in:
Lucas Oliveira 2025-10-28 18:06:29 -03:00 committed by GitHub
parent 9d55ecd717
commit d63fcb3843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 330 additions and 301 deletions

View file

@ -7,29 +7,29 @@ const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
// CSS for the stepped animation states
const animationCSS = `
.state1 { animation: showState1 600ms infinite; }
.state2 { animation: showState2 600ms infinite; }
.state3 { animation: showState3 600ms infinite; }
.state4 { animation: showState4 600ms infinite; }
.state1 { animation: showDogState1 600ms infinite; }
.state2 { animation: showDogState2 600ms infinite; }
.state3 { animation: showDogState3 600ms infinite; }
.state4 { animation: showDogState4 600ms infinite; }
@keyframes showState1 {
@keyframes showDogState1 {
0%, 24.99% { opacity: 1; }
25%, 100% { opacity: 0; }
}
@keyframes showState2 {
@keyframes showDogState2 {
0%, 24.99% { opacity: 0; }
25%, 49.99% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes showState3 {
@keyframes showDogState3 {
0%, 49.99% { opacity: 0; }
50%, 74.99% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes showState4 {
@keyframes showDogState4 {
0%, 74.99% { opacity: 0; }
75%, 100% { opacity: 1; }
}

View file

@ -46,7 +46,7 @@ export function AssistantMessage({
>
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 transition-colors duration-300"
disabled={isCompleted || isInactive}

View file

@ -1,5 +1,5 @@
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button";
@ -9,7 +9,6 @@ import {
PopoverContent,
} from "@/components/ui/popover";
import type { KnowledgeFilterData } from "../types";
import { useState } from "react";
import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
@ -81,7 +80,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
fileInputRef.current?.click();
},
}));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
@ -92,242 +91,247 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
};
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? 'flex-col' : 'flex-row items-center'
}`}>
{/* Filter + Textarea Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'w-full' : 'flex-1'}`}>
{textareaHeight <= 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
</div>
</div>
{/* Action Buttons Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'justify-between w-full' : ''}`}>
{textareaHeight > 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div className="flex items-center gap-2">
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Plus className="h-4 w-4" />
<Funnel className="h-4 w-4" />
</Button>
))}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
autoComplete="off"
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
variant="default"
type="submit"
type="button"
variant="ghost"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
<Funnel className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
);

View file

@ -80,7 +80,7 @@ export function AnimatedProviderSteps({
<div
className={cn(
"transition-all duration-150 relative",
isDone ? "w-3.5 h-3.5" : "w-3.5 h-2.5",
isDone ? "w-3.5 h-3.5" : "w-6 h-6",
)}
>
<CheckIcon
@ -110,7 +110,7 @@ export function AnimatedProviderSteps({
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex items-center gap-4 overflow-y-hidden relative h-6"
>
<div className="w-px h-6 bg-border ml-2" />
<div className="w-px h-6 bg-border ml-3" />
<div className="relative h-5 w-full">
<AnimatePresence mode="sync" initial={false}>
<motion.span

View file

@ -24,6 +24,7 @@ import { AnimatedProviderSteps } from "./animated-provider-steps";
import { IBMOnboarding } from "./ibm-onboarding";
import { OllamaOnboarding } from "./ollama-onboarding";
import { OpenAIOnboarding } from "./openai-onboarding";
import { TabTrigger } from "./tab-trigger";
interface OnboardingCardProps {
onComplete: () => void;
@ -191,84 +192,77 @@ const OnboardingCard = ({
>
<TabsList className="mb-4">
<TabsTrigger value="openai">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "openai" ? "bg-white" : "bg-muted",
)}
<TabTrigger
selected={modelProvider === "openai"}
isLoading={isLoadingModels}
>
<OpenAILogo
<div
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "openai"
? "text-black"
: "text-muted-foreground",
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "openai" ? "bg-white" : "bg-muted",
)}
/>
</div>
OpenAI
>
<OpenAILogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "openai"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
OpenAI
</TabTrigger>
</TabsTrigger>
<TabsTrigger value="watsonx">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "watsonx" ? "bg-[#1063FE]" : "bg-muted",
)}
<TabTrigger
selected={modelProvider === "watsonx"}
isLoading={isLoadingModels}
>
<IBMLogo
<div
className={cn(
"w-4 h-4 shrink-0",
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "watsonx"
? "text-white"
: "text-muted-foreground",
? "bg-[#1063FE]"
: "bg-muted",
)}
/>
</div>
IBM watsonx.ai
>
<IBMLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "watsonx"
? "text-white"
: "text-muted-foreground",
)}
/>
</div>
IBM watsonx.ai
</TabTrigger>
</TabsTrigger>
<TabsTrigger value="ollama">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "ollama" ? "bg-white" : "bg-muted",
)}
<TabTrigger
selected={modelProvider === "ollama"}
isLoading={isLoadingModels}
>
<OllamaLogo
<div
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "ollama"
? "text-black"
: "text-muted-foreground",
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "ollama" ? "bg-white" : "bg-muted",
)}
/>
</div>
Ollama
>
<OllamaLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "ollama"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
Ollama
</TabTrigger>
</TabsTrigger>
</TabsList>
<AnimatePresence>
{isLoadingModels && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.1, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="py-3">
<AnimatedProviderSteps
currentStep={loadingStep}
isCompleted={false}
setCurrentStep={setLoadingStep}
steps={[
"Connecting to the provider",
"Fetching language models",
"Fetching embedding models",
]}
storageKey="model-loading-steps"
/></div>
</motion.div>
)}
</AnimatePresence>
<TabsContent value="openai">
<OpenAIOnboarding
setSettings={setSettings}
@ -295,8 +289,6 @@ const OnboardingCard = ({
</TabsContent>
</Tabs>
<Tooltip>
<TooltipTrigger asChild>
<div>

View file

@ -74,7 +74,7 @@ export function OnboardingStep({
<div className="w-8 h-8 rounded-lg flex-shrink-0" />
) : (
icon || (
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 text-accent-foreground transition-colors duration-300"
disabled={isCompleted}

View file

@ -76,7 +76,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
onClick={handleUploadClick}
disabled={isUploading}
>
{isUploading ? "Uploading..." : "Add a Document"}
<div>{isUploading ? "Uploading..." : "Add a document"}</div>
</Button>
<input
ref={fileInputRef}

View file

@ -30,12 +30,12 @@ export function ProgressBar({ currentStep, totalSteps, onSkip }: ProgressBarProp
<div className="flex-1 flex justify-end">
{currentStep > 0 && onSkip && (
<Button
variant="ghost"
variant="link"
size="sm"
onClick={onSkip}
className="flex items-center gap-2 text-xs text-muted-foreground"
className="flex items-center gap-2 text-mmd !text-placeholder-foreground hover:!text-foreground hover:!no-underline"
>
Skip onboarding
Skip overview
<ArrowRight className="w-4 h-4" />
</Button>
)}

View file

@ -0,0 +1,33 @@
import AnimatedProcessingIcon from "@/components/ui/animated-processing-icon";
import { cn } from "@/lib/utils";
export function TabTrigger({
children,
selected,
isLoading,
}: {
children: React.ReactNode;
selected: boolean;
isLoading: boolean;
}) {
return (
<div className="flex flex-col relative items-start justify-between gap-4 h-full w-full">
<div
className={cn(
"flex absolute items-center justify-center h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-100" : "opacity-0",
)}
>
<AnimatedProcessingIcon className="text-current shrink-0 h-10 w-10" />
</div>
<div
className={cn(
"flex flex-col items-start justify-between gap-4 h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-0" : "opacity-100",
)}
>
{children}
</div>
</div>
);
}

View file

@ -156,7 +156,7 @@ export function ChatRenderer({
}}
className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
!showLayout && "absolute",
!showLayout && "absolute max-h-[calc(100vh-190px)]",
showLayout && !isOnChatPage && "bg-background",
)}
>
@ -199,7 +199,7 @@ export function ChatRenderer({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
className={cn("absolute bottom-10 left-0 right-0")}
className={cn("absolute bottom-6 left-0 right-0")}
>
<ProgressBar
currentStep={currentStep}

View file

@ -51,8 +51,8 @@ const AnimatedProcessingIcon = ({
return (
<svg
width="16"
height="16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"