feat: modernize UI/UX and add quick-start prompts
- Modernize chat UI with dark slate palette and subtle styling - Add interactive quick-start prompt cards that set system prompts - Clear temporary prompt when starting new chat - Fix scroll jumping during streaming by skipping Shiki highlighting - Improve code block styling with CSS containment - Fix doubled newlines in code blocks (Shiki .line display: inline) - Simplify success badge in execution output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,7 +113,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative space-y-3">
|
||||
<div class="relative space-y-2">
|
||||
<!-- Image upload area (only shown for vision models) -->
|
||||
{#if isVisionModel}
|
||||
<ImageUpload
|
||||
@@ -124,12 +124,12 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex items-end gap-2 rounded-2xl border border-gray-300 bg-white p-2 shadow-sm transition-colors focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
class="flex items-end gap-3 rounded-2xl border border-slate-700/50 bg-slate-800/80 p-3 backdrop-blur transition-all focus-within:border-slate-600 focus-within:bg-slate-800"
|
||||
>
|
||||
<!-- Image indicator button (for vision models) -->
|
||||
<!-- Image indicator badge (for vision models) -->
|
||||
{#if isVisionModel && pendingImages.length > 0}
|
||||
<div class="flex h-10 items-center justify-center px-2">
|
||||
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
||||
<div class="flex h-9 items-center">
|
||||
<span class="flex items-center gap-1.5 rounded-lg bg-violet-500/20 px-2.5 py-1 text-xs font-medium text-violet-300">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -156,18 +156,18 @@
|
||||
{placeholder}
|
||||
disabled={disabled || isStreaming}
|
||||
rows="1"
|
||||
class="max-h-[200px] min-h-[44px] flex-1 resize-none bg-transparent px-2 py-2 text-gray-900 placeholder-gray-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-white dark:placeholder-gray-400"
|
||||
class="max-h-[200px] min-h-[40px] flex-1 resize-none bg-transparent px-1 py-1.5 text-slate-100 placeholder-slate-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Message input"
|
||||
></textarea>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center">
|
||||
{#if showStopButton}
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleStop}
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-red-500 text-white transition-colors hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-xl bg-red-500/20 text-red-400 transition-colors hover:bg-red-500/30 hover:text-red-300 focus:outline-none focus:ring-2 focus:ring-red-500/50"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
@@ -186,7 +186,9 @@
|
||||
type="button"
|
||||
onclick={handleSend}
|
||||
disabled={!canSend}
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500 text-white transition-colors hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 dark:disabled:bg-gray-600"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-violet-500/50 {canSend
|
||||
? 'bg-violet-500/20 text-violet-400 hover:bg-violet-500/30 hover:text-violet-300'
|
||||
: 'text-slate-600 cursor-not-allowed'}"
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
>
|
||||
@@ -203,12 +205,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Helper text -->
|
||||
<p class="mt-1.5 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Press <kbd class="rounded bg-gray-200 px-1 py-0.5 font-mono text-xs dark:bg-gray-700">Enter</kbd> to send,
|
||||
<kbd class="rounded bg-gray-200 px-1 py-0.5 font-mono text-xs dark:bg-gray-700">Shift+Enter</kbd> for new line
|
||||
<!-- Subtle helper text -->
|
||||
<p class="text-center text-[11px] text-slate-600">
|
||||
<kbd class="rounded bg-slate-800 px-1 py-0.5 font-mono">Enter</kbd> send
|
||||
<span class="mx-1.5 text-slate-700">·</span>
|
||||
<kbd class="rounded bg-slate-800 px-1 py-0.5 font-mono">Shift+Enter</kbd> new line
|
||||
{#if isVisionModel}
|
||||
<span class="ml-1">| Vision model: paste or drag images</span>
|
||||
<span class="mx-1.5 text-slate-700">·</span>
|
||||
<span class="text-violet-500/70">images supported</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex h-full flex-col bg-slate-900">
|
||||
{#if hasMessages}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MessageList
|
||||
@@ -629,24 +629,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Summary recommendation banner -->
|
||||
<SummaryBanner onSummarize={handleSummarize} isLoading={isSummarizing} />
|
||||
<!-- Input area with subtle gradient fade -->
|
||||
<div class="relative">
|
||||
<!-- Gradient fade at top -->
|
||||
<div class="pointer-events-none absolute -top-8 left-0 right-0 h-8 bg-gradient-to-t from-slate-900 to-transparent"></div>
|
||||
|
||||
<!-- Context usage indicator -->
|
||||
{#if hasMessages}
|
||||
<div class="px-4 pt-3">
|
||||
<ContextUsageBar />
|
||||
<div class="border-t border-slate-800/50 bg-slate-900/95 backdrop-blur-sm">
|
||||
<!-- Summary recommendation banner -->
|
||||
<SummaryBanner onSummarize={handleSummarize} isLoading={isSummarizing} />
|
||||
|
||||
<!-- Context usage indicator -->
|
||||
{#if hasMessages}
|
||||
<div class="px-4 pt-3">
|
||||
<ContextUsageBar />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
onStop={handleStopStreaming}
|
||||
isStreaming={chatState.isStreaming}
|
||||
disabled={!modelsState.selectedId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4 pt-2">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
onStop={handleStopStreaming}
|
||||
isStreaming={chatState.isStreaming}
|
||||
disabled={!modelsState.selectedId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
language?: string;
|
||||
/** Whether to show the run button for executable code */
|
||||
showRunButton?: boolean;
|
||||
/** Skip syntax highlighting during streaming to prevent layout shifts */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const { code, language = 'text', showRunButton = true }: Props = $props();
|
||||
const { code, language = 'text', showRunButton = true, isStreaming = false }: Props = $props();
|
||||
|
||||
// State for highlighted HTML and copy feedback
|
||||
let highlightedHtml = $state('');
|
||||
@@ -190,17 +192,26 @@
|
||||
}
|
||||
|
||||
// Highlight code when component mounts or code/language changes
|
||||
// Skip highlighting during streaming to prevent layout shifts
|
||||
$effect(() => {
|
||||
// Access reactive dependencies
|
||||
const _ = [code, language];
|
||||
const _ = [code, language, isStreaming];
|
||||
|
||||
if (isStreaming) {
|
||||
// During streaming, just show plain code
|
||||
isLoading = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only highlight when not streaming
|
||||
highlightCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="group relative overflow-hidden rounded-lg">
|
||||
<div class="group relative overflow-hidden rounded-xl border border-slate-700/50" style="contain: layout;">
|
||||
<!-- Header with language label, run button, and copy button -->
|
||||
<div
|
||||
class="flex items-center justify-between bg-gray-800 px-4 py-2 text-xs text-gray-400"
|
||||
class="flex items-center justify-between border-b border-slate-700/50 bg-slate-800/80 px-3 py-1.5 text-xs text-slate-400"
|
||||
>
|
||||
<span class="font-mono uppercase">{language}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -270,47 +281,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code content -->
|
||||
<div class="overflow-x-auto bg-[#0d1117]">
|
||||
<!-- Code content - use same styling for loading/loaded to prevent layout shift -->
|
||||
<div class="code-block-content overflow-x-auto bg-slate-900/90">
|
||||
{#if isLoading}
|
||||
<pre class="p-4 font-mono text-sm text-gray-400"><code>{code}</code></pre>
|
||||
<pre class="m-0 overflow-x-auto bg-transparent px-4 py-3" style="line-height: 1.5;"><code class="font-mono text-[13px] text-slate-300" style="line-height: inherit;">{code}</code></pre>
|
||||
{:else}
|
||||
<div class="code-block-content">
|
||||
{@html highlightedHtml}
|
||||
</div>
|
||||
{@html highlightedHtml}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Execution output -->
|
||||
{#if showOutput && (isExecuting || executionResult)}
|
||||
<div class="border-t border-gray-700 bg-[#161b22]">
|
||||
<div class="border-t border-slate-700/50 bg-slate-950/50">
|
||||
<!-- Output header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 text-xs">
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-400">Output</span>
|
||||
<!-- Terminal icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 text-slate-500">
|
||||
<path fill-rule="evenodd" d="M3.25 3A2.25 2.25 0 001 5.25v9.5A2.25 2.25 0 003.25 17h13.5A2.25 2.25 0 0019 14.75v-9.5A2.25 2.25 0 0016.75 3H3.25zm.943 8.752a.75.75 0 01.055-1.06L6.128 9l-1.88-1.693a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 01-1.06-.055zM9.75 10.25a.75.75 0 000 1.5h2.5a.75.75 0 000-1.5h-2.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-slate-400">Output</span>
|
||||
|
||||
{#if isExecuting}
|
||||
<span class="flex items-center gap-1 text-blue-400">
|
||||
<span class="flex items-center gap-1.5 rounded-full bg-blue-500/10 px-2 py-0.5 text-[11px] text-blue-400">
|
||||
<svg class="h-3 w-3 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Running...
|
||||
Running
|
||||
</span>
|
||||
{:else if executionResult}
|
||||
<span class="text-gray-500">
|
||||
{executionResult.duration}ms
|
||||
</span>
|
||||
{#if executionResult.status === 'success'}
|
||||
<span class="text-green-500">Success</span>
|
||||
{:else if executionResult.status === 'error'}
|
||||
<span class="text-red-500">Error</span>
|
||||
<span class="text-[11px] text-slate-500">Completed</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-[11px] text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-3 w-3">
|
||||
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14ZM8 4a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Error
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-[11px] text-slate-600">{executionResult.duration}ms</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearOutput}
|
||||
class="rounded px-2 py-1 text-gray-400 hover:bg-gray-700 hover:text-gray-200"
|
||||
class="rounded-md px-2 py-1 text-[11px] text-slate-500 transition-colors hover:bg-slate-800 hover:text-slate-300"
|
||||
aria-label="Clear output"
|
||||
>
|
||||
Clear
|
||||
@@ -318,12 +335,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Output content -->
|
||||
<div class="max-h-64 overflow-auto px-4 pb-4">
|
||||
<div class="max-h-48 overflow-auto border-t border-slate-800/50 bg-slate-950/30 px-3 py-2">
|
||||
{#if executionResult?.outputs.length}
|
||||
<pre class="font-mono text-sm">{#each executionResult.outputs as output}<span class={getOutputClass(output.type)}>{output.content}</span>
|
||||
{/each}</pre>
|
||||
<pre class="font-mono text-[12px] leading-relaxed">{#each executionResult.outputs as output}<span class={getOutputClass(output.type)}>{output.content}</span>{/each}</pre>
|
||||
{:else if !isExecuting}
|
||||
<p class="text-sm italic text-gray-500">No output</p>
|
||||
<p class="text-[12px] italic text-slate-600">No output</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,33 +347,36 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Override Shiki styles for consistent appearance */
|
||||
/* Override Shiki styles for compact, polished appearance */
|
||||
.code-block-content :global(pre) {
|
||||
@apply m-0 overflow-x-auto bg-transparent p-4;
|
||||
@apply m-0 overflow-x-auto bg-transparent px-4 py-3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-block-content :global(code) {
|
||||
@apply font-mono text-sm leading-relaxed;
|
||||
@apply font-mono text-[13px];
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Shiki wraps each line - make them inline to prevent double spacing */
|
||||
.code-block-content :global(.line) {
|
||||
@apply block min-h-[1.5em];
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for code blocks */
|
||||
.code-block-content :global(pre)::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.code-block-content :global(pre)::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.code-block-content :global(pre)::-webkit-scrollbar-thumb {
|
||||
@apply rounded bg-gray-600;
|
||||
@apply rounded-full bg-slate-600/50;
|
||||
}
|
||||
|
||||
.code-block-content :global(pre)::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500;
|
||||
@apply bg-slate-500/70;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,95 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* EmptyState - Displayed when there are no messages
|
||||
* Shows a welcome message and suggestions
|
||||
* Shows a welcome message and quick-start suggestions that set system prompts
|
||||
*/
|
||||
|
||||
import { modelsState } from '$lib/stores';
|
||||
import { modelsState, promptsState } from '$lib/stores';
|
||||
|
||||
const selectedModel = $derived(modelsState.selected);
|
||||
const hasModel = $derived(!!modelsState.selectedId);
|
||||
|
||||
/** Quick-start prompt definitions */
|
||||
const QUICK_PROMPTS = {
|
||||
question: {
|
||||
name: 'Knowledge Assistant',
|
||||
content: `You are a knowledgeable assistant focused on providing clear, accurate explanations. When answering questions:
|
||||
- Break down complex topics into understandable parts
|
||||
- Use examples and analogies when helpful
|
||||
- Cite sources or indicate uncertainty when appropriate
|
||||
- Ask clarifying questions if the query is ambiguous
|
||||
- Provide both concise answers and deeper context when relevant`
|
||||
},
|
||||
code: {
|
||||
name: 'Code Assistant',
|
||||
content: `You are an expert programming assistant. When helping with code:
|
||||
- Write clean, well-documented code with clear variable names
|
||||
- Explain your implementation choices and trade-offs
|
||||
- Consider edge cases and error handling
|
||||
- Follow language-specific best practices and idioms
|
||||
- Suggest improvements and optimizations when appropriate
|
||||
- If debugging, ask for error messages and context`
|
||||
},
|
||||
content: {
|
||||
name: 'Content Creator',
|
||||
content: `You are a skilled content creator and writing assistant. When creating content:
|
||||
- Adapt tone and style to the target audience
|
||||
- Use clear, engaging language appropriate to the format
|
||||
- Structure content logically with good flow
|
||||
- Proofread for grammar, clarity, and consistency
|
||||
- Ask about purpose, audience, and tone preferences if not specified
|
||||
- Offer variations or alternatives when helpful`
|
||||
},
|
||||
conversation: {
|
||||
name: 'Conversation Partner',
|
||||
content: `You are an engaging conversation partner focused on thoughtful discussion. In conversations:
|
||||
- Listen actively and ask insightful follow-up questions
|
||||
- Share relevant perspectives and ideas while staying open-minded
|
||||
- Help develop and refine ideas through dialogue
|
||||
- Provide honest feedback while being constructive
|
||||
- Connect related topics and draw interesting parallels
|
||||
- Keep discussions focused but allow natural tangents`
|
||||
}
|
||||
} as const;
|
||||
|
||||
type PromptType = keyof typeof QUICK_PROMPTS;
|
||||
|
||||
/** Currently selected quick prompt */
|
||||
const activeQuickPrompt = $derived(promptsState.temporaryPrompt?.name);
|
||||
|
||||
/**
|
||||
* Select a quick-start prompt
|
||||
*/
|
||||
function selectPrompt(type: PromptType): void {
|
||||
const prompt = QUICK_PROMPTS[type];
|
||||
promptsState.setTemporaryPrompt(prompt.name, prompt.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a prompt type is currently active
|
||||
*/
|
||||
function isActive(type: PromptType): boolean {
|
||||
return activeQuickPrompt === QUICK_PROMPTS[type].name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||
<!-- Logo/Icon -->
|
||||
<!-- Logo/Icon - subtle violet glow -->
|
||||
<div
|
||||
class="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-lg"
|
||||
class="mb-6 flex h-14 w-14 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-400"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="h-8 w-8"
|
||||
class="h-7 w-7"
|
||||
>
|
||||
<path d="M12 2a2 2 0 012 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 017 7h1a1 1 0 011 1v3a1 1 0 01-1 1h-1v1a2 2 0 01-2 2H5a2 2 0 01-2-2v-1H2a1 1 0 01-1-1v-3a1 1 0 011-1h1a7 7 0 017-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 012-2zM7.5 13a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm9 0a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" />
|
||||
<path d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Welcome text -->
|
||||
<h2 class="mb-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
<h2 class="mb-2 text-xl font-medium text-slate-100">
|
||||
{#if hasModel}
|
||||
Start a conversation
|
||||
{:else}
|
||||
@@ -34,68 +97,88 @@
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<p class="mb-8 max-w-md text-gray-600 dark:text-gray-400">
|
||||
<p class="mb-8 max-w-md text-sm text-slate-500">
|
||||
{#if hasModel && selectedModel}
|
||||
You're chatting with <span class="font-medium text-gray-900 dark:text-white">{selectedModel.name}</span>.
|
||||
Type a message below to begin.
|
||||
Chatting with <span class="font-medium text-slate-300">{selectedModel.name}</span>
|
||||
{:else}
|
||||
Please select a model from the sidebar to start chatting.
|
||||
Select a model from the sidebar to start chatting
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Suggestion cards -->
|
||||
<!-- Quick-start suggestion cards -->
|
||||
{#if hasModel}
|
||||
<div class="grid max-w-2xl gap-3 sm:grid-cols-2">
|
||||
<div class="grid max-w-xl gap-2 sm:grid-cols-2">
|
||||
{@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"
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Active prompt indicator -->
|
||||
{#if activeQuickPrompt}
|
||||
<p class="mt-4 text-xs text-violet-400/70">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-3 w-3">
|
||||
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{activeQuickPrompt} mode active
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet SuggestionCard(props: { icon: string; title: string; description: string })}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 text-left transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-750"
|
||||
{#snippet SuggestionCard(props: { type: PromptType; icon: string; title: string; description: string })}
|
||||
{@const active = isActive(props.type)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => 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'}"
|
||||
>
|
||||
<div class="flex-shrink-0 text-gray-400 dark:text-gray-500">
|
||||
<div class="flex-shrink-0 {active ? 'text-violet-400' : 'text-slate-500'}">
|
||||
{#if props.icon === 'lightbulb'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path d="M10 1a6 6 0 00-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 00.572.729 6.016 6.016 0 002.856 0A.75.75 0 0012 15.1v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0010 1zM8.863 17.414a.75.75 0 00-.226 1.483 9.066 9.066 0 002.726 0 .75.75 0 00-.226-1.483 7.553 7.553 0 01-2.274 0z" />
|
||||
</svg>
|
||||
{:else if props.icon === 'code'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path fill-rule="evenodd" d="M6.28 5.22a.75.75 0 010 1.06L2.56 10l3.72 3.72a.75.75 0 01-1.06 1.06L.97 10.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 0zm7.44 0a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 010-1.06zM11.377 2.011a.75.75 0 01.612.867l-2.5 14.5a.75.75 0 01-1.478-.255l2.5-14.5a.75.75 0 01.866-.612z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{:else if props.icon === 'pencil'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
|
||||
</svg>
|
||||
{:else if props.icon === 'chat'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path fill-rule="evenodd" d="M10 2c-2.236 0-4.43.18-6.57.524C1.993 2.755 1 4.014 1 5.426v5.148c0 1.413.993 2.67 2.43 2.902 1.168.188 2.352.327 3.55.414.28.02.521.18.642.413l1.713 3.293a.75.75 0 001.33 0l1.713-3.293c.121-.233.362-.393.642-.413a41.102 41.102 0 003.55-.414c1.437-.232 2.43-1.49 2.43-2.902V5.426c0-1.413-.993-2.67-2.43-2.902A41.289 41.289 0 0010 2zM6.75 6a.75.75 0 000 1.5h6.5a.75.75 0 000-1.5h-6.5zm0 2.5a.75.75 0 000 1.5h3.5a.75.75 0 000-1.5h-3.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{props.title}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{props.description}</p>
|
||||
<h3 class="text-sm font-medium {active ? 'text-violet-200' : 'text-slate-200'}">{props.title}</h3>
|
||||
<p class="text-xs {active ? 'text-violet-400/70' : 'text-slate-500'}">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -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 @@
|
||||
<CodeBlock
|
||||
code={part.content}
|
||||
language={part.language || 'text'}
|
||||
{isStreaming}
|
||||
/>
|
||||
<!-- Show preview for HTML code blocks -->
|
||||
{#if part.showPreview}
|
||||
|
||||
@@ -110,9 +110,9 @@
|
||||
<!-- Avatar for assistant -->
|
||||
{#if isAssistant}
|
||||
{#if isToolMessage}
|
||||
<!-- Tool message avatar -->
|
||||
<!-- Tool message avatar - subtle teal -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 text-white"
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/10 text-teal-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
@@ -125,9 +125,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Normal assistant avatar -->
|
||||
<!-- Normal assistant avatar - subtle violet -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 text-white"
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-violet-500/10 text-violet-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
@@ -136,7 +136,7 @@
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path d="M12 2a2 2 0 012 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 017 7h1a1 1 0 011 1v3a1 1 0 01-1 1h-1v1a2 2 0 01-2 2H5a2 2 0 01-2-2v-1H2a1 1 0 01-1-1v-3a1 1 0 011-1h1a7 7 0 017-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 012-2zM7.5 13a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm9 0a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" />
|
||||
<path d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -150,10 +150,10 @@
|
||||
<!-- Message bubble with branch navigator -->
|
||||
<div
|
||||
class="relative rounded-2xl px-4 py-3 {isUser
|
||||
? 'bg-blue-500 text-white rounded-br-md'
|
||||
? 'bg-slate-700 text-slate-100'
|
||||
: isToolMessage
|
||||
? 'bg-emerald-950/30 border-l-2 border-emerald-500 rounded-bl-md'
|
||||
: 'bg-gray-100 dark:bg-gray-800 rounded-bl-md'}"
|
||||
? 'bg-slate-800/50 border-l-2 border-teal-500/50'
|
||||
: 'bg-transparent'}"
|
||||
>
|
||||
{#if isEditing}
|
||||
<!-- Edit mode -->
|
||||
@@ -188,6 +188,7 @@
|
||||
<MessageContent
|
||||
content={node.message.content}
|
||||
images={node.message.images}
|
||||
{isStreaming}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -238,7 +239,7 @@
|
||||
<!-- Avatar for user -->
|
||||
{#if isUser}
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 text-white"
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-slate-600 text-slate-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 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 @@
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
onscroll={handleScroll}
|
||||
class="h-full overflow-y-auto scroll-smooth"
|
||||
class="h-full overflow-y-auto"
|
||||
style="overflow-anchor: none;"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Chat messages"
|
||||
@@ -140,6 +167,8 @@
|
||||
onEdit={(newContent) => onEditMessage?.(node.id, newContent)}
|
||||
/>
|
||||
{/each}
|
||||
<!-- Scroll anchor element -->
|
||||
<div bind:this={anchorElement} class="h-0" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,9 @@ class PromptsState {
|
||||
/** Currently selected prompt for new chats (null = no system prompt) */
|
||||
activePromptId = $state<string | null>(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<void> | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user