VibeGame / src /lib /components /chat /InProgressBlock.svelte
dylanebert's picture
improve chat
3342a1d
raw
history blame
11.9 kB
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import gsap from "gsap";
export let status: "thinking" | "streaming" | "completing" = "thinking";
export let content: string = "";
export let startTime: number = Date.now();
export let isExpanded: boolean = false;
export let autoCollapse: boolean = true;
let blockElement: HTMLDivElement;
let contentElement: HTMLDivElement;
let expandIcon: HTMLSpanElement;
let statusElement: HTMLSpanElement;
let progressBar: HTMLDivElement;
let collapseTimeout: number | null = null;
let prevStatus: string = status;
let timeline: gsap.core.Timeline | null = null;
$: duration = Date.now() - startTime;
$: formattedDuration = formatDuration(duration);
$: if (status !== prevStatus && statusElement) {
animateStatusTransition(prevStatus, status);
prevStatus = status;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function toggleExpanded() {
isExpanded = !isExpanded;
updateExpandState();
}
function updateExpandState() {
if (!expandIcon || !contentElement) return;
if (timeline) timeline.kill();
timeline = gsap.timeline();
if (isExpanded) {
timeline
.to(expandIcon, {
rotation: 90,
duration: 0.2,
ease: "power2.out",
})
.set(contentElement, { display: "block" })
.fromTo(
contentElement,
{ opacity: 0, maxHeight: 0, y: -10 },
{ opacity: 1, maxHeight: 500, y: 0, duration: 0.3, ease: "power2.out" },
"-=0.1"
);
} else {
timeline
.to(expandIcon, {
rotation: 0,
duration: 0.2,
ease: "power2.in",
})
.to(contentElement, {
opacity: 0,
maxHeight: 0,
y: -5,
duration: 0.2,
ease: "power2.in",
onComplete: () => {
gsap.set(contentElement, { display: "none" });
},
}, "-=0.1");
}
}
function animateStatusTransition(_from: string, to: string) {
if (!statusElement || !blockElement) return;
const tl = gsap.timeline();
tl.to(blockElement, {
duration: 0.4,
ease: "power2.inOut",
onUpdate: function() {
updateBlockStyle(to);
}
});
tl.to(statusElement, {
scale: 0.9,
opacity: 0,
duration: 0.15,
ease: "power2.in",
onComplete: () => {}
})
.to(statusElement, {
scale: 1,
opacity: 1,
duration: 0.15,
ease: "power2.out"
});
if (progressBar) {
if (to === "streaming") {
gsap.to(progressBar, {
width: "60%",
duration: 1,
ease: "power2.out"
});
} else if (to === "completing") {
gsap.to(progressBar, {
width: "90%",
duration: 0.5,
ease: "power2.out"
});
}
}
}
function updateBlockStyle(status: string) {
if (!blockElement) return;
blockElement.className = `in-progress-block ${status}`;
}
function getStatusIcon() {
switch (status) {
case "thinking":
return "🤔";
case "streaming":
return "✍️";
case "completing":
return "✨";
default:
return "⚡";
}
}
function getStatusText() {
switch (status) {
case "thinking":
return "Thinking";
case "streaming":
return "Writing response";
case "completing":
return "Finalizing response";
default:
return "Processing";
}
}
$: if (status === "completing" && autoCollapse && isExpanded) {
if (collapseTimeout) clearTimeout(collapseTimeout);
collapseTimeout = window.setTimeout(() => {
isExpanded = false;
updateExpandState();
}, 500);
}
onMount(() => {
if (blockElement) {
gsap.fromTo(
blockElement,
{ opacity: 0, y: -10, scale: 0.95 },
{ opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "back.out(1.2)" },
);
}
if (progressBar) {
gsap.fromTo(progressBar,
{ width: "0%" },
{ width: "30%", duration: 0.5, ease: "power2.out" }
);
}
const interval = setInterval(() => {
duration = Date.now() - startTime;
}, 100);
return () => clearInterval(interval);
});
onDestroy(() => {
if (collapseTimeout) clearTimeout(collapseTimeout);
if (timeline) timeline.kill();
});
</script>
<div class="in-progress-block {status}" bind:this={blockElement}>
<div class="progress-bar-track">
<div bind:this={progressBar} class="progress-bar"></div>
</div>
<button class="progress-header" on:click={toggleExpanded} aria-expanded={isExpanded}>
<span class="status-icon">
{#if status === "streaming"}
<span class="animated-icon">{getStatusIcon()}</span>
{:else if status === "thinking"}
<span class="spinning-icon">{getStatusIcon()}</span>
{:else}
{getStatusIcon()}
{/if}
</span>
<span bind:this={statusElement} class="status-text">
{getStatusText()}<span class="dots"></span>
</span>
<span class="progress-meta">
<span class="duration">{formattedDuration}</span>
{#if content}
<span class="char-count">{content.length} chars</span>
{/if}
</span>
<span bind:this={expandIcon} class="expand-icon"></span>
</button>
<div bind:this={contentElement} class="progress-content" style="display: none;">
{#if content}
<div class="content-wrapper">
<div class="content-text">{content}</div>
<span class="cursor"></span>
</div>
{:else}
<div class="waiting-message">Waiting for response to begin...</div>
{/if}
</div>
</div>
<style>
.in-progress-block {
margin: 0.4rem 0;
border-radius: 4px;
overflow: hidden;
border: 1px solid rgba(255, 210, 30, 0.2);
background: rgba(255, 210, 30, 0.05);
transition: all 0.2s ease;
position: relative;
}
.in-progress-block.thinking {
border-color: rgba(255, 210, 30, 0.3);
background: rgba(255, 210, 30, 0.08);
}
.in-progress-block.streaming {
border-color: rgba(65, 105, 225, 0.3);
background: rgba(65, 105, 225, 0.08);
}
.in-progress-block.completing {
border-color: rgba(0, 255, 0, 0.2);
background: rgba(0, 255, 0, 0.05);
}
.progress-bar-track {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg,
rgba(255, 210, 30, 0.6) 0%,
rgba(65, 105, 225, 0.6) 50%,
rgba(0, 255, 0, 0.6) 100%);
box-shadow: 0 0 10px rgba(65, 105, 225, 0.4);
transition: width 0.3s ease;
}
.in-progress-block.thinking .progress-bar {
background: rgba(255, 210, 30, 0.6);
box-shadow: 0 0 10px rgba(255, 210, 30, 0.4);
}
.in-progress-block.streaming .progress-bar {
background: rgba(65, 105, 225, 0.6);
box-shadow: 0 0 10px rgba(65, 105, 225, 0.4);
}
.in-progress-block.completing .progress-bar {
background: rgba(0, 255, 0, 0.6);
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
}
.progress-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.2s ease;
}
.progress-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.status-icon {
font-size: 1rem;
width: 1.5rem;
text-align: center;
}
.spinning-icon {
display: inline-block;
animation: spin 1s linear infinite;
}
.animated-icon {
display: inline-block;
animation: bounce 1s ease-in-out infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-2px);
}
}
.status-text {
flex: 1;
color: rgba(255, 255, 255, 0.9);
font-size: 0.875rem;
}
.dots::after {
content: "";
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: "";
}
40% {
content: ".";
}
60% {
content: "..";
}
80%,
100% {
content: "...";
}
}
.progress-meta {
display: flex;
gap: 1rem;
align-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
font-family: "Monaco", "Menlo", monospace;
}
.duration {
min-width: 3rem;
}
.char-count {
opacity: 0.7;
}
.expand-icon {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
transition: transform 0.2s ease;
transform-origin: center;
}
.progress-content {
padding: 0.75rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
max-height: 300px;
overflow-y: auto;
}
.content-wrapper {
font-family: "Monaco", "Menlo", monospace;
font-size: 0.8rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
}
.content-text {
white-space: pre-wrap;
word-wrap: break-word;
display: inline;
}
.cursor {
display: inline-block;
animation: blink 1s infinite;
color: rgba(65, 105, 225, 0.8);
font-weight: bold;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.waiting-message {
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
font-size: 0.85rem;
padding: 1rem;
}
::-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>