← back to Exo
dashboard/src/lib/components/ChatMessages.svelte
851 lines
<script lang="ts">
import {
messages,
currentResponse,
isLoading,
prefillProgress,
deleteMessage,
editAndRegenerate,
regenerateLastResponse,
regenerateFromToken,
setEditingImage,
} from "$lib/stores/app.svelte";
import type { MessageAttachment } from "$lib/stores/app.svelte";
import MarkdownContent from "./MarkdownContent.svelte";
import TokenHeatmap from "./TokenHeatmap.svelte";
import PrefillProgressBar from "./PrefillProgressBar.svelte";
import ImageLightbox from "./ImageLightbox.svelte";
interface Props {
class?: string;
scrollParent?: HTMLElement | null;
}
let { class: className = "", scrollParent = null }: Props = $props();
const messageList = $derived(messages());
const response = $derived(currentResponse());
const loading = $derived(isLoading());
const prefill = $derived(prefillProgress());
// Scroll management - user controls scroll, show button when not at bottom
const SCROLL_THRESHOLD = 100;
let showScrollButton = $state(false);
let lastMessageCount = 0;
let containerRef: HTMLDivElement | undefined = $state();
function getScrollContainer(): HTMLElement | null {
if (scrollParent) return scrollParent;
return containerRef?.parentElement ?? null;
}
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
}
function scrollToBottom() {
const el = getScrollContainer();
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}
function updateScrollButtonVisibility() {
const el = getScrollContainer();
if (!el) return;
showScrollButton = !isNearBottom(el);
}
// Attach scroll listener
$effect(() => {
const el = scrollParent ?? containerRef?.parentElement;
if (!el) return;
el.addEventListener("scroll", updateScrollButtonVisibility, {
passive: true,
});
// Initial check
updateScrollButtonVisibility();
return () => el.removeEventListener("scroll", updateScrollButtonVisibility);
});
// Auto-scroll when user sends a new message
$effect(() => {
const count = messageList.length;
if (count > lastMessageCount) {
const el = getScrollContainer();
if (el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
});
}
}
lastMessageCount = count;
});
// Update scroll button visibility when content changes
$effect(() => {
// Track response to trigger re-check during streaming
const _ = response;
// Small delay to let DOM update
requestAnimationFrame(() => updateScrollButtonVisibility());
});
// Edit state
let editingMessageId = $state<string | null>(null);
let editContent = $state("");
let editTextareaRef: HTMLTextAreaElement | undefined = $state();
// Delete confirmation state
let deleteConfirmId = $state<string | null>(null);
// Copied state for feedback
let copiedMessageId = $state<string | null>(null);
let expandedThinkingMessageIds = $state<Set<string>>(new Set());
// Lightbox state
let expandedImageSrc = $state<string | null>(null);
// Uncertainty heatmap toggle
let heatmapMessageIds = $state<Set<string>>(new Set());
function toggleHeatmap(messageId: string) {
const next = new Set(heatmapMessageIds);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
heatmapMessageIds = next;
}
function isHeatmapVisible(messageId: string): boolean {
return heatmapMessageIds.has(messageId);
}
function formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function getAttachmentIcon(attachment: MessageAttachment): string {
switch (attachment.type) {
case "image":
return "🖼";
case "text":
return "📄";
case "pdf":
return "📑";
default:
return "📎";
}
}
function truncateName(name: string, maxLen: number = 25): string {
if (name.length <= maxLen) return name;
const ext = name.slice(name.lastIndexOf("."));
const base = name.slice(0, name.lastIndexOf("."));
const available = maxLen - ext.length - 3;
return base.slice(0, available) + "..." + ext;
}
async function handleCopy(content: string, messageId: string) {
try {
await navigator.clipboard.writeText(content);
copiedMessageId = messageId;
setTimeout(() => {
copiedMessageId = null;
}, 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
}
function toggleThinkingVisibility(messageId: string) {
const next = new Set(expandedThinkingMessageIds);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
expandedThinkingMessageIds = next;
}
function isThinkingExpanded(messageId: string): boolean {
return expandedThinkingMessageIds.has(messageId);
}
function handleStartEdit(messageId: string, content: string) {
editingMessageId = messageId;
editContent = content;
setTimeout(() => {
if (editTextareaRef) {
editTextareaRef.focus();
editTextareaRef.setSelectionRange(
editTextareaRef.value.length,
editTextareaRef.value.length,
);
// Auto-resize
editTextareaRef.style.height = "auto";
editTextareaRef.style.height =
Math.min(editTextareaRef.scrollHeight, 200) + "px";
}
}, 10);
}
function handleCancelEdit() {
editingMessageId = null;
editContent = "";
}
function handleSaveEdit() {
if (editingMessageId && editContent.trim()) {
editAndRegenerate(editingMessageId, editContent.trim());
}
editingMessageId = null;
editContent = "";
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSaveEdit();
} else if (event.key === "Escape") {
handleCancelEdit();
}
}
function handleEditInput() {
if (editTextareaRef) {
editTextareaRef.style.height = "auto";
editTextareaRef.style.height =
Math.min(editTextareaRef.scrollHeight, 200) + "px";
}
}
function handleDeleteClick(messageId: string) {
if (loading) return;
deleteConfirmId = messageId;
}
function handleConfirmDelete() {
if (deleteConfirmId) {
deleteMessage(deleteConfirmId);
deleteConfirmId = null;
}
}
function handleCancelDelete() {
deleteConfirmId = null;
}
function handleRegenerate() {
regenerateLastResponse();
}
// Check if a message is the last assistant message
function isLastAssistantMessage(messageId: string): boolean {
for (let i = messageList.length - 1; i >= 0; i--) {
if (messageList[i].role === "assistant") {
return messageList[i].id === messageId;
}
}
return false;
}
</script>
<div class="flex flex-col gap-4 sm:gap-6 {className}">
{#each messageList as message, i (message.id)}
<div
class="group flex {message.role === 'user'
? 'justify-end'
: 'justify-start'}"
>
<div
class={message.role === "user"
? "max-w-[85%] sm:max-w-[70%] flex flex-col items-end"
: "w-full max-w-[98%] sm:max-w-[95%]"}
>
{#if message.role === "assistant"}
<!-- Assistant message header -->
<div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
<div
class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"
></div>
<span
class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium"
>EXO</span
>
<span
class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
>{formatTimestamp(message.timestamp)}</span
>
{#if message.ttftMs || message.tps}
<span class="text-xs text-exo-light-gray/80 font-mono ml-2">
{#if message.ttftMs}<span class="text-exo-light-gray/50"
>TTFT</span
>
{message.ttftMs.toFixed(
0,
)}ms{/if}{#if message.ttftMs && message.tps}<span
class="text-exo-light-gray/30 mx-1">•</span
>{/if}{#if message.tps}{message.tps.toFixed(1)}
<span class="text-exo-light-gray/50">tok/s</span>{/if}
</span>
{/if}
</div>
{:else}
<!-- User message header -->
<div
class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2"
>
<span
class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
>{formatTimestamp(message.timestamp)}</span
>
<span
class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
>QUERY</span
>
<div
class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"
></div>
</div>
{/if}
{#if deleteConfirmId === message.id}
<!-- Delete confirmation -->
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
<p class="text-xs text-red-400 mb-3">
{#if i === messageList.length - 1}
Delete this message?
{:else}
Delete this message and all messages after it?
{/if}
</p>
<div class="flex gap-2 justify-end">
<button
onclick={handleCancelDelete}
class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
>
CANCEL
</button>
<button
onclick={handleConfirmDelete}
class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
>
DELETE
</button>
</div>
</div>
{:else if editingMessageId === message.id}
<!-- Edit mode -->
<div class="command-panel rounded-lg p-3">
<textarea
bind:this={editTextareaRef}
bind:value={editContent}
onkeydown={handleEditKeydown}
oninput={handleEditInput}
class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
style="min-height: 60px; max-height: 200px;"
></textarea>
<div class="flex gap-2 justify-end mt-2">
<button
onclick={handleCancelEdit}
class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
>
CANCEL
</button>
<button
onclick={handleSaveEdit}
disabled={!editContent.trim()}
class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
>
<svg
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
SEND
</button>
</div>
</div>
{:else}
<div
class={message.role === "user"
? "command-panel rounded-lg rounded-tr-sm inline-block"
: "command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full"}
>
{#if message.role === "user"}
<!-- User message styling -->
<div class="px-4 py-3">
<!-- Attachments -->
{#if message.attachments && message.attachments.length > 0}
<div class="flex flex-wrap gap-2 mb-3">
{#each message.attachments as attachment}
<div
class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono"
>
{#if attachment.type === "image" && attachment.preview}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
<img
src={attachment.preview}
alt={attachment.name}
class="w-12 h-12 object-cover rounded border border-exo-yellow/20 cursor-pointer hover:border-exo-yellow/50 transition-colors"
onclick={() => {
if (attachment.preview)
expandedImageSrc = attachment.preview;
}}
/>
{:else}
<span>{getAttachmentIcon(attachment)}</span>
{/if}
<span class="text-exo-yellow" title={attachment.name}
>{truncateName(attachment.name)}</span
>
</div>
{/each}
</div>
{/if}
{#if message.content}
<div
class="text-xs text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed"
>
{message.content}
</div>
{/if}
</div>
{:else}
<!-- Assistant message styling -->
<div class="p-3 sm:p-4">
{#if loading && isLastAssistantMessage(message.id) && prefill && !message.content}
<PrefillProgressBar progress={prefill} class="mb-3" />
{/if}
{#if message.thinking && message.thinking.trim().length > 0}
<div
class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40"
>
<button
type="button"
class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
onclick={() => toggleThinkingVisibility(message.id)}
aria-expanded={isThinkingExpanded(message.id)}
aria-controls={`thinking-panel-${message.id}`}
>
<span class="flex items-center gap-2 tracking-[0.25em]">
<svg
class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Thinking...</span>
</span>
<span
class="text-[10px] tracking-[0.2em] text-exo-light-gray/60 ml-4"
>
{isThinkingExpanded(message.id) ? "HIDE" : "SHOW"}
</span>
</button>
{#if isThinkingExpanded(message.id)}
<div
id={`thinking-panel-${message.id}`}
class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
>
{message.thinking.trim()}
</div>
{/if}
</div>
{/if}
<!-- Generated Images -->
{#if message.attachments?.some((a) => a.type === "generated-image")}
<div class="mb-3">
{#each message.attachments.filter((a) => a.type === "generated-image") as attachment}
<div class="relative group/img inline-block">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
<img
src={attachment.preview}
alt=""
class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20 cursor-pointer"
onclick={() => {
if (attachment.preview)
expandedImageSrc = attachment.preview;
}}
/>
<!-- Button overlay -->
<div
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity"
>
<!-- Expand button -->
<button
type="button"
class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
onclick={() => {
if (attachment.preview)
expandedImageSrc = attachment.preview;
}}
title="Expand image"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
<!-- Edit button -->
<button
type="button"
class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
onclick={() => {
if (attachment.preview) {
setEditingImage(attachment.preview, message);
}
}}
title="Edit image"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<!-- Download button -->
<button
type="button"
class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
onclick={() => {
if (attachment.preview) {
const link = document.createElement("a");
link.href = attachment.preview;
const ext =
attachment.name?.split(".").pop() || "png";
link.download = `generated-image-${Date.now()}.${ext}`;
link.click();
}
}}
title="Download image"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
<div class="text-xs text-foreground">
{#if message.content === "Generating image..." || message.content === "Editing image..." || message.content?.startsWith("Generating...") || message.content?.startsWith("Editing...")}
<div class="flex items-center gap-3 text-exo-yellow">
<div class="relative">
<div
class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"
></div>
<svg
class="absolute inset-0 w-8 h-8 p-1.5 text-exo-yellow/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
ry="2"
/>
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<span class="font-mono tracking-wider uppercase text-sm"
>{message.content}</span
>
</div>
{:else if message.content || (loading && !message.attachments?.some((a) => a.type === "generated-image"))}
{#if isHeatmapVisible(message.id) && message.tokens && message.tokens.length > 0}
<TokenHeatmap
tokens={message.tokens}
isGenerating={loading &&
isLastAssistantMessage(message.id)}
onRegenerateFrom={(tokenIndex) =>
regenerateFromToken(message.id, tokenIndex)}
/>
{:else}
<MarkdownContent
content={message.content || (loading ? response : "")}
/>
{#if loading && !message.content}
<span
class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"
></span>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
</div>
<!-- Action buttons -->
<div
class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role ===
'user'
? 'justify-end'
: 'justify-start'}"
>
<!-- Copy button -->
<button
onclick={() => handleCopy(message.content, message.id)}
class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
title="Copy message"
>
{#if copiedMessageId === message.id}
<svg
class="w-3.5 h-3.5 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
{/if}
</button>
<!-- Edit button (user messages only) -->
{#if message.role === "user"}
<button
onclick={() => handleStartEdit(message.id, message.content)}
class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
title="Edit message"
>
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/if}
<!-- Uncertainty heatmap toggle (assistant messages with tokens) -->
{#if message.role === "assistant" && message.tokens && message.tokens.length > 0}
<button
onclick={() => toggleHeatmap(message.id)}
class="p-1.5 transition-colors rounded cursor-pointer {isHeatmapVisible(
message.id,
)
? 'text-exo-yellow'
: 'text-exo-light-gray hover:text-exo-yellow'}"
title={isHeatmapVisible(message.id)
? "Hide uncertainty heatmap"
: "Show uncertainty heatmap"}
>
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</button>
{/if}
<!-- Regenerate button (last assistant message only) -->
{#if message.role === "assistant" && isLastAssistantMessage(message.id) && !loading}
<button
onclick={handleRegenerate}
class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
title="Regenerate response"
>
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
{/if}
<!-- Delete button -->
<button
onclick={() => handleDeleteClick(message.id)}
disabled={loading}
class="p-1.5 transition-colors rounded {loading
? 'text-exo-light-gray/30 cursor-not-allowed'
: 'text-exo-light-gray hover:text-red-400 hover:bg-red-500/10 cursor-pointer'}"
title={loading
? "Cannot delete while generating"
: "Delete message"}
>
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
</div>
</div>
{/each}
{#if messageList.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]"
>
<div
class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4"
>
<div
class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center"
>
<div
class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"
></div>
</div>
</div>
<p
class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase"
>
AWAITING INPUT
</p>
<p class="text-xs text-white/30 tracking-wider mt-1.5 font-mono">
Type a message below · Shift+Enter for newline
</p>
</div>
{/if}
<!-- Invisible element for container reference -->
<div bind:this={containerRef}></div>
<!-- Scroll to bottom button -->
{#if showScrollButton}
<button
type="button"
onclick={scrollToBottom}
class="sticky bottom-4 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-exo-dark-gray/90 border border-exo-medium-gray/50 flex items-center justify-center text-exo-light-gray hover:text-exo-yellow hover:border-exo-yellow/50 transition-all shadow-lg cursor-pointer z-10"
title="Scroll to bottom"
aria-label="Scroll to bottom of messages"
>
<svg
class="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</button>
{/if}
</div>
<ImageLightbox
src={expandedImageSrc}
onclose={() => (expandedImageSrc = null)}
/>