VibeGame / src /lib /components /chat /MessageList.svelte
dylanebert's picture
improved prompting/UX
db9635c
<script lang="ts">
import { createEventDispatcher, afterUpdate } from "svelte";
import gsap from "gsap";
import type { ChatMessage } from "../../models/chat-data";
import Message from "./Message.svelte";
import ExampleMessages from "./ExampleMessages.svelte";
import { typingIndicator } from "../../stores/chat-store";
export let messages: ReadonlyArray<ChatMessage> = [];
export let error: string | null = null;
export let autoScroll: boolean = true;
export let isConnected: boolean = false;
export let onSendMessage: ((message: string) => void) | undefined = undefined;
let typingIndicatorRef: HTMLDivElement;
const dispatch = createEventDispatcher();
let messagesDiv: HTMLDivElement;
function handleScroll() {
const isNearBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 50;
dispatch("scroll", { nearBottom: isNearBottom });
}
export function scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
afterUpdate(() => {
if (messages.length > 0 && autoScroll) {
scrollToBottom();
}
});
$: if ($typingIndicator && typingIndicatorRef) {
gsap.fromTo(typingIndicatorRef,
{ opacity: 0, y: 10, scale: 0.95 },
{ opacity: 1, y: 0, scale: 1, duration: 0.3, ease: "power2.out" }
);
const dots = typingIndicatorRef.querySelectorAll('.dot');
gsap.to(dots, {
y: -3,
duration: 0.6,
stagger: 0.15,
repeat: -1,
yoyo: true,
ease: "power1.inOut"
});
}
$: if (!$typingIndicator && typingIndicatorRef) {
gsap.killTweensOf(typingIndicatorRef.querySelectorAll('.dot'));
gsap.to(typingIndicatorRef, {
opacity: 0,
y: -5,
scale: 0.95,
duration: 0.2,
ease: "power2.in"
});
}
</script>
<div class="messages" bind:this={messagesDiv} on:scroll={handleScroll}>
{#if messages.length === 0 && isConnected && onSendMessage}
<div class="welcome-section">
<ExampleMessages />
</div>
{/if}
{#each messages as message (message.id)}
<Message {message} />
{/each}
{#if $typingIndicator}
<div class="typing-indicator" bind:this={typingIndicatorRef}>
<div class="typing-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<span class="typing-text">AI is thinking...</span>
</div>
{/if}
{#if error}
<div class="error-message">Error: {error}</div>
{/if}
</div>
<style>
.messages {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 0;
position: relative;
}
.welcome-section {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin: 0.25rem 0;
border-left: 2px solid rgba(33, 150, 243, 0.3);
background: linear-gradient(90deg,
rgba(33, 150, 243, 0.05),
transparent);
border-radius: 0 4px 4px 0;
position: relative;
overflow: hidden;
}
.typing-indicator::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
transparent,
rgba(33, 150, 243, 0.1),
transparent);
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
to { left: 100%; }
}
.typing-dots {
display: flex;
gap: 0.3rem;
align-items: center;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.8),
rgba(100, 181, 246, 0.6));
display: inline-block;
position: relative;
}
.typing-text {
font-size: 11px;
color: rgba(251, 248, 244, 0.4);
font-style: normal;
letter-spacing: 0.3px;
animation: fade-pulse 2s ease-in-out infinite;
}
@keyframes fade-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.error-message {
background: rgba(244, 67, 54, 0.1);
border-left: 2px solid #f44336;
border-radius: 3px;
padding: 0.4rem 0.6rem;
color: #ff9999;
font-size: 0.875rem;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>