feat: add context management, summarization, and UI improvements

Context Window Management:
- Add ContextFullModal with recovery options (summarize, new chat, dismiss)
- Show toast notifications at 85% and 95% context thresholds
- Block sending when context exceeds 100% until user takes action

Conversation Summarization:
- Add isSummarized/isSummary flags to Message type
- Implement markAsSummarized() and insertSummaryMessage() in ChatState
- Add messagesForContext derived state (excludes summarized, includes summaries)
- Complete handleSummarize flow with LLM summary generation
- Add amber-styled summary message UI with archive icon

Auto-scroll Fixes:
- Fix Svelte 5 reactivity issues by using plain variables instead of $state
- Add continuous scroll during streaming via streamBuffer tracking
- Properly handle user scroll override (re-enable at bottom)

Drag & Drop Improvements:
- Add full-screen drag overlay with document-level event listeners
- Use dragCounter pattern for reliable nested element detection
- Add hideDropZone prop to FileUpload/ImageUpload components

Additional Features:
- Add SystemPromptSelector for per-conversation prompts
- Add SearchModal for full-text message search
- Add ShortcutsModal for keyboard shortcuts help
- Add theme toggle to TopNav

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 04:36:18 +01:00
parent bb5720434a
commit 463067d2ae
25 changed files with 1703 additions and 112 deletions

View File

@@ -2,11 +2,14 @@
/**
* ChatInput - Message input area with auto-growing textarea
* Handles send/stop actions, keyboard shortcuts, and file uploads
* Drag overlay appears when files are dragged over the input area
*/
import { modelsState } from '$lib/stores';
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatAttachmentsForMessage } from '$lib/utils/file-processor.js';
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
import { isImageMimeType } from '$lib/types/attachment.js';
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
import FileUpload from './FileUpload.svelte';
interface Props {
@@ -35,6 +38,10 @@
// File attachment state (text/PDF for all models)
let pendingAttachments = $state<FileAttachment[]>([]);
// Drag overlay state
let isDragOver = $state(false);
let dragCounter = 0; // Track enter/leave for nested elements
// Derived state
const hasContent = $derived(
inputValue.trim().length > 0 || pendingImages.length > 0 || pendingAttachments.length > 0
@@ -45,6 +52,12 @@
// Vision model detection
const isVisionModel = $derived(modelsState.selectedSupportsVision);
// Token estimation for current input
const tokenEstimate = $derived(
estimateMessageTokens(inputValue, pendingImages.length > 0 ? pendingImages : undefined)
);
const showTokenCount = $derived(inputValue.length > 0 || pendingImages.length > 0);
/**
* Auto-resize textarea based on content
*/
@@ -144,9 +157,132 @@
$effect(() => {
focusInput();
});
// =========================================================================
// Drag & Drop - Document-level listeners for reliable detection
// =========================================================================
// Set up document-level drag listeners for reliable detection
$effect(() => {
if (disabled) return;
function onDragEnter(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
dragCounter++;
isDragOver = true;
}
function onDragLeave(event: DragEvent): void {
event.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
isDragOver = false;
}
}
function onDragOver(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
function onDrop(event: DragEvent): void {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
if (!event.dataTransfer?.files.length) return;
const files = Array.from(event.dataTransfer.files);
processDroppedFiles(files);
}
document.addEventListener('dragenter', onDragEnter);
document.addEventListener('dragleave', onDragLeave);
document.addEventListener('dragover', onDragOver);
document.addEventListener('drop', onDrop);
return () => {
document.removeEventListener('dragenter', onDragEnter);
document.removeEventListener('dragleave', onDragLeave);
document.removeEventListener('dragover', onDragOver);
document.removeEventListener('drop', onDrop);
};
});
/**
* Process dropped files - images go to pendingImages, others to attachments
*/
async function processDroppedFiles(files: File[]): Promise<void> {
const imageFiles: File[] = [];
const otherFiles: File[] = [];
for (const file of files) {
if (isImageMimeType(file.type)) {
imageFiles.push(file);
} else {
otherFiles.push(file);
}
}
// Process images if vision is supported
if (imageFiles.length > 0 && isVisionModel) {
const { processImageForOllama, isValidImageType } = await import('$lib/ollama/image-processor');
const validImages = imageFiles.filter(isValidImageType);
const maxImages = 4;
const remainingSlots = maxImages - pendingImages.length;
const toProcess = validImages.slice(0, remainingSlots);
for (const file of toProcess) {
try {
const processed = await processImageForOllama(file);
pendingImages = [...pendingImages, processed.base64];
} catch (err) {
console.error('Failed to process image:', err);
}
}
}
// Process other files
for (const file of otherFiles) {
const result = await processFile(file);
if (result.success) {
pendingAttachments = [...pendingAttachments, result.attachment];
}
}
}
</script>
<!-- Full-screen drag overlay - shown when dragging files anywhere on the page -->
{#if isDragOver}
<div class="pointer-events-none fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-violet-500 bg-slate-800/90 p-8 text-violet-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-12 w-12">
<path fill-rule="evenodd" d="M10.5 3.75a6 6 0 0 0-5.98 6.496A5.25 5.25 0 0 0 6.75 20.25H18a4.5 4.5 0 0 0 2.206-8.423 3.75 3.75 0 0 0-4.133-4.303A6.001 6.001 0 0 0 10.5 3.75Zm2.03 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v4.19a.75.75 0 0 0 1.5 0v-4.19l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
</svg>
<span class="text-lg font-medium">
{#if isVisionModel}
Drop images or files
{:else}
Drop files here
{/if}
</span>
<span class="text-sm text-slate-400">
{#if isVisionModel}
Images, text files, and PDFs supported
{:else}
Text files and PDFs supported
{/if}
</span>
</div>
</div>
{/if}
<div class="relative space-y-2">
<!-- File upload area (images for vision models, text/PDFs for all) -->
<FileUpload
images={pendingImages}
@@ -155,6 +291,7 @@
onAttachmentsChange={handleAttachmentsChange}
supportsVision={isVisionModel}
{disabled}
hideDropZone={true}
/>
<div
@@ -211,6 +348,7 @@
rows="1"
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"
data-chat-input
></textarea>
<!-- Action buttons -->
@@ -269,5 +407,11 @@
<span class="mx-1 text-slate-700">+</span>
{/if}
<span class="text-slate-500">files supported</span>
{#if showTokenCount}
<span class="mx-1.5 text-slate-700">·</span>
<span class="text-slate-500" title="{tokenEstimate.textTokens} text + {tokenEstimate.imageTokens} image tokens">
~{formatTokenCount(tokenEstimate.totalTokens)} tokens
</span>
{/if}
</p>
</div>

View File

@@ -26,8 +26,10 @@
import ChatInput from './ChatInput.svelte';
import EmptyState from './EmptyState.svelte';
import ContextUsageBar from './ContextUsageBar.svelte';
import ContextFullModal from './ContextFullModal.svelte';
import SummaryBanner from './SummaryBanner.svelte';
import StreamingStats from './StreamingStats.svelte';
import SystemPromptSelector from './SystemPromptSelector.svelte';
import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte';
import { settingsState } from '$lib/stores/settings.svelte';
@@ -58,6 +60,10 @@
// Summarization state
let isSummarizing = $state(false);
// Context full modal state
let showContextFullModal = $state(false);
let pendingMessage: { content: string; images?: string[] } | null = $state(null);
// Tool execution state
let isExecutingTools = $state(false);
@@ -77,6 +83,35 @@
checkKnowledgeBase();
});
// Track previous context state for threshold crossing detection
let previousContextState: 'normal' | 'warning' | 'critical' | 'full' = 'normal';
// Context warning toasts - show once per threshold crossing
$effect(() => {
const percentage = contextManager.contextUsage.percentage;
let currentState: 'normal' | 'warning' | 'critical' | 'full' = 'normal';
if (percentage >= 100) {
currentState = 'full';
} else if (percentage >= 95) {
currentState = 'critical';
} else if (percentage >= 85) {
currentState = 'warning';
}
// Only show toast when crossing INTO a worse state
if (currentState !== previousContextState) {
if (currentState === 'warning' && previousContextState === 'normal') {
toastState.warning('Context is 85% full. Consider starting a new chat soon.');
} else if (currentState === 'critical' && previousContextState !== 'full') {
toastState.warning('Context almost full (95%). Summarize or start a new chat.');
} else if (currentState === 'full') {
// Full state is handled by the modal, no toast needed
}
previousContextState = currentState;
}
});
/**
* Check if knowledge base has any documents
*/
@@ -165,10 +200,10 @@
/**
* Convert chat state messages to Ollama API format
* Uses allMessages to include hidden tool result messages
* Uses messagesForContext to exclude summarized originals but include summaries
*/
function getMessagesForApi(): OllamaMessage[] {
return chatState.allMessages.map((node) => ({
return chatState.messagesForContext.map((node) => ({
role: node.message.role as OllamaMessage['role'],
content: node.message.content,
images: node.message.images
@@ -186,6 +221,7 @@
const { toSummarize, toKeep } = selectMessagesForSummarization(messages, 0);
if (toSummarize.length === 0) {
toastState.warning('No messages available to summarize');
return;
}
@@ -194,24 +230,88 @@
try {
// Generate summary using the LLM
const summary = await generateSummary(toSummarize, selectedModel);
const formattedSummary = formatSummaryAsContext(summary);
// Calculate savings
const savedTokens = calculateTokenSavings(toSummarize, formattedSummary);
// Calculate savings for logging
const savedTokens = calculateTokenSavings(toSummarize, summary);
// TODO: Implement message replacement in chat state
// This requires adding a method to ChatState to replace messages
// with a summary node
// Mark original messages as summarized (they'll be hidden from UI and context)
const messageIdsToSummarize = toSummarize.map((node) => node.id);
chatState.markAsSummarized(messageIdsToSummarize);
// Insert the summary message at the beginning (after any system messages)
chatState.insertSummaryMessage(summary);
// Force context recalculation with updated message list
contextManager.updateMessages(chatState.visibleMessages, true);
// Show success notification
toastState.success(
`Summarized ${toSummarize.length} messages, saved ~${Math.round(savedTokens / 100) * 100} tokens`
);
} catch (error) {
console.error('Summarization failed:', error);
toastState.error('Summarization failed. Please try again.');
} finally {
isSummarizing = false;
}
}
// =========================================================================
// Context Full Modal Handlers
// =========================================================================
/**
* Send a message and stream the response (with tool support)
* Handle "Summarize & Continue" from context full modal
*/
async function handleContextFullSummarize(): Promise<void> {
showContextFullModal = false;
await handleSummarize();
// After summarization, try to send the pending message
if (pendingMessage && contextManager.contextUsage.percentage < 100) {
const { content, images } = pendingMessage;
pendingMessage = null;
await handleSendMessage(content, images);
} else if (pendingMessage) {
// Still full after summarization - show toast
toastState.warning('Context still full after summarization. Try starting a new chat.');
pendingMessage = null;
}
}
/**
* Handle "Start New Chat" from context full modal
*/
function handleContextFullNewChat(): void {
showContextFullModal = false;
pendingMessage = null;
chatState.reset();
contextManager.reset();
toastState.info('Started new chat. Previous conversation was saved.');
}
/**
* Handle "Continue Anyway" from context full modal
*/
async function handleContextFullDismiss(): Promise<void> {
showContextFullModal = false;
// Try to send the message anyway (may fail or get truncated)
if (pendingMessage) {
const { content, images } = pendingMessage;
pendingMessage = null;
// Bypass the context check by calling the inner logic directly
await sendMessageInternal(content, images);
}
}
/**
* Check if summarization is possible (enough messages)
*/
const canSummarizeConversation = $derived(chatState.visibleMessages.length >= 6);
/**
* Send a message - checks context and may show modal
*/
async function handleSendMessage(content: string, images?: string[]): Promise<void> {
const selectedModel = modelsState.selectedId;
@@ -221,6 +321,24 @@
return;
}
// Check if context is full (100%+)
if (contextManager.contextUsage.percentage >= 100) {
// Store pending message and show modal
pendingMessage = { content, images };
showContextFullModal = true;
return;
}
await sendMessageInternal(content, images);
}
/**
* Internal: Send message and stream response (bypasses context check)
*/
async function sendMessageInternal(content: string, images?: string[]): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) return;
// In 'new' mode with no messages yet, create conversation first
if (mode === 'new' && !hasMessages && onFirstMessage) {
await onFirstMessage(content, images);
@@ -289,11 +407,24 @@
// Build system prompt from active prompt + thinking + RAG context
const systemParts: string[] = [];
// Wait for prompts to be loaded, then add system prompt if active
// Wait for prompts to be loaded
await promptsState.ready();
const activePrompt = promptsState.activePrompt;
if (activePrompt) {
systemParts.push(activePrompt.content);
// Priority: per-conversation prompt > global active prompt > none
let promptContent: string | null = null;
if (conversation?.systemPromptId) {
// Use per-conversation prompt
const conversationPrompt = promptsState.get(conversation.systemPromptId);
if (conversationPrompt) {
promptContent = conversationPrompt.content;
}
} else if (promptsState.activePrompt) {
// Fall back to global active prompt
promptContent = promptsState.activePrompt.content;
}
if (promptContent) {
systemParts.push(promptContent);
}
// RAG: Retrieve relevant context for the last user message
@@ -703,26 +834,36 @@
<StreamingStats />
</div>
<!-- Chat options bar (settings toggle + thinking mode toggle) -->
<!-- Chat options bar (settings toggle + system prompt + thinking mode toggle) -->
<div class="flex items-center justify-between gap-3 px-4 pt-3">
<!-- Left side: Settings gear -->
<button
type="button"
onclick={() => settingsState.togglePanel()}
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
class:bg-slate-800={settingsState.isPanelOpen}
class:text-sky-400={settingsState.isPanelOpen || settingsState.useCustomParameters}
aria-label="Toggle model parameters"
aria-expanded={settingsState.isPanelOpen}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{#if settingsState.useCustomParameters}
<span class="text-[10px]">Custom</span>
<!-- Left side: Settings gear + System prompt selector -->
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => settingsState.togglePanel()}
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
class:bg-slate-800={settingsState.isPanelOpen}
class:text-sky-400={settingsState.isPanelOpen || settingsState.useCustomParameters}
aria-label="Toggle model parameters"
aria-expanded={settingsState.isPanelOpen}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{#if settingsState.useCustomParameters}
<span class="text-[10px]">Custom</span>
{/if}
</button>
<!-- System prompt selector (only in conversation mode) -->
{#if mode === 'conversation' && conversation}
<SystemPromptSelector
conversationId={conversation.id}
currentPromptId={conversation.systemPromptId}
/>
{/if}
</button>
</div>
<!-- Right side: Thinking mode toggle -->
{#if supportsThinking}
@@ -762,3 +903,13 @@
</div>
</div>
</div>
<!-- Context full modal -->
<ContextFullModal
isOpen={showContextFullModal}
onSummarize={handleContextFullSummarize}
onNewChat={handleContextFullNewChat}
onDismiss={handleContextFullDismiss}
{isSummarizing}
canSummarize={canSummarizeConversation}
/>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
/**
* ContextFullModal - Shown when context window is exhausted
* Offers recovery options: summarize, new chat, or dismiss
*/
import { contextManager } from '$lib/memory';
import { formatTokenCount } from '$lib/memory/tokenizer';
import { formatContextSize } from '$lib/memory/model-limits';
interface Props {
isOpen: boolean;
onSummarize: () => void;
onNewChat: () => void;
onDismiss: () => void;
isSummarizing?: boolean;
canSummarize?: boolean;
}
const {
isOpen,
onSummarize,
onNewChat,
onDismiss,
isSummarizing = false,
canSummarize = true
}: Props = $props();
// Context usage info
const usage = $derived(contextManager.contextUsage);
const overflowAmount = $derived(Math.max(0, usage.usedTokens - usage.maxTokens));
</script>
{#if isOpen}
<!-- Backdrop -->
<div class="fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<!-- Modal -->
<div class="mx-4 w-full max-w-md rounded-2xl border border-red-500/30 bg-slate-900 p-6 shadow-2xl">
<!-- Header with warning icon -->
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Context Window Full</h2>
<p class="text-sm text-slate-400">
{formatTokenCount(usage.usedTokens)} / {formatContextSize(usage.maxTokens)} tokens used
</p>
</div>
</div>
<!-- Explanation -->
<div class="mb-6 rounded-lg bg-slate-800/50 p-4 text-sm text-slate-300">
<p class="mb-2">
The conversation has exceeded the model's context window by
<span class="font-medium text-red-400">{formatTokenCount(overflowAmount)} tokens</span>.
</p>
<p class="text-slate-400">
The model cannot process more text until space is freed. Choose how to proceed:
</p>
</div>
<!-- Options -->
<div class="space-y-3">
<!-- Summarize option -->
{#if canSummarize}
<button
type="button"
onclick={onSummarize}
disabled={isSummarizing}
class="flex w-full items-center gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-500/20">
{#if isSummarizing}
<svg class="h-5 w-5 animate-spin text-emerald-400" 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>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{/if}
</div>
<div class="flex-1">
<div class="font-medium text-emerald-300">
{isSummarizing ? 'Summarizing...' : 'Summarize & Continue'}
</div>
<div class="text-xs text-slate-400">
Compress older messages into a summary to free space
</div>
</div>
<span class="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
Recommended
</span>
</button>
{/if}
<!-- New chat option -->
<button
type="button"
onclick={onNewChat}
class="flex w-full items-center gap-3 rounded-xl border border-slate-600/50 bg-slate-800/50 p-4 text-left transition-colors hover:bg-slate-700/50"
>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-200">Start New Chat</div>
<div class="text-xs text-slate-400">
Begin a fresh conversation (current chat is saved)
</div>
</div>
</button>
<!-- Dismiss option (risky) -->
<button
type="button"
onclick={onDismiss}
class="flex w-full items-center gap-3 rounded-xl border border-slate-700/50 p-4 text-left transition-colors hover:bg-slate-800/50"
>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-slate-400">Continue Anyway</div>
<div class="text-xs text-slate-500">
Try to send (may result in errors or truncated responses)
</div>
</div>
</button>
</div>
</div>
</div>
{/if}

View File

@@ -22,6 +22,8 @@
supportsVision?: boolean;
/** Whether upload is disabled */
disabled?: boolean;
/** Hide the drop zone (when drag overlay is handled by parent) */
hideDropZone?: boolean;
}
const {
@@ -30,7 +32,8 @@
attachments,
onAttachmentsChange,
supportsVision = false,
disabled = false
disabled = false,
hideDropZone = false
}: Props = $props();
// Processing state
@@ -112,7 +115,9 @@
}
/**
* Handle paste events for file attachments
* Handle paste events for file attachments and images
* Images are forwarded to ImageUpload when vision is supported,
* otherwise shows a helpful message
*/
function handlePaste(event: ClipboardEvent) {
if (disabled) return;
@@ -121,22 +126,101 @@
if (!items) return;
const files: File[] = [];
const imageFiles: File[] = [];
for (const item of items) {
// Handle non-image files (images handled by ImageUpload)
if (!item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
files.push(file);
}
const file = item.getAsFile();
if (!file) continue;
if (item.type.startsWith('image/')) {
imageFiles.push(file);
} else {
files.push(file);
}
}
// Handle non-image files
if (files.length > 0) {
// Don't prevent default if we have no files to process
// (let ImageUpload handle images)
processFiles(files);
}
// Handle image files
if (imageFiles.length > 0) {
event.preventDefault();
if (supportsVision) {
// Forward to ImageUpload by triggering file processing there
// ImageUpload has its own paste handler, but we handle it here too
// to ensure consistent behavior
processImageFiles(imageFiles);
} else {
// Show message that images require vision model
errorMessage = 'Images can only be used with vision-capable models (e.g., llava, bakllava)';
setTimeout(() => {
errorMessage = null;
}, 5000);
}
}
}
/**
* Process image files and add to images array
*/
async function processImageFiles(files: File[]) {
// Import the processor dynamically to keep bundle small when not needed
const { processImageForOllama, isValidImageType, ImageProcessingError } = await import(
'$lib/ollama/image-processor'
);
const validFiles = files.filter(isValidImageType);
if (validFiles.length === 0) {
errorMessage = 'No valid image files. Supported: JPEG, PNG, GIF, WebP';
setTimeout(() => {
errorMessage = null;
}, 3000);
return;
}
// Limit to 4 images max
const maxImages = 4;
const remainingSlots = maxImages - images.length;
const filesToProcess = validFiles.slice(0, remainingSlots);
if (filesToProcess.length === 0) {
errorMessage = `Maximum ${maxImages} images allowed`;
setTimeout(() => {
errorMessage = null;
}, 3000);
return;
}
isProcessing = true;
errorMessage = null;
try {
const newImages: string[] = [];
for (const file of filesToProcess) {
try {
const processed = await processImageForOllama(file);
newImages.push(processed.base64);
} catch (err) {
if (err instanceof ImageProcessingError) {
console.error(`Failed to process ${file.name}:`, err.message);
errorMessage = err.message;
} else {
console.error(`Failed to process ${file.name}:`, err);
errorMessage = `Failed to process ${file.name}`;
}
}
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
}
} finally {
isProcessing = false;
}
}
// Set up paste listener
@@ -153,7 +237,7 @@
<div class="space-y-3">
<!-- Image upload section (only for vision models) -->
{#if supportsVision}
<ImageUpload {images} {onImagesChange} {disabled} />
<ImageUpload {images} {onImagesChange} {disabled} {hideDropZone} />
{/if}
<!-- File attachments preview -->

View File

@@ -1,7 +1,8 @@
<script lang="ts">
/**
* ImageUpload - Drag and drop image upload with clipboard support
* ImageUpload - Drag and drop image upload for vision models
* Supports multiple images, shows thumbnails, and handles removal
* Note: Clipboard paste is handled by FileUpload for consistent behavior
*/
import { processImageForOllama, isValidImageType, ImageProcessingError } from '$lib/ollama/image-processor';
@@ -16,13 +17,16 @@
disabled?: boolean;
/** Maximum number of images allowed */
maxImages?: number;
/** Hide the drop zone (when drag is handled by parent) */
hideDropZone?: boolean;
}
const {
images,
onImagesChange,
disabled = false,
maxImages = 4
maxImages = 4,
hideDropZone = false
}: Props = $props();
/** Drag over state for visual feedback */
@@ -144,32 +148,6 @@
}
}
/**
* Handle paste from clipboard
*/
function handlePaste(event: ClipboardEvent): void {
if (disabled) return;
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
event.preventDefault();
handleFiles(imageFiles);
}
}
/**
* Remove an image at the given index
*/
@@ -188,17 +166,24 @@
}
}
// Set up paste listener
$effect(() => {
if (!disabled) {
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}
});
// Note: Paste handling is done in FileUpload.svelte to ensure
// consistent behavior whether or not this component is rendered
</script>
<!-- Only show image previews when hideDropZone is true (parent handles drag) -->
{#if hideDropZone}
{#if images.length > 0}
<div class="flex flex-wrap gap-2">
{#each images as image, index (index)}
<ImagePreview
src={image}
onRemove={() => removeImage(index)}
alt={`Uploaded image ${index + 1}`}
/>
{/each}
</div>
{/if}
{:else}
<div class="space-y-3">
<!-- Image previews -->
{#if images.length > 0}
@@ -287,7 +272,7 @@
{#if isDragOver}
Drop images here
{:else}
Drag & drop, click, or paste images
Drag & drop or click to add images
{/if}
</p>
@@ -308,3 +293,4 @@
</div>
{/if}
</div>
{/if}

View File

@@ -40,9 +40,13 @@
const isUser = $derived(node.message.role === 'user');
const isAssistant = $derived(node.message.role === 'assistant');
const isSystem = $derived(node.message.role === 'system');
const hasContent = $derived(node.message.content.length > 0);
const hasToolCalls = $derived(node.message.toolCalls && node.message.toolCalls.length > 0);
// Detect summary messages (compressed conversation history)
const isSummaryMessage = $derived(node.message.isSummary === true);
// Detect tool result messages (sent as user role but should be hidden or styled differently)
const isToolResultMessage = $derived(
isUser && (
@@ -104,6 +108,34 @@
<!-- Hide tool result messages - they're internal API messages -->
{#if isToolResultMessage}
<!-- Tool results are handled in the assistant message display -->
{:else if isSummaryMessage}
<!-- Summary message - special compact styling -->
<article
class="mb-4 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4"
aria-label="Conversation summary"
>
<div class="flex items-start gap-3">
<!-- Archive/compress icon -->
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path d="M3.75 3A1.75 1.75 0 002 4.75v3.26a3.235 3.235 0 011.75-.51h12.5c.644 0 1.245.188 1.75.51V6.75A1.75 1.75 0 0016.25 5h-4.836a.25.25 0 01-.177-.073L9.823 3.513A1.75 1.75 0 008.586 3H3.75zM3.75 9A1.75 1.75 0 002 10.75v4.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0018 15.25v-4.5A1.75 1.75 0 0016.25 9H3.75z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<span class="text-xs font-medium text-amber-400">Conversation Summary</span>
<span class="text-xs text-slate-500">Earlier messages compressed</span>
</div>
<div class="prose prose-sm prose-invert max-w-none text-slate-300">
<MessageContent
content={node.message.content.replace('[Previous conversation summary]\n\n', '')}
{isStreaming}
{showThinking}
/>
</div>
</div>
</div>
</article>
{:else}
<article
class="group mb-6 flex gap-4"

View File

@@ -25,7 +25,8 @@
let userScrolledAway = $state(false);
// Track previous streaming state to detect when streaming ends
let wasStreaming = $state(false);
// Note: Using plain variables (not $state) to avoid re-triggering effects
let wasStreaming = false;
// Threshold for "near bottom" detection
const SCROLL_THRESHOLD = 100;
@@ -51,25 +52,25 @@
/**
* Scroll to bottom smoothly (for button click)
* Note: userScrolledAway is updated naturally by handleScroll when we reach bottom
*/
function scrollToBottom(): void {
if (anchorElement) {
anchorElement.scrollIntoView({ behavior: 'smooth', block: 'end' });
userScrolledAway = false;
}
}
/**
* Scroll to bottom instantly (for auto-scroll)
* Note: userScrolledAway is updated naturally by handleScroll when we reach bottom
*/
function scrollToBottomInstant(): void {
if (anchorElement) {
anchorElement.scrollIntoView({ block: 'end' });
userScrolledAway = false;
}
}
// Auto-scroll when streaming starts (if user hasn't scrolled away)
// Auto-scroll when streaming state changes
$effect(() => {
const isStreaming = chatState.isStreaming;
@@ -95,8 +96,22 @@
wasStreaming = isStreaming;
});
// Continuous scroll during streaming as content grows
$effect(() => {
// Track stream buffer changes - when content grows during streaming, scroll
const buffer = chatState.streamBuffer;
const isStreaming = chatState.isStreaming;
if (isStreaming && buffer && !userScrolledAway) {
requestAnimationFrame(() => {
scrollToBottomInstant();
});
}
});
// Scroll when new messages are added (user sends a message)
let previousMessageCount = $state(0);
// Note: Using plain variable to avoid creating a dependency that re-triggers the effect
let previousMessageCount = 0;
$effect(() => {
const currentCount = chatState.visibleMessages.length;

View File

@@ -0,0 +1,206 @@
<script lang="ts">
/**
* SystemPromptSelector - Dropdown to select a system prompt for the current conversation
* Allows per-conversation prompt assignment with quick preview
*/
import { promptsState, conversationsState, toastState } from '$lib/stores';
import { updateSystemPrompt } from '$lib/storage';
interface Props {
conversationId: string | null;
currentPromptId?: string | null;
}
let { conversationId, currentPromptId = null }: Props = $props();
// UI state
let isOpen = $state(false);
let dropdownElement: HTMLDivElement | null = $state(null);
// Available prompts from store
const prompts = $derived(promptsState.prompts);
// Current prompt for this conversation
const currentPrompt = $derived(
currentPromptId ? prompts.find((p) => p.id === currentPromptId) : null
);
// Display text for the button
const buttonText = $derived(currentPrompt?.name ?? 'No system prompt');
/**
* Toggle dropdown
*/
function toggleDropdown(): void {
isOpen = !isOpen;
}
/**
* Close dropdown
*/
function closeDropdown(): void {
isOpen = false;
}
/**
* Handle prompt selection
*/
async function handleSelect(promptId: string | null): Promise<void> {
if (!conversationId) return;
// Update in storage
const result = await updateSystemPrompt(conversationId, promptId);
if (result.success) {
// Update in memory
conversationsState.setSystemPrompt(conversationId, promptId);
const promptName = promptId ? prompts.find((p) => p.id === promptId)?.name : null;
toastState.success(promptName ? `Using "${promptName}"` : 'System prompt cleared');
} else {
toastState.error('Failed to update system prompt');
}
closeDropdown();
}
/**
* Handle click outside to close
*/
function handleClickOutside(event: MouseEvent): void {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
closeDropdown();
}
}
/**
* Handle escape key
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && isOpen) {
closeDropdown();
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative" bind:this={dropdownElement}>
<!-- Trigger button -->
<button
type="button"
onclick={toggleDropdown}
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors {currentPrompt
? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'}"
title={currentPrompt ? `System prompt: ${currentPrompt.name}` : 'Set system prompt'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
<span class="max-w-[120px] truncate">{buttonText}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Dropdown menu -->
{#if isOpen}
<div
class="absolute left-0 top-full z-50 mt-1 w-64 rounded-lg border border-slate-700 bg-slate-800 py-1 shadow-xl"
>
<!-- No prompt option -->
<button
type="button"
onclick={() => handleSelect(null)}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-slate-700 {!currentPromptId
? 'bg-slate-700/50 text-slate-100'
: 'text-slate-300'}"
>
<span class="flex-1">No system prompt</span>
{#if !currentPromptId}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 text-emerald-400"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
{#if prompts.length > 0}
<div class="my-1 border-t border-slate-700"></div>
<!-- Available prompts -->
{#each prompts as prompt}
<button
type="button"
onclick={() => handleSelect(prompt.id)}
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-slate-700 {currentPromptId ===
prompt.id
? 'bg-slate-700/50'
: ''}"
>
<div class="flex items-center gap-2">
<span
class="flex-1 text-sm font-medium {currentPromptId === prompt.id
? 'text-slate-100'
: 'text-slate-300'}"
>
{prompt.name}
{#if prompt.isDefault}
<span class="ml-1 text-xs text-emerald-400">(default)</span>
{/if}
</span>
{#if currentPromptId === prompt.id}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 text-emerald-400"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
{#if prompt.description}
<span class="line-clamp-1 text-xs text-slate-500">{prompt.description}</span>
{/if}
</button>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-slate-500">
No prompts available. <a href="/prompts" class="text-violet-400 hover:underline"
>Create one</a
>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -30,3 +30,11 @@ export { default as CodeBlock } from './CodeBlock.svelte';
// Indicators and states
export { default as StreamingIndicator } from './StreamingIndicator.svelte';
export { default as EmptyState } from './EmptyState.svelte';
// Context management
export { default as ContextUsageBar } from './ContextUsageBar.svelte';
export { default as ContextFullModal } from './ContextFullModal.svelte';
export { default as SummaryBanner } from './SummaryBanner.svelte';
// Prompt selection
export { default as SystemPromptSelector } from './SystemPromptSelector.svelte';

View File

@@ -167,8 +167,50 @@
</div>
{/if}
<!-- Right section: Chat actions -->
<!-- Right section: Theme toggle + Chat actions -->
<div class="flex items-center gap-1">
<!-- Theme toggle (always visible) -->
<button
type="button"
onclick={() => uiState.toggleDarkMode()}
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
aria-label={uiState.darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
title={uiState.darkMode ? 'Light mode' : 'Dark mode'}
>
{#if uiState.darkMode}
<!-- Sun icon - shown in dark mode to switch to light -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
/>
</svg>
{:else}
<!-- Moon icon - shown in light mode to switch to dark -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
/>
</svg>
{/if}
</button>
{#if isInChat}
<!-- Export button -->

View File

@@ -0,0 +1,360 @@
<script lang="ts">
/**
* SearchModal - Global search modal for conversations and messages
* Supports searching both conversation titles and message content
*/
import { goto } from '$app/navigation';
import { searchConversations, searchMessages, type MessageSearchResult } from '$lib/storage';
import { conversationsState } from '$lib/stores';
import type { Conversation } from '$lib/types/conversation';
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
// Search state
let searchQuery = $state('');
let activeTab = $state<'titles' | 'messages'>('titles');
let isSearching = $state(false);
// Results
let titleResults = $state<Conversation[]>([]);
let messageResults = $state<MessageSearchResult[]>([]);
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Input element for focus
let inputElement: HTMLInputElement | null = $state(null);
/**
* Debounced search
*/
function handleSearch(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (!searchQuery.trim()) {
titleResults = [];
messageResults = [];
return;
}
debounceTimer = setTimeout(async () => {
isSearching = true;
try {
// Search both in parallel
const [titlesResult, messagesResult] = await Promise.all([
searchConversations(searchQuery),
searchMessages(searchQuery, { limit: 30 })
]);
if (titlesResult.success) {
titleResults = titlesResult.data;
}
if (messagesResult.success) {
messageResults = messagesResult.data;
}
} finally {
isSearching = false;
}
}, 200);
}
/**
* Navigate to a conversation
*/
function navigateToConversation(conversationId: string): void {
onClose();
goto(`/chat/${conversationId}`);
}
/**
* Get a snippet around the match
*/
function getSnippet(content: string, matchIndex: number, query: string): string {
const snippetLength = 100;
const start = Math.max(0, matchIndex - 40);
const end = Math.min(content.length, matchIndex + query.length + 60);
let snippet = content.slice(start, end);
// Add ellipsis if truncated
if (start > 0) snippet = '...' + snippet;
if (end < content.length) snippet = snippet + '...';
return snippet;
}
/**
* Get conversation title for a message result
*/
function getConversationTitle(conversationId: string): string {
const conversation = conversationsState.find(conversationId);
return conversation?.title ?? 'Unknown conversation';
}
/**
* Handle backdrop click
*/
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
/**
* Handle keyboard events
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
/**
* Reset state and close
*/
function handleClose(): void {
searchQuery = '';
titleResults = [];
messageResults = [];
activeTab = 'titles';
onClose();
}
// Focus input when modal opens
$effect(() => {
if (isOpen && inputElement) {
setTimeout(() => inputElement?.focus(), 50);
}
});
// Clear results when closing
$effect(() => {
if (!isOpen) {
searchQuery = '';
titleResults = [];
messageResults = [];
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="search-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-2xl rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<!-- Search input -->
<div class="flex items-center gap-3 border-b border-slate-700 px-4 py-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
bind:this={inputElement}
bind:value={searchQuery}
oninput={handleSearch}
type="text"
placeholder="Search conversations and messages..."
class="flex-1 bg-transparent text-slate-100 placeholder-slate-500 focus:outline-none"
/>
{#if isSearching}
<svg class="h-5 w-5 animate-spin text-slate-400" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
/>
<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"
/>
</svg>
{:else if searchQuery}
<button
type="button"
onclick={() => {
searchQuery = '';
titleResults = [];
messageResults = [];
inputElement?.focus();
}}
class="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
aria-label="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
<kbd class="rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-500">Esc</kbd>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-700">
<button
type="button"
onclick={() => (activeTab = 'titles')}
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'titles'
? 'border-b-2 border-violet-500 text-violet-400'
: 'text-slate-400 hover:text-slate-200'}"
>
Titles
{#if titleResults.length > 0}
<span class="ml-1.5 rounded-full bg-slate-800 px-1.5 py-0.5 text-xs"
>{titleResults.length}</span
>
{/if}
</button>
<button
type="button"
onclick={() => (activeTab = 'messages')}
class="flex-1 px-4 py-2 text-sm font-medium transition-colors {activeTab === 'messages'
? 'border-b-2 border-violet-500 text-violet-400'
: 'text-slate-400 hover:text-slate-200'}"
>
Messages
{#if messageResults.length > 0}
<span class="ml-1.5 rounded-full bg-slate-800 px-1.5 py-0.5 text-xs"
>{messageResults.length}</span
>
{/if}
</button>
</div>
<!-- Results -->
<div class="max-h-[50vh] overflow-y-auto">
{#if !searchQuery.trim()}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
<svg class="mb-3 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<p class="text-sm">Start typing to search...</p>
</div>
{:else if activeTab === 'titles'}
{#if titleResults.length === 0 && !isSearching}
<div class="py-8 text-center text-sm text-slate-500">
No conversations found matching "{searchQuery}"
</div>
{:else}
<div class="divide-y divide-slate-800">
{#each titleResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.id)}
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-800"
>
<svg class="h-4 w-4 flex-shrink-0 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-200">
{result.title}
</p>
<p class="text-xs text-slate-500">
{result.messageCount} messages · {result.model}
</p>
</div>
{#if result.isPinned}
<svg class="h-4 w-4 text-emerald-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
{:else}
{#if messageResults.length === 0 && !isSearching}
<div class="py-8 text-center text-sm text-slate-500">
No messages found matching "{searchQuery}"
</div>
{:else}
<div class="divide-y divide-slate-800">
{#each messageResults as result}
<button
type="button"
onclick={() => navigateToConversation(result.conversationId)}
class="flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-slate-800"
>
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {result.role ===
'user'
? 'bg-blue-500/20 text-blue-400'
: 'bg-emerald-500/20 text-emerald-400'}"
>
{result.role}
</span>
<span class="truncate text-xs text-slate-500">
{getConversationTitle(result.conversationId)}
</span>
</div>
<p class="line-clamp-2 text-sm text-slate-300">
{getSnippet(result.content, result.matchIndex, searchQuery)}
</p>
</button>
{/each}
</div>
{/if}
{/if}
</div>
<!-- Footer hint -->
<div class="border-t border-slate-700 px-4 py-2">
<p class="text-center text-xs text-slate-500">
<kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Enter</kbd> to select ·
<kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Tab</kbd> to switch tabs
</p>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
/**
* ShortcutsModal - Display available keyboard shortcuts
*/
import { keyboardShortcuts, formatShortcut, type Shortcut } from '$lib/utils/keyboard';
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
// Get all registered shortcuts
const shortcuts = $derived(keyboardShortcuts.getShortcuts());
// Group shortcuts by category
const groupedShortcuts = $derived.by(() => {
const groups: Record<string, Shortcut[]> = {
'Navigation': [],
'Chat': [],
'General': []
};
for (const shortcut of shortcuts) {
if (['new-chat', 'search', 'toggle-sidenav'].includes(shortcut.id)) {
groups['Navigation'].push(shortcut);
} else if (['focus-input', 'send-message', 'stop-generation'].includes(shortcut.id)) {
groups['Chat'].push(shortcut);
} else {
groups['General'].push(shortcut);
}
}
return groups;
});
/**
* Handle backdrop click to close
*/
function handleBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
onClose();
}
}
/**
* Handle escape key to close
*/
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
>
<!-- Dialog -->
<div class="mx-4 w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-700 px-6 py-4">
<h2 id="shortcuts-dialog-title" class="text-lg font-semibold text-slate-100">
Keyboard Shortcuts
</h2>
<button
type="button"
onclick={onClose}
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto px-6 py-4">
{#each Object.entries(groupedShortcuts) as [group, items]}
{#if items.length > 0}
<div class="mb-4 last:mb-0">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
{group}
</h3>
<div class="space-y-2">
{#each items as shortcut}
<div class="flex items-center justify-between">
<span class="text-sm text-slate-300">{shortcut.description}</span>
<kbd class="rounded bg-slate-800 px-2 py-1 font-mono text-xs text-slate-400">
{formatShortcut(shortcut.key, shortcut.modifiers)}
</kbd>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
<!-- Footer -->
<div class="border-t border-slate-700 px-6 py-3">
<p class="text-center text-xs text-slate-500">
Press <kbd class="rounded bg-slate-800 px-1.5 py-0.5 font-mono">Shift+?</kbd> to toggle this panel
</p>
</div>
</div>
</div>
{/if}

View File

@@ -11,3 +11,5 @@ export { default as Skeleton } from './Skeleton.svelte';
export { default as MessageSkeleton } from './MessageSkeleton.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
export { default as SettingsModal } from './SettingsModal.svelte';
export { default as ShortcutsModal } from './ShortcutsModal.svelte';
export { default as SearchModal } from './SearchModal.svelte';

View File

@@ -22,8 +22,11 @@ Keep the summary brief but complete. Write in third person.
Conversation:
`;
/** Minimum messages to consider for summarization */
const MIN_MESSAGES_FOR_SUMMARY = 6;
/** Minimum messages required in the toSummarize array (after filtering) */
const MIN_MESSAGES_TO_SUMMARIZE = 2;
/** Minimum total messages before showing summarization option */
const MIN_TOTAL_MESSAGES_FOR_SUMMARY = 6;
/** How many recent messages to always preserve */
const PRESERVE_RECENT_MESSAGES = 4;
@@ -54,7 +57,7 @@ export async function generateSummary(
messages: MessageNode[],
model: string
): Promise<string> {
if (messages.length < MIN_MESSAGES_FOR_SUMMARY) {
if (messages.length < MIN_MESSAGES_TO_SUMMARIZE) {
throw new Error('Not enough messages to summarize');
}
@@ -151,8 +154,15 @@ export function shouldSummarize(
maxTokens: number,
messageCount: number
): boolean {
// Don't summarize if too few messages
if (messageCount < MIN_MESSAGES_FOR_SUMMARY) {
// Don't show summarization if not enough total messages
if (messageCount < MIN_TOTAL_MESSAGES_FOR_SUMMARY) {
return false;
}
// Check if there would actually be messages to summarize after filtering
// (messageCount - PRESERVE_RECENT_MESSAGES = messages available for summarization)
const summarizableCount = messageCount - PRESERVE_RECENT_MESSAGES;
if (summarizableCount < MIN_MESSAGES_TO_SUMMARIZE) {
return false;
}

View File

@@ -20,7 +20,8 @@ function toDomainConversation(stored: StoredConversation): Conversation {
updatedAt: new Date(stored.updatedAt),
isPinned: stored.isPinned,
isArchived: stored.isArchived,
messageCount: stored.messageCount
messageCount: stored.messageCount,
systemPromptId: stored.systemPromptId ?? null
};
}
@@ -42,7 +43,8 @@ function toStoredConversation(
updatedAt: conversation.updatedAt?.getTime() ?? now,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
messageCount: conversation.messageCount
messageCount: conversation.messageCount,
systemPromptId: conversation.systemPromptId ?? null
};
}
@@ -139,7 +141,8 @@ export async function createConversation(
isPinned: data.isPinned ?? false,
isArchived: data.isArchived ?? false,
messageCount: 0,
syncVersion: 1
syncVersion: 1,
systemPromptId: data.systemPromptId ?? null
};
await db.conversations.add(stored);
@@ -279,6 +282,16 @@ export async function updateMessageCount(
});
}
/**
* Update the system prompt for a conversation
*/
export async function updateSystemPrompt(
conversationId: string,
systemPromptId: string | null
): Promise<StorageResult<Conversation>> {
return updateConversation(conversationId, { systemPromptId });
}
/**
* Search conversations by title
*/

View File

@@ -19,6 +19,8 @@ export interface StoredConversation {
isArchived: boolean;
messageCount: number;
syncVersion?: number;
/** Optional system prompt ID for this conversation */
systemPromptId?: string | null;
}
/**
@@ -34,6 +36,8 @@ export interface ConversationRecord {
isArchived: boolean;
messageCount: number;
syncVersion?: number;
/** Optional system prompt ID for this conversation */
systemPromptId?: string | null;
}
/**
@@ -186,6 +190,19 @@ class OllamaDatabase extends Dexie {
// System prompt templates
prompts: 'id, name, isDefault, updatedAt'
});
// Version 4: Per-conversation system prompts
// Note: No schema change needed - just adding optional field to conversations
// Dexie handles this gracefully (field is undefined on old records)
this.version(4).stores({
conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId',
messages: 'id, conversationId, parentId, createdAt',
attachments: 'id, messageId',
syncQueue: 'id, entityType, createdAt',
documents: 'id, name, createdAt, updatedAt',
chunks: 'id, documentId',
prompts: 'id, name, isDefault, updatedAt'
});
}
}

View File

@@ -26,6 +26,7 @@ export {
pinConversation,
archiveConversation,
updateMessageCount,
updateSystemPrompt,
searchConversations
} from './conversations.js';
@@ -40,8 +41,10 @@ export {
getMessageTree,
getSiblings,
getPathToMessage,
appendToMessage
appendToMessage,
searchMessages
} from './messages.js';
export type { MessageSearchResult } from './messages.js';
// Attachment operations
export {

View File

@@ -387,3 +387,68 @@ export async function appendToMessage(
});
});
}
/** Search result for message content search */
export interface MessageSearchResult {
messageId: string;
conversationId: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
matchIndex: number;
createdAt: Date;
}
/**
* Search messages by content
* Returns matching messages with context snippets
*/
export async function searchMessages(
query: string,
options?: {
conversationId?: string;
limit?: number;
}
): Promise<StorageResult<MessageSearchResult[]>> {
return withErrorHandling(async () => {
if (!query.trim()) {
return [];
}
const lowerQuery = query.toLowerCase().trim();
const limit = options?.limit ?? 50;
// Get messages (optionally filtered by conversation)
let messagesQuery = db.messages.orderBy('createdAt').reverse();
const allMessages = await messagesQuery.toArray();
// Filter by content match and optional conversation filter
const matches: MessageSearchResult[] = [];
for (const msg of allMessages) {
if (matches.length >= limit) break;
// Skip if filtering by conversation and doesn't match
if (options?.conversationId && msg.conversationId !== options.conversationId) {
continue;
}
// Check for content match
const lowerContent = msg.content.toLowerCase();
const matchIndex = lowerContent.indexOf(lowerQuery);
if (matchIndex !== -1) {
matches.push({
messageId: msg.id,
conversationId: msg.conversationId,
role: msg.role,
content: msg.content,
matchIndex,
createdAt: new Date(msg.createdAt)
});
}
}
return matches;
});
}

View File

@@ -23,20 +23,20 @@ export class ChatState {
streamingMessageId = $state<string | null>(null);
streamBuffer = $state('');
// Derived: Get visible messages along the active path (excluding hidden messages for UI)
// Derived: Get visible messages along the active path (excluding hidden and summarized messages for UI)
visibleMessages = $derived.by(() => {
const messages: MessageNode[] = [];
for (const id of this.activePath) {
const node = this.messageTree.get(id);
// Skip hidden messages (e.g., internal tool result context)
if (node && !node.message.hidden) {
// Skip hidden messages and summarized messages (but show summary messages)
if (node && !node.message.hidden && !node.message.isSummarized) {
messages.push(node);
}
}
return messages;
});
// Derived: Get ALL messages along active path (including hidden, for API calls)
// Derived: Get ALL messages along active path (including hidden, for storage)
allMessages = $derived.by(() => {
const messages: MessageNode[] = [];
for (const id of this.activePath) {
@@ -48,6 +48,19 @@ export class ChatState {
return messages;
});
// Derived: Get messages for API context (excludes summarized originals, includes summaries)
messagesForContext = $derived.by(() => {
const messages: MessageNode[] = [];
for (const id of this.activePath) {
const node = this.messageTree.get(id);
// Exclude summarized messages but include summary messages and hidden tool results
if (node && !node.message.isSummarized) {
messages.push(node);
}
}
return messages;
});
// Derived: Can regenerate the last assistant message
canRegenerate = $derived.by(() => {
if (this.activePath.length === 0 || this.isStreaming) {
@@ -364,6 +377,126 @@ export class ChatState {
return Array.from(this.messageTree.values()).filter((node) => node.childIds.length === 0);
}
/**
* Mark messages as summarized (they'll be hidden from UI and context)
* @param messageIds Array of message IDs to mark as summarized
*/
markAsSummarized(messageIds: string[]): void {
const newTree = new Map(this.messageTree);
for (const id of messageIds) {
const node = newTree.get(id);
if (node) {
newTree.set(id, {
...node,
message: {
...node.message,
isSummarized: true
}
});
}
}
this.messageTree = newTree;
}
/**
* Insert a summary message at the beginning of the conversation (after system message if present)
* @param summaryText The summary content
* @returns The ID of the inserted summary message
*/
insertSummaryMessage(summaryText: string): string {
const id = generateId();
// Find the first non-system message position in the active path
let insertIndex = 0;
for (let i = 0; i < this.activePath.length; i++) {
const node = this.messageTree.get(this.activePath[i]);
if (node?.message.role !== 'system') {
insertIndex = i;
break;
}
insertIndex = i + 1;
}
// Determine parent: either the last system message or null
const parentId = insertIndex > 0 ? this.activePath[insertIndex - 1] : null;
// Find the original first non-summarized message to connect to
let nextMessageId: string | null = null;
for (let i = insertIndex; i < this.activePath.length; i++) {
const node = this.messageTree.get(this.activePath[i]);
if (node && !node.message.isSummarized) {
nextMessageId = this.activePath[i];
break;
}
}
// Create the summary node
const summaryNode: MessageNode = {
id,
message: {
role: 'system',
content: `[Previous conversation summary]\n\n${summaryText}`,
isSummary: true
},
parentId,
childIds: nextMessageId ? [nextMessageId] : [],
createdAt: new Date()
};
const newTree = new Map(this.messageTree);
// Update parent to point to summary instead of original child
if (parentId) {
const parent = newTree.get(parentId);
if (parent && nextMessageId) {
newTree.set(parentId, {
...parent,
childIds: parent.childIds.map((cid) => (cid === nextMessageId ? id : cid))
});
} else if (parent) {
newTree.set(parentId, {
...parent,
childIds: [...parent.childIds, id]
});
}
}
// Update next message's parent to point to summary
if (nextMessageId) {
const nextNode = newTree.get(nextMessageId);
if (nextNode) {
newTree.set(nextMessageId, {
...nextNode,
parentId: id
});
}
}
// Add summary node
newTree.set(id, summaryNode);
this.messageTree = newTree;
// Update root if summary is at the beginning
if (!parentId) {
this.rootMessageId = id;
}
// Rebuild active path to include summary
const newPath: string[] = [];
for (let i = 0; i < insertIndex; i++) {
newPath.push(this.activePath[i]);
}
newPath.push(id);
for (let i = insertIndex; i < this.activePath.length; i++) {
newPath.push(this.activePath[i]);
}
this.activePath = newPath;
return id;
}
/**
* Reset the chat state
*/

View File

@@ -195,6 +195,15 @@ export class ConversationsState {
clearSearch(): void {
this.searchQuery = '';
}
/**
* Update the system prompt for a conversation
* @param id The conversation ID
* @param systemPromptId The prompt ID (or null to clear)
*/
setSystemPrompt(id: string, systemPromptId: string | null): void {
this.update(id, { systemPromptId });
}
}
/** Singleton conversations state instance */

View File

@@ -9,7 +9,7 @@ import { ollamaClient } from '$lib/ollama/client.js';
import { fetchRemoteModels, type RemoteModel } from '$lib/api/model-registry';
/** Known vision model families/patterns (fallback if API doesn't report) */
const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision'];
const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision', 'ministral', 'pixtral', 'minicpm-v'];
/** Capability display metadata */
export const CAPABILITY_INFO: Record<string, { label: string; icon: string; color: string }> = {
@@ -128,8 +128,15 @@ export class ModelsState {
});
// Derived: Check if selected model supports vision
// Uses capabilities cache first (from Ollama API), falls back to pattern matching
selectedSupportsVision = $derived.by(() => {
if (!this.selected) return false;
// Check capabilities cache first (most accurate)
const caps = this.capabilitiesCache.get(this.selected.name);
if (caps) {
return caps.includes('vision');
}
// Fallback to pattern matching
return isVisionModel(this.selected);
});

View File

@@ -24,6 +24,10 @@ export interface Message {
toolCalls?: ToolCall[];
/** If true, message is hidden from UI (e.g., internal tool result messages) */
hidden?: boolean;
/** If true, this message has been summarized and should be excluded from context */
isSummarized?: boolean;
/** If true, this is a summary message representing compressed conversation history */
isSummary?: boolean;
}
/** A node in the message tree structure (for branching conversations) */

View File

@@ -14,6 +14,8 @@ export interface Conversation {
isPinned: boolean;
isArchived: boolean;
messageCount: number;
/** Optional system prompt ID for this conversation (null = use global default) */
systemPromptId?: string | null;
}
/** Full conversation including message tree and navigation state */

View File

@@ -237,6 +237,18 @@ export function getShortcuts() {
modifiers: getPrimaryModifiers(),
description: 'Toggle sidebar'
},
FOCUS_INPUT: {
id: 'focus-input',
key: '/',
modifiers: getPrimaryModifiers(),
description: 'Focus chat input'
},
SHOW_SHORTCUTS: {
id: 'show-shortcuts',
key: '?',
modifiers: { shift: true },
description: 'Show keyboard shortcuts'
},
CLOSE_MODAL: {
id: 'close-modal',
key: 'Escape',

View File

@@ -14,7 +14,7 @@
import Sidenav from '$lib/components/layout/Sidenav.svelte';
import TopNav from '$lib/components/layout/TopNav.svelte';
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
import { ToastContainer } from '$lib/components/shared';
import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared';
import type { LayoutData } from './$types';
import type { Snippet } from 'svelte';
@@ -32,6 +32,9 @@
// Search modal state
let showSearchModal = $state(false);
// Shortcuts modal state
let showShortcutsModal = $state(false);
onMount(() => {
// Initialize UI state (handles responsive detection, theme, etc.)
uiState.initialize();
@@ -82,20 +85,12 @@
}
});
// Search (Cmd/Ctrl + K)
// Search (Cmd/Ctrl + K) - opens global search modal
keyboardShortcuts.register({
...SHORTCUTS.SEARCH,
preventDefault: true,
handler: () => {
// Focus the search input in sidenav if open, otherwise open sidenav
if (!uiState.sidenavOpen) {
uiState.openSidenav();
}
// Focus search after a short delay to allow sidenav to open
setTimeout(() => {
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
searchInput?.focus();
}, 100);
showSearchModal = true;
}
});
@@ -107,6 +102,25 @@
uiState.toggleSidenav();
}
});
// Focus chat input (Cmd/Alt + /)
keyboardShortcuts.register({
...SHORTCUTS.FOCUS_INPUT,
preventDefault: true,
handler: () => {
const chatInput = document.querySelector('[data-chat-input]') as HTMLTextAreaElement;
chatInput?.focus();
}
});
// Show keyboard shortcuts (Shift + ?)
keyboardShortcuts.register({
...SHORTCUTS.SHOW_SHORTCUTS,
preventDefault: true,
handler: () => {
showShortcutsModal = !showShortcutsModal;
}
});
}
/**
@@ -157,3 +171,9 @@
<!-- Toast notifications -->
<ToastContainer />
<!-- Keyboard shortcuts help -->
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />
<!-- Global search modal -->
<SearchModal isOpen={showSearchModal} onClose={() => (showSearchModal = false)} />