+
{@render SuggestionCard({
+ type: "question",
icon: "lightbulb",
title: "Ask a question",
description: "Get explanations on any topic"
})}
{@render SuggestionCard({
+ type: "code",
icon: "code",
title: "Write code",
- description: "Generate or debug code snippets"
+ description: "Generate or debug code"
})}
{@render SuggestionCard({
+ type: "content",
icon: "pencil",
title: "Create content",
- description: "Draft emails, articles, or stories"
+ description: "Draft emails or articles"
})}
{@render SuggestionCard({
+ type: "conversation",
icon: "chat",
title: "Have a conversation",
- description: "Discuss ideas and get feedback"
+ description: "Discuss ideas and brainstorm"
})}
+
+
+ {#if activeQuickPrompt}
+
+
+
+
+
+ {activeQuickPrompt} mode active
+
+
+ {/if}
{/if}
-{#snippet SuggestionCard(props: { icon: string; title: string; description: string })}
-
selectPrompt(props.type)}
+ class="flex items-start gap-3 rounded-xl border p-3 text-left transition-all {active
+ ? 'border-violet-500/50 bg-violet-500/10'
+ : 'border-slate-800/50 bg-slate-800/30 hover:border-slate-700 hover:bg-slate-800/60'}"
>
-
+
{#if props.icon === 'lightbulb'}
-
+
{:else if props.icon === 'code'}
-
+
{:else if props.icon === 'pencil'}
-
+
{:else if props.icon === 'chat'}
-
+
{/if}
-
{props.title}
-
{props.description}
+
{props.title}
+
{props.description}
-
+
{/snippet}
diff --git a/frontend/src/lib/components/chat/MessageContent.svelte b/frontend/src/lib/components/chat/MessageContent.svelte
index 3d1143c..6d02cf1 100644
--- a/frontend/src/lib/components/chat/MessageContent.svelte
+++ b/frontend/src/lib/components/chat/MessageContent.svelte
@@ -14,9 +14,10 @@
interface Props {
content: string;
images?: string[];
+ isStreaming?: boolean;
}
- const { content, images }: Props = $props();
+ const { content, images, isStreaming = false }: Props = $props();
// Pattern to find fenced code blocks
const CODE_BLOCK_PATTERN = /```(\w+)?\n([\s\S]*?)```/g;
@@ -259,6 +260,7 @@
{#if part.showPreview}
diff --git a/frontend/src/lib/components/chat/MessageItem.svelte b/frontend/src/lib/components/chat/MessageItem.svelte
index 47cb5be..3078be3 100644
--- a/frontend/src/lib/components/chat/MessageItem.svelte
+++ b/frontend/src/lib/components/chat/MessageItem.svelte
@@ -110,9 +110,9 @@
{#if isAssistant}
{#if isToolMessage}
-
+
{:else}
-
+
{/if}
@@ -150,10 +150,10 @@
{#if isEditing}
@@ -188,6 +188,7 @@
{/if}
@@ -238,7 +239,7 @@
{#if isUser}
/**
* MessageList - Scrollable container for chat messages
- * Auto-scrolls to bottom on new messages, but respects user scroll position
+ * Uses CSS scroll anchoring for stable positioning during content changes
*/
import { chatState } from '$lib/stores';
@@ -15,16 +15,17 @@
const { onRegenerate, onEditMessage }: Props = $props();
- // Reference to scroll container
+ // Reference to scroll container and anchor element
let scrollContainer: HTMLDivElement | null = $state(null);
+ let anchorElement: HTMLDivElement | null = $state(null);
- // Track previous message count for auto-scroll
- let previousMessageCount = $state(0);
+ // Track if user has scrolled away from bottom
+ let userScrolledAway = $state(false);
- // Track if user is near bottom (should auto-scroll)
- let userNearBottom = $state(true);
+ // Track previous streaming state to detect when streaming ends
+ let wasStreaming = $state(false);
- // Threshold for "near bottom" detection (pixels from bottom)
+ // Threshold for "near bottom" detection
const SCROLL_THRESHOLD = 100;
/**
@@ -37,53 +38,78 @@
}
/**
- * Handle scroll events to detect user scroll intent
+ * Handle scroll events - detect when user scrolls away
*/
function handleScroll(): void {
- userNearBottom = isNearBottom();
+ if (!scrollContainer) return;
+
+ // User is considered "scrolled away" if not near bottom
+ userScrolledAway = !isNearBottom();
}
/**
- * Scroll to bottom (for button click)
+ * Scroll to bottom smoothly (for button click)
*/
function scrollToBottom(): void {
- if (scrollContainer) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- userNearBottom = true;
+ if (anchorElement) {
+ anchorElement.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ userScrolledAway = false;
}
}
- // Auto-scroll to bottom when new messages arrive (only if user was near bottom)
+ /**
+ * Scroll to bottom instantly (for auto-scroll)
+ */
+ function scrollToBottomInstant(): void {
+ if (anchorElement) {
+ anchorElement.scrollIntoView({ block: 'end' });
+ userScrolledAway = false;
+ }
+ }
+
+ // Auto-scroll when streaming starts (if user hasn't scrolled away)
+ $effect(() => {
+ const isStreaming = chatState.isStreaming;
+
+ // When streaming starts, scroll to bottom if user is near bottom
+ if (isStreaming && !wasStreaming) {
+ if (!userScrolledAway) {
+ // Small delay to let the new message element render
+ requestAnimationFrame(() => {
+ scrollToBottomInstant();
+ });
+ }
+ }
+
+ // When streaming ends, do a final scroll if user hasn't scrolled away
+ if (!isStreaming && wasStreaming) {
+ if (!userScrolledAway) {
+ requestAnimationFrame(() => {
+ scrollToBottomInstant();
+ });
+ }
+ }
+
+ wasStreaming = isStreaming;
+ });
+
+ // Scroll when new messages are added (user sends a message)
+ let previousMessageCount = $state(0);
$effect(() => {
const currentCount = chatState.visibleMessages.length;
- // Scroll when new message added (if user was near bottom)
- if (scrollContainer && currentCount > previousMessageCount) {
- if (userNearBottom) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- }
+ if (currentCount > previousMessageCount && currentCount > 0) {
+ // New message added - always scroll to it
+ requestAnimationFrame(() => {
+ scrollToBottomInstant();
+ });
}
previousMessageCount = currentCount;
});
- // Also scroll on streaming content updates (only if user was near bottom)
- $effect(() => {
- // Access streamBuffer to trigger reactivity
- const _ = chatState.streamBuffer;
-
- if (scrollContainer && chatState.isStreaming && userNearBottom) {
- // Use requestAnimationFrame for smooth scrolling during streaming
- requestAnimationFrame(() => {
- if (scrollContainer && userNearBottom) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- }
- });
- }
- });
-
- // Show scroll-to-bottom button when streaming and user scrolled away
- let showScrollButton = $derived(chatState.isStreaming && !userNearBottom);
+ // Show scroll button when user has scrolled away
+ let showScrollButton = $derived(userScrolledAway && chatState.visibleMessages.length > 0);
/**
* Get branch info for a message
@@ -123,7 +149,8 @@
diff --git a/frontend/src/lib/components/layout/SidenavHeader.svelte b/frontend/src/lib/components/layout/SidenavHeader.svelte
index 45cdbc6..dbd1968 100644
--- a/frontend/src/lib/components/layout/SidenavHeader.svelte
+++ b/frontend/src/lib/components/layout/SidenavHeader.svelte
@@ -4,7 +4,7 @@
* Contains the app logo/title and "New Chat" button
*/
import { goto } from '$app/navigation';
- import { uiState, chatState } from '$lib/stores';
+ import { uiState, chatState, promptsState } from '$lib/stores';
/**
* Handle new chat - reset state and navigate
@@ -12,6 +12,7 @@
function handleNewChat(event: MouseEvent) {
event.preventDefault();
chatState.reset();
+ promptsState.clearTemporaryPrompt();
goto('/');
}
diff --git a/frontend/src/lib/stores/prompts.svelte.ts b/frontend/src/lib/stores/prompts.svelte.ts
index 435d0a9..ab0daf3 100644
--- a/frontend/src/lib/stores/prompts.svelte.ts
+++ b/frontend/src/lib/stores/prompts.svelte.ts
@@ -42,6 +42,9 @@ class PromptsState {
/** Currently selected prompt for new chats (null = no system prompt) */
activePromptId = $state
(null);
+ /** Temporary prompt content for session (overrides activePromptId when set) */
+ temporaryPrompt = $state<{ name: string; content: string } | null>(null);
+
/** Loading state */
isLoading = $state(false);
@@ -52,8 +55,12 @@ class PromptsState {
private _readyPromise: Promise | null = null;
private _readyResolve: (() => void) | null = null;
- /** Derived: active prompt content */
- get activePrompt(): Prompt | null {
+ /** Derived: active prompt content (temporary takes precedence) */
+ get activePrompt(): Prompt | { name: string; content: string } | null {
+ // Temporary prompt takes precedence
+ if (this.temporaryPrompt) {
+ return this.temporaryPrompt;
+ }
if (!this.activePromptId) return null;
return this.prompts.find(p => p.id === this.activePromptId) ?? null;
}
@@ -254,6 +261,23 @@ class PromptsState {
*/
setActive(id: string | null): void {
this.activePromptId = id;
+ // Clear temporary prompt when explicitly setting active
+ this.temporaryPrompt = null;
+ }
+
+ /**
+ * Set a temporary prompt for the current session (overrides stored prompts)
+ * Use this for quick-start prompts from the suggestion cards
+ */
+ setTemporaryPrompt(name: string, content: string): void {
+ this.temporaryPrompt = { name, content };
+ }
+
+ /**
+ * Clear the temporary prompt
+ */
+ clearTemporaryPrompt(): void {
+ this.temporaryPrompt = null;
}
/**