ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
@@ -28,18 +28,18 @@
}
let {
class: className = '',
style = '',
activeModelId,
attachments = [],
readonly = false,
onFileRemove,
uploadedFiles = $bindable([]),
class: className = '',
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId
onFileRemove,
readonly = false,
style = '',
uploadedFiles = $bindable([])
}: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state();
@@ -48,7 +48,7 @@
let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false);
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
@@ -2,8 +2,8 @@
import {
ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource,
ChatAttachmentsListItemThumbnailImage,
ChatAttachmentsListItemThumbnailFile
ChatAttachmentsListItemThumbnailFile,
ChatAttachmentsListItemThumbnailImage
} from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
import type {
@@ -49,10 +49,10 @@
return {
id,
resource: {
uri: extra.uri,
name: extra.name,
serverName: extra.serverName,
title: extra.name,
serverName: extra.serverName
uri: extra.uri
}
};
}
@@ -64,12 +64,12 @@
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
arguments: item.uploadedFile.mcpPrompt.arguments,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
name: item.name,
promptName: item.uploadedFile.mcpPrompt.promptName,
serverName: item.uploadedFile.mcpPrompt.serverName,
type: AttachmentType.MCP_PROMPT as const
}
: null}
{#if mcpPrompt}
@@ -1,8 +1,8 @@
<script lang="ts">
import { ChatMessageMcpPromptContent, ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { ActionIcon, ChatMessageMcpPromptContent } from '$lib/components/app';
import { McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
@@ -1,11 +1,11 @@
<script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte';
import { AlertCircle, Loader2 } from '@lucide/svelte';
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
import { getResourceDisplayName, getResourceIcon } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
@@ -24,6 +24,7 @@
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
@@ -1,17 +1,17 @@
<script lang="ts">
import { Music, Video, X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { X, Music, Video } from '@lucide/svelte';
import { AttachmentType } from '$lib/enums';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
isPdfFile,
isAudioFile,
isVideoFile,
isTextFile
isPdfFile,
isTextFile,
isVideoFile
} from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
interface Props {
attachment?: DatabaseMessageExtra;
@@ -31,9 +31,9 @@
attachment,
class: className = '',
id,
name,
onclick,
onRemove,
name,
readonly = false,
size,
textContent,
@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
interface Props {
class?: string;
@@ -20,9 +20,9 @@
height = 'h-16',
id,
imageClass = '',
name,
onclick,
onRemove,
name,
preview,
readonly = false,
width = 'w-auto'
@@ -12,12 +12,12 @@
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
isVideoFile,
isImageFile,
isMcpPrompt,
isMcpResource,
isPdfFile,
isTextFile
isTextFile,
isVideoFile
} from '$lib/utils';
interface PreviewItem {
@@ -42,21 +42,21 @@
}
let {
uploadedFiles = [],
attachments = [],
activeModelId,
attachments = [],
class: className = '',
previewFocusIndex = 0
previewFocusIndex = 0,
uploadedFiles = []
}: Props = $props();
let allItems = $derived(
getAttachmentDisplayItems({ uploadedFiles, attachments })
getAttachmentDisplayItems({ attachments, uploadedFiles })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map(
(item): PreviewItem => ({
...item,
isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile),
isImage: isImageFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
@@ -88,10 +88,11 @@
$effect(() => {
const index = currentIndex;
setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 0);
});
@@ -1,12 +1,12 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import { FileIcon, FileText, Image, Music, Video } from '@lucide/svelte';
import type { ChatAttachmentDisplayItem } from '$lib/types';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
@@ -25,19 +25,19 @@
}
let {
activeModelId,
audioSrc,
currentItem,
isImage,
isAudio,
isVideo,
isPdf,
isText,
displayPreview,
displayTextContent,
audioSrc,
videoSrc,
language,
hasVisionModality,
activeModelId
isAudio,
isImage,
isPdf,
isText,
isVideo,
language,
videoSrc
}: Props = $props();
let IconComponent = $derived(
@@ -6,7 +6,7 @@
audioSrc: string | null;
}
let { currentItem, audioSrc }: Props = $props();
let { audioSrc, currentItem }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
@@ -1,13 +1,13 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { FileText, Eye, Info } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Alert from '$lib/components/ui/alert';
import { Eye, FileText, Info } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { PdfViewMode } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { PdfViewMode } from '$lib/enums';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
@@ -17,7 +17,7 @@
activeModelId?: string;
}
let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props =
let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: Props =
$props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
@@ -47,6 +47,7 @@
currentItem.attachment.images.length > 0
) {
pdfImages = currentItem.attachment.images;
return;
}
@@ -55,10 +56,12 @@
const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
@@ -8,7 +8,7 @@
show: boolean;
}
let { onPrev, onNext, show }: Props = $props();
let { onNext, onPrev, show }: Props = $props();
</script>
{#if show}
@@ -1,7 +1,7 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Music, Video, FileText } from '@lucide/svelte';
import { FileText, Music, Video } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface PreviewItem {
id: string;
@@ -18,13 +18,15 @@
onNavigate: (index: number) => void;
}
let { items, currentIndex, onNavigate }: Props = $props();
let { currentIndex, items, onNavigate }: Props = $props();
function getFileExtension(name: string): string {
const parts = name.split('.');
if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? '';
}
return '';
}
</script>
@@ -1,4 +1,5 @@
<script lang="ts">
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import {
ChatAttachmentsList,
ChatFormActions,
@@ -12,10 +13,10 @@
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR
INPUT_CLASSES,
PROMPT_CONTENT_SEPARATOR,
SETTING_CONFIG_DEFAULT
} from '$lib/constants';
import {
ContentPartType,
@@ -24,20 +25,20 @@
MimeTypeText,
SpecialFileType
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import {
conversationsStore,
activeMessages,
activeConversation,
activeMessages,
conversationsStore,
pendingCwd
} from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
FileMentionEntry,
GetPromptResult,
@@ -56,7 +57,6 @@
parseClipboardContent,
uuid
} from '$lib/utils';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
AudioRecorder,
convertToWav,
@@ -96,12 +96,6 @@
class: className = '',
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop,
@@ -109,7 +103,13 @@
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
onValueChange,
placeholder = 'Type a message...',
showAddButton = true,
showMcpPromptButton = false,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable('')
}: Props = $props();
// Component References
@@ -146,32 +146,35 @@
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
const pickers = useChatFormPickers({
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
getPickersRef: () => pickersRef,
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
value = v;
onValueChange?.(v);
},
getCaretOffset: () => inputRef?.getCaretOffset(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
getShowModelSelector: () => showModelSelector,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
getCwd: () => cwd,
getServerHome: () => toolsStore.serverHome ?? null,
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
getPickersRef: () => pickersRef
}
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null);
}
@@ -185,6 +188,7 @@
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
@@ -200,13 +204,16 @@
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
@@ -238,6 +245,7 @@
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) {
@@ -268,8 +276,10 @@
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
@@ -284,6 +294,7 @@
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
@@ -333,6 +344,7 @@
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
@@ -354,26 +366,27 @@
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
}
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
id: uuid(),
name: att.name,
size: att.content.length,
type: SpecialFileType.MCP_PROMPT,
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
id: uuid(),
isLoading: false,
textContent: att.content,
mcpPrompt: {
serverName: att.serverName,
arguments: att.arguments,
promptName: att.promptName,
arguments: att.arguments
}
serverName: att.serverName
},
name: att.name,
size: att.content.length,
textContent: att.content,
type: SpecialFileType.MCP_PROMPT
}));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
@@ -412,17 +425,17 @@
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'),
id: placeholderId,
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
arguments: args ? { ...args } : undefined,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
serverName: promptInfo.serverName
},
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT
};
uploadedFiles = [...uploadedFiles, placeholder];
@@ -450,12 +463,12 @@
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
})
}),
isLoading: false,
size: promptText.length,
textContent: promptText
}
: f
);
@@ -480,9 +493,11 @@
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
@@ -504,6 +519,7 @@
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
@@ -642,7 +658,7 @@
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
@@ -1,9 +1,9 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
disabled?: boolean;
@@ -1,20 +1,20 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
import {
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu,
ChatFormActionAddToolsSubmenu
} from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import {
ChatFormActionAddToolsSubmenu,
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu
} from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
@@ -36,15 +36,15 @@
class: className = '',
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onMcpResourcesClick
onSystemPromptClick
}: Props = $props();
let dropdownOpen = $state(false);
@@ -59,13 +59,13 @@
const attachmentMenu = useAttachmentMenu(
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
hasMcpResourcesSupport,
hasVideoModality,
hasVisionModality
}),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => {
dropdownOpen = false;
}
@@ -1,15 +1,15 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Plus, Settings } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app';
import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ROUTES } from '$lib/constants/routes';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants/routes';
interface Props {
onMcpSettingsClick?: () => void;
@@ -24,10 +24,13 @@
let hasMcpServers = $derived(mcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
@@ -1,8 +1,8 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
const reasoning = useReasoningMenu();
@@ -1,30 +1,30 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import * as Collapsible from '$lib/components/ui/collapsible';
import { File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { McpLogo } from '$lib/components/app';
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import {
PencilRuler,
Check,
ChevronDown,
ChevronRight,
Lightbulb,
LightbulbOff,
Check
PencilRuler
} from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
@@ -45,14 +45,14 @@
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick,
onSystemPromptClick,
trigger
}: Props = $props();
@@ -64,13 +64,13 @@
const attachmentMenu = useAttachmentMenu(
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
hasMcpResourcesSupport,
hasVideoModality,
hasVisionModality
}),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => {
sheetOpen = false;
}
@@ -1,14 +1,14 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toolsStore } from '$lib/stores/tools.svelte';
import { CLI_FLAGS } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
@@ -1,8 +1,8 @@
<script lang="ts">
import { isMobile } from '$lib/stores/viewport.svelte';
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
interface Props {
disabled?: boolean;
@@ -21,9 +21,9 @@
let {
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
@@ -1,15 +1,15 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import {
modelsStore,
modelOptions,
modelsStore,
selectedModelId,
selectedModelName
} from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { isMobile } from '$lib/stores/viewport.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
disabled?: boolean;
@@ -27,9 +27,9 @@
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
hasModelSelected = $bindable(false),
hasVideoModality = $bindable(false),
hasVisionModality = $bindable(false),
hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true),
submitTooltip = $bindable(''),
useGlobalSelection = false
@@ -46,6 +46,7 @@
let selectorModel = $derived.by(() => {
const storeModel = selectedModelName();
if (storeModel && storeModel !== conversationModel) {
return storeModel;
}
@@ -66,6 +67,7 @@
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
@@ -76,6 +78,7 @@
) {
lastSyncedConversationModel = null;
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
@@ -1,8 +1,8 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
class?: string;
@@ -1,28 +1,28 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Square, SkipForward } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { ChatService } from '$lib/services';
import { SkipForward, Square } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
ChatFormActionsAdd,
ChatFormActionSubmit,
ChatFormContextGauge
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ROUTES } from '$lib/constants/routes';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { config } from '$lib/stores/settings.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { ChatService } from '$lib/services';
import {
activeProcessingState,
isChatStreaming,
isLoading as chatIsLoading
} from '$lib/stores/chat.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { config } from '$lib/stores/settings.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants/routes';
interface Props {
canSend?: boolean;
@@ -51,15 +51,15 @@
isLoading = false,
isReasoning = false,
isRecording = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = [],
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMicClick,
onStop,
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick
showAddButton = true,
showModelSelector = true,
uploadedFiles = []
}: Props = $props();
let currentConfig = $derived(config());
@@ -105,29 +105,39 @@
if (!page.params.id) return false;
const messages = activeMessages() as DatabaseMessage[];
let totalHistoricalTokens = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT) continue;
const timings = m.timings;
if (!timings) continue;
const agenticLlm = timings.agentic?.llm;
if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
} else {
totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
}
}
if (totalHistoricalTokens > 0) return true;
if (!chatIsLoading() && !isChatStreaming()) return false;
const processingState = activeProcessingState();
if (!processingState) return false;
const livePromptTokens = Math.max(
processingState.promptTokens ?? 0,
processingState.promptProgress?.processed ?? 0
);
const liveOutputTokens = processingState.outputTokensUsed ?? 0;
return livePromptTokens > 0 || liveOutputTokens > 0;
});
</script>
@@ -1,11 +1,8 @@
<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { isMobile } from '$lib/stores/viewport.svelte';
import { ColorMode } from '$lib/enums';
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
import {
badgeAwareWordJump,
buildFragment,
@@ -19,10 +16,13 @@
SourceHistory,
stripBlockBoundaryLineBreaks,
syncCodeBlockHatches,
tokenizeContent,
textOffsetToRange
textOffsetToRange,
tokenizeContent
} from '$lib/utils';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { mode } from 'mode-watcher';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props {
class?: string;
@@ -57,7 +57,9 @@
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
if (!rootElement) return;
const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
}
@@ -93,23 +95,24 @@
*/
function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? '';
if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment);
if (!open) return false;
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
el.replaceChildren(
@@ -118,6 +121,7 @@
document.createTextNode(trailing + '```')
);
highlightedSegments.set(el, segment);
return true;
}
@@ -136,9 +140,11 @@
if (!rootElement) return;
const range = safeRange();
if (!range) return;
let node: Node | null = range.startContainer;
if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null;
}
@@ -146,11 +152,14 @@
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) {
restoreCaret(caret);
}
return;
}
node = node.parentNode;
}
}
@@ -182,6 +191,7 @@
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
@@ -196,6 +206,7 @@
if (!rootElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
@@ -212,6 +223,7 @@
const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection();
if (!selection) return;
if (extend && selection.anchorNode) {
@@ -221,6 +233,7 @@
target.startContainer,
target.startOffset
);
return;
}
@@ -230,14 +243,16 @@
function resizeHeight() {
if (!rootElement) return;
rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`;
}
function recordHistory(newGroup: boolean) {
if (!rootElement) return;
history.push(
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
{ caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
Date.now(),
newGroup
);
@@ -262,10 +277,12 @@
// lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange());
if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret);
} else {
const source = serializeContent(rootElement);
let end = caret;
// the caret must end up after the inserted \n; some browsers
@@ -284,7 +301,9 @@
// \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element.
let last = rootElement.lastChild;
while (last && last.nodeName === 'BR') last = last.previousSibling;
if (
end === source.length &&
source.endsWith('\n') &&
@@ -302,7 +321,9 @@
syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement);
syncEmptyState(serialized);
if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits
@@ -316,6 +337,7 @@
// completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized);
if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens);
@@ -324,6 +346,7 @@
// block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement);
if (reserialized !== serialized) {
lastEmittedValue = reserialized;
value = reserialized;
@@ -361,6 +384,7 @@
if (!rootElement) return;
const range = safeRange();
if (!range) return;
if (!range.collapsed) {
@@ -374,16 +398,22 @@
// a break at the very end of a code block exits the block (the
// new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null;
if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode;
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const tail = document.createRange();
tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length);
if (tail.toString().length === 0) exitBlock = node;
break;
}
node = node.parentNode;
}
}
@@ -392,6 +422,7 @@
exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
if (offset === 0) {
text.before(nl);
} else if (offset === text.length) {
@@ -405,6 +436,7 @@
const selection = window.getSelection();
const after = document.createRange();
after.setStartAfter(nl);
after.collapse(true);
selection?.removeAllRanges();
@@ -428,9 +460,11 @@
if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild;
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
const range = safeRange();
if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first
@@ -439,16 +473,19 @@
if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range);
if (key === 'ArrowLeft') {
if (caret !== 0) return false;
} else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
return true;
}
@@ -464,14 +501,17 @@
if (!rootElement) return;
const first = rootElement.firstChild;
if (first?.nodeName !== 'BR') return;
const second = first.nextSibling;
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
const range = safeRange();
const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0;
if (!onHatch) {
first.remove();
}
@@ -491,6 +531,7 @@
*/
function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
@@ -499,11 +540,13 @@
if (isUndo || isRedo) {
event.preventDefault();
const current = {
value: lastEmittedValue,
caret: rangeToTextOffset(rootElement, safeRange())
caret: rangeToTextOffset(rootElement, safeRange()),
value: lastEmittedValue
};
const entry = isUndo ? history.undo(current) : history.redo(current);
if (entry) applyHistoryEntry(entry);
return;
}
}
@@ -524,6 +567,7 @@
// stuck on the old line (see insertLineBreak).
event.preventDefault();
insertLineBreak();
return;
}
@@ -543,6 +587,7 @@
// re-tokenize/re-highlight follows.
event.preventDefault();
document.execCommand('insertLineBreak');
return;
}
@@ -555,6 +600,7 @@
) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault();
return;
}
}
@@ -574,6 +620,7 @@
if (target !== null) {
event.preventDefault();
restoreCaret(target, event.shiftKey);
return;
}
}
@@ -586,6 +633,7 @@
// change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return;
renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value;
value = entry.value;
@@ -602,6 +650,7 @@
*/
function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain');
if (pasted && pasted.length > 0) {
event.preventDefault();
@@ -609,6 +658,7 @@
// element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace.
const range = safeRange();
if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range));
}
@@ -621,6 +671,7 @@
// consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) {
onPaste?.(event);
if (!event.defaultPrevented) {
handlePasteEvent(event);
}
@@ -634,20 +685,23 @@
if (!rootElement) return null;
const range = safeRange();
if (!range || range.collapsed) return null;
const startRange = range.cloneRange();
startRange.collapse(true);
const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range);
return { text: source.slice(start, end), range };
return { range, text: source.slice(start, end) };
}
function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
@@ -656,6 +710,7 @@
function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
@@ -675,6 +730,7 @@
resizeHeight();
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
rootElement?.focus({ preventScroll: true });
}
@@ -689,6 +745,7 @@
// browser already owns the right shape.
$effect(() => {
const incoming = value ?? '';
if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
@@ -702,6 +759,7 @@
export function getCaretOffset(): number {
if (!rootElement) return 0;
return rangeToTextOffset(rootElement, safeRange());
}
@@ -710,11 +768,13 @@
if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true });
}
restoreCaret(offset);
}
export function focus() {
if (isMobile.current) return;
rootElement?.focus({ preventScroll: true });
}
@@ -1,9 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import {
gaugeTriggerClick,
gaugeTriggerEnter,
@@ -11,22 +9,28 @@
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from '$lib/stores/context-gauge-popup.svelte';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { untrack } from 'svelte';
const gauge = useContextGauge();
$effect(() => {
const conv = activeConversation();
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
});
$effect(() => {
const conv = activeConversation();
const messages = activeMessages() as DatabaseMessage[];
if (!conv) return;
if (isLoading() || isChatStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
return;
}
@@ -5,7 +5,7 @@
subtitle?: string;
}
let { label, value, subtitle }: Props = $props();
let { label, subtitle, value }: Props = $props();
</script>
<div class="grid gap-1.5">
@@ -1,8 +1,8 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
interface Props {
currentRead: number;
@@ -18,15 +18,15 @@
}
let {
currentRead,
currentFresh,
currentCache,
currentOutput,
kvTotal,
cumulativeRead,
cumulativeOutput,
cumulativeCacheTotal,
averageTokensPerSecond,
cumulativeCacheTotal,
cumulativeOutput,
cumulativeRead,
currentCache,
currentFresh,
currentOutput,
currentRead,
kvTotal,
transientDetails
}: Props = $props();
@@ -8,7 +8,7 @@
size?: 'sm' | 'md';
}
let { percent, level, size = 'sm' }: Props = $props();
let { level, percent, size = 'sm' }: Props = $props();
const RADIUS = 11;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -8,7 +8,7 @@
onLoad: () => void;
}
let { modelId, isLoading, onLoad }: Props = $props();
let { isLoading, modelId, onLoad }: Props = $props();
</script>
{#if modelId !== null && !isLoading}
@@ -1,15 +1,15 @@
<script lang="ts">
import { formatParameters } from '$lib/utils/formatters';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import {
gaugePopup,
gaugeCardEnter,
gaugeCardLeave,
gaugePopup,
gaugePopupClose
} from '$lib/stores/context-gauge-popup.svelte';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -30,13 +30,18 @@
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (cardEl?.contains(target)) return;
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
gaugePopupClose();
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
});
@@ -5,8 +5,11 @@ const CRITICAL_THRESHOLD = 95;
export function colorLevelFromPercent(percent: number | null): ColorLevel {
if (percent === null) return 'neutral';
if (percent >= CRITICAL_THRESHOLD) return 'critical';
if (percent >= WARNING_THRESHOLD) return 'warning';
return 'ok';
}
@@ -1,13 +1,13 @@
<script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpHasResourceAttachments,
mcpResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
interface Props {
class?: string;
@@ -1,14 +1,14 @@
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { ChatFormCommandAction } from '$lib/enums';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { ChatFormCommand } from '$lib/types';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
@@ -24,12 +24,12 @@
onSelect: (command: ChatFormCommand) => void;
}
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
[ChatFormCommandAction.PROMPT]: Sparkles
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
@@ -51,20 +51,24 @@
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
if (n === 0) return -1;
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx;
}
return -1;
}
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => filteredCommands.length,
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
isOpen: () => isOpen,
onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index])
onSelect: (index) => handleSelect(filteredCommands[index]),
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir))
});
$effect(() => {
@@ -76,8 +80,10 @@
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
return;
}
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
@@ -85,6 +91,7 @@
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
onSelect(command);
onClose();
}
@@ -1,22 +1,22 @@
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { FileMentionEntry } from '$lib/types';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { FileMentionEntry } from '$lib/types';
import { abbreviateHome, type GlobEntryResult, runGlobSearchWithChildren } from '$lib/utils';
/**
* Floating file/folder mention picker. The chat input is the search
@@ -38,18 +38,18 @@
let {
class: className = '',
isOpen,
query,
customAnchor = null,
scopePath = null,
isOpen,
onClose,
onOpened,
onSelect,
onOpened
query,
scopePath = null
}: Props = $props();
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => displayedItems.length,
isOpen: () => isOpen,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
@@ -69,6 +69,7 @@
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
});
@@ -78,8 +79,8 @@
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
@@ -91,23 +92,29 @@
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
);
if (!isCurrent()) return;
if (res.error) {
searchResults = [];
searchError = res.error;
return;
}
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
path: e.path,
name: e.name,
path: e.path,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
@@ -121,9 +128,11 @@
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
@@ -131,6 +140,7 @@
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
@@ -146,12 +156,15 @@
$effect(() => {
const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
return;
}
search.setLoading(true);
search.run(q);
});
@@ -167,9 +180,11 @@
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
return true;
}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
@@ -12,7 +12,7 @@
subtitle?: Snippet;
}
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
let { description, server, serverLabel, subtitle, title, titleExtra }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
@@ -1,9 +1,9 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import type { Snippet } from 'svelte';
interface Props {
items: T[];
@@ -28,22 +28,22 @@
}
let {
items,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage,
autofocus = false,
inputRef = $bindable(null),
onSearchClose,
itemKey,
item,
skeleton,
skeletonCount = 6,
emptyMessage,
footer,
scrollTrigger
inputRef = $bindable(null),
isLoading,
item,
itemKey,
items,
onSearchClose,
scrollTrigger,
searchPlaceholder = 'Search...',
searchQuery = $bindable(),
selectedIndex,
showSearchInput,
skeleton,
skeletonCount = 6
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
@@ -55,11 +55,11 @@
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
getTrigger: () => scrollTrigger,
dataIndex: 'picker',
getContainer: () => listContainer,
getIndex: () => selectedIndex,
getCount: () => items.length,
dataIndex: 'picker'
getIndex: () => selectedIndex,
getTrigger: () => scrollTrigger
});
</script>
@@ -12,13 +12,13 @@
}
let {
children,
class: className = '',
isSelected = false,
disabled = false,
onclick,
onmouseenter,
dataIndex,
children
disabled = false,
isSelected = false,
onclick,
onmouseenter
}: Props = $props();
</script>
@@ -4,7 +4,7 @@
showBadge?: boolean;
}
let { titleWidth = 'w-48', showBadge = false }: Props = $props();
let { showBadge = false, titleWidth = 'w-48' }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
@@ -12,12 +12,12 @@
}
let {
children,
class: className = '',
isOpen = $bindable(false),
srLabel = 'Open picker',
onClose,
onKeydown,
children
srLabel = 'Open picker'
}: Props = $props();
</script>
@@ -1,19 +1,19 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce, uuid } from '$lib/utils';
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import {
ChatFormPickerPopover,
ChatFormPickerItemHeader,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton,
ChatFormPickerPopover,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
class?: string;
@@ -32,11 +32,11 @@
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
onPromptLoadError,
onPromptLoadStart,
searchQuery = ''
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
@@ -89,7 +89,6 @@
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
@@ -118,6 +117,7 @@
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
@@ -131,7 +131,6 @@
promptError = null;
const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
@@ -142,10 +141,12 @@
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
@@ -167,9 +168,9 @@
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
promptName: selectedPrompt.name,
serverName: selectedPrompt.serverName,
value
});
}
@@ -187,9 +188,9 @@
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
value,
result,
suggestionsCount: result?.values.length ?? 0
suggestionsCount: result?.values.length ?? 0,
value
});
}
@@ -234,6 +235,7 @@
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
return;
}
@@ -274,6 +276,7 @@
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
selectedPrompt = null;
promptArgs = {};
promptError = null;
@@ -284,6 +287,7 @@
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
@@ -296,6 +300,7 @@
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
@@ -306,6 +311,7 @@
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
@@ -316,6 +322,7 @@
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
@@ -329,14 +336,14 @@
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
@@ -1,7 +1,7 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
import type { MCPPromptInfo } from '$lib/types';
interface Props {
prompt: MCPPromptInfo;
@@ -21,20 +21,20 @@
}
let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
promptError,
onArgInput,
onArgKeydown,
loadingSuggestions,
onArgBlur,
onArgFocus,
onArgInput,
onArgKeydown,
onCancel,
onSelectSuggestion,
onSubmit,
onCancel
prompt,
promptArgs,
promptError,
suggestions
}: Props = $props();
</script>
@@ -1,8 +1,8 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
@@ -22,16 +22,16 @@
let {
argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
isAutocompleteActive = false,
isLoadingSuggestions = false,
onBlur,
onFocus,
onSelectSuggestion
onInput,
onKeydown,
onSelectSuggestion,
suggestions = [],
value = ''
}: Props = $props();
</script>
@@ -66,7 +66,7 @@
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
transition:fly={{ duration: 100, y: -5 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
@@ -35,24 +35,24 @@
}
let {
isCommandPickerOpen,
commandQuery,
commands = [],
isCommandPickerOpen,
isMentionPickerOpen,
isPromptPickerOpen,
mentionAnchor,
mentionQuery,
onCommandPickerClose,
onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isMentionPickerOpen,
mentionQuery,
mentionAnchor,
scopePath,
onPromptPickerClose,
onMentionPickerClose,
onMentionOpened,
onMentionPickerClose,
onMentionSelect,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
onPromptLoadError,
onPromptLoadStart,
onPromptPickerClose,
promptSearchQuery,
scopePath
}: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
@@ -52,6 +52,7 @@
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
}
@@ -1,23 +1,9 @@
<script lang="ts">
import { FolderOpen } from '@lucide/svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
runGlobSearchWithChildren,
type GlobEntry
} from '$lib/utils';
import * as Popover from '$lib/components/ui/popover';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
import { FolderOpen } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import {
DEFAULT_MOBILE_BREAKPOINT,
HOME_TILDE,
@@ -28,6 +14,20 @@
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
} from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
type GlobEntry,
joinPath,
lastPathSegment,
runGlobSearchWithChildren
} from '$lib/utils';
// Microtask delay so the popover's focus scope tears down first.
const FOCUS_DELAY_MS = 0;
@@ -52,14 +52,14 @@
let {
class: className = '',
disabled = false,
directory = null,
isOpen,
query = $bindable(''),
customAnchor = null,
directory = null,
disabled = false,
isOpen,
onChange,
onClose,
onOpen
onOpen,
query = $bindable('')
}: Props = $props();
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
@@ -88,8 +88,8 @@
let listContainer = $state<HTMLDivElement | null>(null);
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => queryResults.length,
isOpen: () => isOpen,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
@@ -99,19 +99,24 @@
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
$effect(() => {
if (!isOpen) return;
const q = query.trim();
nav.reset(-1);
if (q && fileSearchEnabled) {
search.run(q);
} else {
@@ -124,11 +129,11 @@
});
useScrollActiveRow({
getTrigger: () => nav.scrollTrigger,
dataIndex: 'result',
getContainer: () => listContainer,
getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length,
dataIndex: 'result'
getIndex: () => nav.hoveredIndex,
getTrigger: () => nav.scrollTrigger
});
let searchScope = $state(HOME_TILDE);
@@ -136,16 +141,18 @@
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
if (!trimmed) {
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
return;
}
@@ -160,25 +167,31 @@
signal,
{ type: GlobSearchType.DIR }
);
if (!isCurrent()) return;
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
@@ -197,7 +210,9 @@
function setDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
onChange?.(trimmed);
}
@@ -207,17 +222,18 @@
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR,
include: buildCaseInsensitiveGlob(name),
limit: NATIVE_LIMIT,
max_depth: NATIVE_MAX_DEPTH,
limit: NATIVE_LIMIT
path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR
});
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const match = entries.find(
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
);
return match ? joinPath(base, match.path) : null;
} catch {
return null;
@@ -226,9 +242,11 @@
async function browseNative() {
if (disabled || !window.showDirectoryPicker) return;
try {
const handle = await window.showDirectoryPicker();
const path = await resolveNativeName(handle.name);
if (path) {
setDirectory(path);
closePicker();
@@ -240,16 +258,20 @@
} catch (err) {
// user cancelled - silently ignore; other errors are logged
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
}
}
function handleSubmit() {
const value = query.trim();
if (!value) {
closePicker();
return;
}
setDirectory(value);
closePicker();
}
@@ -257,6 +279,7 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
@@ -287,6 +310,7 @@
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (directory) {
clearDirectory(event);
}
@@ -1,9 +1,9 @@
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
import { abbreviateWorkingDir } from '$lib/utils';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions';
import * as Tooltip from '$lib/components/ui/tooltip';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import { abbreviateWorkingDir } from '$lib/utils';
interface Props {
directory?: string | null;
@@ -15,10 +15,10 @@
let {
directory = null,
homeBase = null,
disabled = false,
showTooltip = false,
onClear
homeBase = null,
onClear,
showTooltip = false
}: Props = $props();
const displayLabel = $derived(
@@ -1,8 +1,8 @@
<script lang="ts">
import { Folder } from '@lucide/svelte';
import { fly } from 'svelte/transition';
import { highlightMatch } from '$lib/utils';
import { cn } from '$lib/components/ui/utils';
import { highlightMatch } from '$lib/utils';
import { fly } from 'svelte/transition';
// Fly-in transition for the results list.
const FLY_Y_PX = -4;
@@ -20,21 +20,21 @@
}
let {
results,
container = $bindable(null),
error,
hoveredIndex,
isSearching,
error,
rawQuery,
container = $bindable(null),
onCommit,
onHover
onHover,
rawQuery,
results
}: Props = $props();
</script>
<div
bind:this={container}
class="max-h-48 overflow-y-auto py-2"
transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }}
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
>
{#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
@@ -1,24 +1,24 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
import {
ChatMessageAssistant,
ChatMessageUser,
ChatMessageSystem,
ChatMessageMcpPrompt,
ChatMessageSynthetic,
ChatMessageMcpPrompt
ChatMessageSystem,
ChatMessageUser
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { deriveAgenticSections } from '$lib/utils';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { ROUTES } from '$lib/constants/routes';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { deriveAgenticSections } from '$lib/utils';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props {
class?: string;
@@ -32,12 +32,12 @@
let {
class: className = '',
message,
toolMessages = [],
isLastAssistantMessage = false,
isLastUserMessage = false,
message,
nextAssistantMessage = null,
siblingInfo = null
siblingInfo = null,
toolMessages = []
}: Props = $props();
const chatActions = getChatActionsContext();
@@ -72,10 +72,12 @@
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
break;
case AgenticSectionType.TOOL_CALL:
@@ -115,9 +117,7 @@
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setMessageEditContext({
get isEditing() {
return isEditing;
},
cancel: handleCancelEdit,
get editedContent() {
return editedContent;
},
@@ -127,6 +127,12 @@
get editedUploadedFiles() {
return editedUploadedFiles;
},
get isEditing() {
return isEditing;
},
get messageRole() {
return message.role;
},
get originalContent() {
return message.role === MessageRole.ASSISTANT
? (rawEditContent ?? message.content)
@@ -135,42 +141,40 @@
get originalExtras() {
return message.extra || [];
},
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get messageRole() {
return message.role;
},
get rawEditContent() {
return rawEditContent;
},
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
setContent: (content: string) => {
editedContent = content;
},
setExtras: (extras: DatabaseMessageExtra[]) => {
editedExtras = extras;
},
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
setShouldBranchAfterEdit: (value: boolean) => {
shouldBranchAfterEdit = value;
},
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
cancel: handleCancelEdit,
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
startEdit: handleEdit
});
let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null;
if (message.content.trim()) return null;
if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0];
@@ -238,6 +242,7 @@
function handleEdit() {
isEditing = true;
// Clear temporary placeholder content for system messages
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
editedContent = '';
@@ -281,6 +286,7 @@
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (isMobile.current) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
@@ -292,23 +298,29 @@
// If content is empty, remove without deleting children
if (!newContent) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
goto(ROUTES.START);
} else {
focusMainChatForm();
}
return;
}
await DatabaseService.updateMessage(message.id, { content: newContent });
const index = conversationsStore.findMessageIndex(message.id);
if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent });
}
focusMainChatForm();
} else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras();
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else {
// For assistant messages, preserve exact content including trailing whitespace
@@ -325,6 +337,7 @@
if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras();
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
}
@@ -1,7 +1,7 @@
<script lang="ts">
import {
ChatMessageAgenticContent,
ChatMessageActionIcons,
ChatMessageAgenticContent,
ChatMessageAssistantModel,
ChatMessageAssistantProcessingInfo,
ChatMessageAssistantRawOutput,
@@ -9,14 +9,13 @@
ChatMessageEditForm
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { modelLoadProgressText } from '$lib/utils';
import { MessageRole } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { modelLoadProgressText } from '$lib/utils';
import { hasAgenticContent } from '$lib/utils';
interface Props {
@@ -49,7 +48,6 @@
deletionInfo,
isLastAssistantMessage = false,
message,
toolMessages = [],
onConfirmDelete,
onContinue,
onCopy,
@@ -61,7 +59,8 @@
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
textareaElement = $bindable()
textareaElement = $bindable(),
toolMessages = []
}: Props = $props();
// Get edit context
@@ -124,18 +123,21 @@
if (!userMessageEl) {
lastUserMessageHeight = 0;
return;
}
const updateHeight = () => {
const rect = userMessageEl.getBoundingClientRect();
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
lastUserMessageHeight = Math.round(rect.height + marginTop);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(userMessageEl);
return () => {
@@ -1,8 +1,8 @@
<script lang="ts">
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
import { copyToClipboard } from '$lib/utils';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { copyToClipboard } from '$lib/utils';
interface Props {
displayedModel: string | null;
@@ -11,7 +11,7 @@
onRegenerate: (modelOverride?: string) => void;
}
let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
let pendingModel = $state<string | null>(null);
@@ -38,6 +38,7 @@
}
onRegenerate(modelName);
return true;
}}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
import { fade } from 'svelte/transition';
interface Props {
modelLoadingText: string | null;
@@ -8,7 +8,7 @@
position: 'top' | 'bottom';
}
let { modelLoadingText, processingState, position }: Props = $props();
let { modelLoadingText, position, processingState }: Props = $props();
const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
import { buildAssistantRawOutput, deriveAgenticSections } from '$lib/utils';
interface Props {
message: DatabaseMessage;
@@ -10,6 +10,7 @@
let rawOutputContent = $derived.by(() => {
const sections = deriveAgenticSections(message, toolMessages, [], false);
return buildAssistantRawOutput(sections);
});
</script>
@@ -10,7 +10,7 @@
showMessageStats: boolean;
}
let { message, isLoading, processingState, showMessageStats }: Props = $props();
let { isLoading, message, processingState, showMessageStats }: Props = $props();
</script>
{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Folder, FolderX } from '@lucide/svelte';
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
@@ -5,7 +5,7 @@
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { MessageRole, McpPromptVariant } from '$lib/enums';
import { McpPromptVariant, MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
@@ -30,17 +30,17 @@
let {
class: className = '',
message,
mcpPrompt,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
onCopy,
onEdit,
onDelete,
mcpPrompt,
message,
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling,
onShowDeleteDialogChange
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props();
// Get edit context
@@ -1,11 +1,11 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { McpPromptVariant } from '$lib/enums';
import { TruncatedText } from '$lib/components/app/misc';
import { Card } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { McpPromptVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
interface ContentPart {
text: string;
@@ -22,10 +22,10 @@
let {
class: className = '',
prompt,
variant = McpPromptVariant.MESSAGE,
isLoading = false,
loadError
loadError,
prompt,
variant = McpPromptVariant.MESSAGE
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
@@ -35,13 +35,15 @@
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
return [{ text: prompt.content || '', argKey: null }];
return [{ argKey: null, text: prompt.content || '' }];
}
const parts: ContentPart[] = [];
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
@@ -55,20 +57,21 @@
for (const value of sortedValues) {
const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, value, key: valueToKey.get(value)! };
earliestMatch = { index, key: valueToKey.get(value)!, value };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
parts.push({ argKey: null, text: remaining.slice(0, earliestMatch.index) });
}
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
parts.push({ argKey: earliestMatch.key, text: earliestMatch.value });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
parts.push({ text: remaining, argKey: null });
parts.push({ argKey: null, text: remaining });
break;
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
@@ -31,16 +31,16 @@
let {
class: className = '',
message,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
onCopy,
onEdit,
onDelete,
message,
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
textareaElement = $bindable()
}: Props = $props();
@@ -1,12 +1,4 @@
<script lang="ts">
import { BuiltInTool } from '$lib/enums';
import {
extractSearchQuery,
extractSearchResults,
isWebSearchToolName,
type AgenticSection
} from '$lib/utils';
import type { DatabaseMessageExtra } from '$lib/types';
import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
@@ -18,6 +10,14 @@
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
import { BuiltInTool } from '$lib/enums';
import type { DatabaseMessageExtra } from '$lib/types';
import {
type AgenticSection,
extractSearchQuery,
extractSearchResults,
isWebSearchToolName
} from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -28,7 +28,7 @@
onToggle?: () => void;
}
let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props();
let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
const searchResults = $derived(extractSearchResults(section.toolResult));
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
@@ -3,19 +3,19 @@
// Renders section.toolArgs / section.toolResult directly using the
// shared chrome shell.
import ToolCallBlock from './ToolCallBlock.svelte';
import { Loader2 } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { FileTypeText, ToolResultKind } from '$lib/enums';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { FileTypeText, ToolResultKind } from '$lib/enums';
import type { DatabaseMessageExtra } from '$lib/types';
import {
type AgenticSection,
classifyToolResult,
formatJsonPretty,
parseToolResultWithImages,
type AgenticSection
parseToolResultWithImages
} from '$lib/utils';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import type { DatabaseMessageExtra } from '$lib/types';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
@@ -25,7 +25,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, attachments, onToggle }: Props = $props();
let { attachments, isStreaming, onToggle, open, section }: Props = $props();
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult));
@@ -1,10 +1,10 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseEditFileMeta } from './parsers/edit-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection, computeLineDiff, prefixFor } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -13,7 +13,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const editFileMeta = $derived(parseEditFileMeta(section));
const home = $derived(toolsStore.serverHome);
@@ -6,26 +6,26 @@
// The scroll-to-bottom auto-scroll logic mirrors what was here
// before extraction.
import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import ToolCallBlock from './ToolCallBlock.svelte';
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte';
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import {
abbreviateHome,
type AgenticSection,
type ExecShellExitStatus,
highlightCode,
isExitCodeSummaryLine,
parseExecShellCommandError,
parseExecShellCommandExitStatus,
parseToolResultWithImages,
type AgenticSection,
type ExecShellExitStatus,
type ToolResultLine
} from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import type { DatabaseMessageExtra } from '$lib/types';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
@@ -39,7 +39,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props();
let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
// `isLive` covers all in-flight phases: pre-chunk spinner and
// streaming itself. Frozen output (tool done while agent continues)
@@ -108,6 +108,7 @@
function isAtBottom(): boolean {
if (!scrollEl) return false;
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
@@ -116,6 +117,7 @@
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
@@ -128,18 +130,23 @@
function handleScrollEvent() {
if (!scrollEl) return;
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
void section.toolResult;
if (!scrollEl || !autoScroll) return;
scrollToBottomOnFrame();
});
@@ -149,10 +156,11 @@
if (!scrollEl || !autoScroll) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
observer.observe(scrollEl, {
characterData: true,
childList: true,
subtree: true,
characterData: true
subtree: true
});
return () => observer.disconnect();
@@ -1,9 +1,9 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
const home = $derived(toolsStore.serverHome);
@@ -8,7 +8,7 @@
isStreaming?: boolean;
}
let { section, isStreaming = false }: Props = $props();
let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -24,9 +24,12 @@
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') return { errorMessage: obj.error };
if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
}
} catch {
@@ -1,15 +1,15 @@
<script lang="ts">
import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
isStreaming?: boolean;
}
let { section, isStreaming = false }: Props = $props();
let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -26,12 +26,15 @@
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') return { errorMessage: obj.error };
return {
os: typeof obj.os === 'string' ? obj.os : undefined,
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
os: typeof obj.os === 'string' ? obj.os : undefined
};
}
} catch {
@@ -1,9 +1,9 @@
<script lang="ts">
import { XCircle } from '@lucide/svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const grepMeta = $derived(parseGrepSearchMeta(section));
const home = $derived(toolsStore.serverHome);
@@ -1,9 +1,9 @@
<script lang="ts">
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { type AgenticSection } from '$lib/utils';
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props {
section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const readFileMeta = $derived(parseReadFileMeta(section));
</script>
@@ -1,11 +1,11 @@
<script lang="ts">
import { XCircle, Terminal } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { FileTypeText } from '$lib/enums';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
import { parseRunJavascriptMeta } from './parsers/run-javascript';
import ToolCallBlock from './ToolCallBlock.svelte';
import { Terminal, XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import { type AgenticSection, getBuiltinToolUi } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -14,7 +14,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const runJsMeta = $derived(parseRunJavascriptMeta(section));
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
@@ -1,17 +1,17 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { Globe, Loader2 } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import * as HoverCard from '$lib/components/ui/hover-card';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
extractSearchResults,
type AgenticSection,
extractSearchQuery,
extractSearchResults,
faviconForUrl,
sanitizeExternalUrl,
type SearchResult,
type AgenticSection
type SearchResult
} from '$lib/utils';
interface Props {
@@ -21,7 +21,7 @@
onToggle?: () => void;
}
let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props();
let { isStreaming = false, onToggle, open = $bindable(false), section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -43,6 +43,7 @@
// retrospective.
const title = $derived.by(() => {
const verb = showSpinner ? 'Searching' : 'Searched';
return query ? `${verb} web for "${query}"` : `${verb} web`;
});
@@ -52,13 +53,16 @@
function formatPublishDate(iso: string | undefined): string | null {
if (!iso) return null;
try {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, {
year: 'numeric',
day: 'numeric',
month: 'short',
day: 'numeric'
year: 'numeric'
});
} catch {
return iso;
@@ -1,11 +1,11 @@
<script lang="ts">
import { parseWriteFileMeta } from './parsers/write-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseWriteFileMeta } from './parsers/write-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -14,7 +14,7 @@
onToggle?: () => void;
}
let { section, open, isStreaming, onToggle }: Props = $props();
let { isStreaming, onToggle, open, section }: Props = $props();
const writeFileMeta = $derived(parseWriteFileMeta(section));
const home = $derived(toolsStore.serverHome);
@@ -11,12 +11,12 @@
import { Loader2, Wrench } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { Component, Snippet } from 'svelte';
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
@@ -64,17 +64,17 @@
}
let {
section,
open,
children,
extraLiveStreaming = false,
isStreaming,
meta,
extraLiveStreaming = false,
onToggle,
open,
section,
spinIconWhenActive = false,
wrapper: Wrapper = CollapsibleContentBlock,
title,
titleSnippet,
onToggle,
children
wrapper: Wrapper = CollapsibleContentBlock
}: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
@@ -102,8 +102,11 @@
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (showSpinner) return undefined;
if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined;
}
@@ -122,9 +125,9 @@
{onToggle}
>
{@render children(meta, {
isStreaming,
isCodeStreaming,
isPending,
isStreamingCall,
isCodeStreaming
isStreaming,
isStreamingCall
})}
</Wrapper>
@@ -5,8 +5,8 @@
// stay focused on its own format quirks.
import { BuiltInTool } from '$lib/enums';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
import type { AgenticSection } from '$lib/utils/agentic';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
/**
* Strict (final-state) JSON parser for a tool-args blob. Mirrors the
@@ -17,9 +17,11 @@ import type { AgenticSection } from '$lib/utils/agentic';
function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(blob);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
@@ -43,6 +45,7 @@ export function parseToolArgs(
options: { partial?: boolean } = {}
): Record<string, unknown> | null {
if (section.toolName !== expected || !section.toolArgs) return null;
return options.partial
? parsePartialJsonArgs(section.toolArgs)
: parseFinalToolArgs(section.toolArgs);
@@ -3,10 +3,10 @@
// rendering), plus the result blob for `result` / `edits_applied` /
// `error` fields.
import { BuiltInTool } from '$lib/enums';
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { tryParseToolResultObject, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, tryParseToolResultObject } from '$lib/utils';
export type EditFileEdit = {
oldText: string;
@@ -24,48 +24,57 @@ export type EditFileMeta = {
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
// Filter the streamed edits array strictly: each entry must be an
// object with a non-empty `old_text`. Edits without an old_text
// would diff against empty and render as a full re-write.
const rawEdits = Array.isArray(args.edits) ? args.edits : [];
const edits: EditFileEdit[] = [];
for (const e of rawEdits) {
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
const obj = e as Record<string, unknown>;
const oldText = typeof obj.old_text === 'string' ? obj.old_text : '';
if (!oldText) continue;
const newText = typeof obj.new_text === 'string' ? obj.new_text : '';
edits.push({ oldText, newText });
edits.push({ newText, oldText });
}
const resultObj = tryParseToolResultObject(section.toolResult);
let resultMessage: string | undefined;
let editsApplied: number | undefined;
let errorMessage: string | undefined;
if (typeof resultObj?.error === 'string') {
errorMessage = resultObj.error;
} else if (resultObj) {
if (typeof resultObj.result === 'string') {
resultMessage = resultObj.result;
}
if (Number.isFinite(Number(resultObj.edits_applied))) {
editsApplied = Number(resultObj.edits_applied);
}
}
return {
edits,
editsApplied,
errorMessage,
fileName,
filePath: rawPath,
edits,
resultMessage,
editsApplied,
errorMessage
resultMessage
};
}
@@ -5,9 +5,9 @@
// file only deals with what's strictly about *calling* the tool, since
// the error / exit status elide from call-section to result-section.
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
export type ExecShellCommandMeta = {
command: string;
@@ -15,9 +15,12 @@ export type ExecShellCommandMeta = {
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
if (!args) return null;
const commandRaw = args.command ?? args.cmd ?? args.shell_command;
if (typeof commandRaw !== 'string' || !commandRaw) return null;
return { command: commandRaw };
}
@@ -4,9 +4,9 @@
// parser keeps the original raw-text fallback for MCP servers that
// emit unparseable output.
import { BuiltInTool } from '$lib/enums';
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type FileGlobSearchMeta = {
path: string;
@@ -19,11 +19,13 @@ export type FileGlobSearchMeta = {
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
if (!args) return null;
const path = typeof args.path === 'string' ? args.path : '';
const include = typeof args.include === 'string' && args.include ? args.include : '**';
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
if (!path) return null;
let matches: string[] = [];
@@ -31,17 +33,21 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
let errorMessage: string | undefined;
const toolResultString = section.toolResult;
if (toolResultString) {
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') {
errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total;
});
matches = split.lines;
}
}
@@ -50,9 +56,10 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total;
});
matches = split.lines;
}
}
return { path, include, exclude, matches, totalMatches, errorMessage };
return { errorMessage, exclude, include, matches, path, totalMatches };
}
@@ -5,9 +5,9 @@
// fallback so MCP servers that return unparseable output still get
// surfaced.
import { BuiltInTool } from '$lib/enums';
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type GrepSearchMatch = {
file: string;
@@ -28,10 +28,12 @@ export type GrepSearchMeta = {
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
if (!args) return null;
const path = typeof args.path === 'string' ? args.path : '';
const pattern = typeof args.pattern === 'string' ? args.pattern : '';
if (!path || !pattern) return null;
const include = typeof args.include === 'string' && args.include ? args.include : '**';
@@ -43,17 +45,21 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
let errorMessage: string | undefined;
const toolResultString = section.toolResult;
if (toolResultString) {
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') {
errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total;
});
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
}
}
@@ -64,19 +70,20 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total;
});
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
}
}
return {
errorMessage,
exclude,
include,
matches,
path,
pattern,
include,
exclude,
showLineNumbers,
matches,
totalMatches,
errorMessage
totalMatches
};
}
@@ -85,24 +92,29 @@ function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch
// <file>:<content> when return_line_numbers=false
// <file>:<lineno>:<content> when return_line_numbers=true
const firstColon = line.indexOf(':');
if (firstColon === -1) {
return { file: line, content: '' };
return { content: '', file: line };
}
const file = line.slice(0, firstColon);
const tail = line.slice(firstColon + 1);
if (!showLineNumbers) {
return { file, content: tail };
return { content: tail, file };
}
const secondColon = tail.indexOf(':');
if (secondColon === -1) {
return { file, content: tail };
return { content: tail, file };
}
const lineNum = parseInt(tail.slice(0, secondColon), 10);
return {
content: tail.slice(secondColon + 1),
file,
line: Number.isFinite(lineNum) ? lineNum : undefined,
content: tail.slice(secondColon + 1)
line: Number.isFinite(lineNum) ? lineNum : undefined
};
}
@@ -3,14 +3,14 @@
// `start_line`+`line_count`). Args are parsed partially so a header
// can render incrementally as the file path streams in.
import { BuiltInTool } from '$lib/enums';
import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
import { getFileTypeByExtension, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, getFileTypeByExtension } from '$lib/utils';
export type ReadFileMeta = {
fileName: string;
@@ -20,13 +20,14 @@ export type ReadFileMeta = {
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
// Models emit range arguments under several aliases. Accept all to
// stay forgiving across prompt variations.
const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line;
@@ -34,19 +35,22 @@ export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null
const countRaw = args.line_count ?? args.count ?? args.num_lines;
let lineRange: { start: number; end: number } | null = null;
const sNum = Number(startRaw);
const eNum = Number(endRaw);
if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) {
lineRange = { start: sNum, end: eNum };
lineRange = { end: eNum, start: sNum };
} else if (startRaw != null && countRaw != null) {
const cNum = Number(countRaw);
if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
lineRange = { start: sNum, end: sNum + cNum - 1 };
lineRange = { end: sNum + cNum - 1, start: sNum };
}
}
const fileType = getFileTypeByExtension(fileName);
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
return { fileName, lineRange, language };
return { fileName, language, lineRange };
}
@@ -5,9 +5,9 @@
// failure renders as a flat line beginning with `Error:`. Both shapes
// are handled.
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
export type RunJavascriptMeta = {
code: string;
@@ -17,30 +17,37 @@ export type RunJavascriptMeta = {
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
if (!args) return null;
const code = typeof args.code === 'string' ? args.code : '';
if (!code) return null;
const timeoutRaw = Number(args.timeout_ms);
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
let errorMessage: string | undefined;
const toolResultString = section.toolResult;
if (toolResultString) {
// Branches matter here: a JSON object can carry `error`, but a
// JSON array always represents successful output (sandbox returns
// the array of values). Only when the result isn't a JSON object
// do we scan raw lines for the `Error:` prefix.
let parsedObject: Record<string, unknown> | null = null;
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedObject = parsed as Record<string, unknown>;
}
} catch {
parsedObject = null;
}
if (typeof parsedObject?.error === 'string') {
errorMessage = parsedObject.error;
} else if (!parsedObject) {
@@ -48,9 +55,10 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('Error:'));
if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim();
}
}
return { code, timeoutMs, errorMessage };
return { code, errorMessage, timeoutMs };
}
@@ -3,14 +3,14 @@
// finishes) and surfaces `bytes`, `result`, and `error` from the
// result blob.
import { BuiltInTool } from '$lib/enums';
import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
export type WriteFileMeta = {
fileName: string;
@@ -24,18 +24,19 @@ export type WriteFileMeta = {
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
if (!args) return null;
// Tool contracts drifted over time: some models emit `path`,
// others `file_path` / `filePath`. Accept all three.
const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
const content = typeof args.content === 'string' ? args.content : '';
const language =
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
@@ -43,12 +44,12 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
return {
bytesWritten,
content,
errorMessage,
fileName,
filePath: rawPath,
language,
content,
bytesWritten,
resultMessage,
errorMessage
resultMessage
};
}
@@ -6,9 +6,9 @@
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts';
import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import { MessageRole, ChatMessageStatisticsMode } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
interface Props {
@@ -35,19 +35,19 @@
let {
class: className = '',
message,
siblingInfo = null,
deletionInfo,
isLastUserMessage = false,
message,
nextAssistantMessage = null,
showDeleteDialog,
onEdit,
onDelete,
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onForkConversation,
onShowDeleteDialogChange,
onNavigateToSibling,
onCopy
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props();
// Get contexts
@@ -60,13 +60,14 @@
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => {
const timings = nextAssistantMessage?.timings;
if (!timings?.prompt_n || !timings?.prompt_ms) return null;
const agentic = timings.agentic;
return {
promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n,
promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms
promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms,
promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n
};
});
@@ -1,6 +1,6 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
@@ -14,12 +14,12 @@
}
let {
content,
attachments = [],
renderMarkdown = false,
textColorClass = 'text-foreground',
cardBgClass = 'dark:bg-primary/15',
maxHeightStyle = ''
content,
maxHeightStyle = '',
renderMarkdown = false,
textColorClass = 'text-foreground'
}: Props = $props();
let isMultiline = $state(false);
@@ -31,6 +31,7 @@
if (content.includes('\n')) {
isMultiline = true;
return;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
interface Props {
@@ -16,9 +16,9 @@
class: className = '',
content,
extras = [],
onSendImmediately,
onDelete,
onEdit,
onDelete
onSendImmediately
}: Props = $props();
const editCtx = useMessageEditContext({
@@ -1,6 +1,6 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet, Component } from 'svelte';
import type { Component, Snippet } from 'svelte';
interface Props {
icon: Component<{ class?: string }>;
@@ -8,7 +8,7 @@
actions: Snippet;
}
let { icon: IconComponent, message, actions }: Props = $props();
let { actions, icon: IconComponent, message }: Props = $props();
</script>
<div class="my-2 rounded-lg border border-border bg-card p-3">
@@ -1,7 +1,7 @@
<script lang="ts">
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
import { RotateCw } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props {
onDecision: (shouldContinue: boolean) => void;
@@ -3,10 +3,10 @@
import { ChatMessageActionCard } from '$lib/components/app';
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
import { cn } from '$lib/components/ui/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ToolSource, ToolPermissionDecision } from '$lib/enums';
import { cn } from '$lib/components/ui/utils';
import { TOOL_SERVER_LABELS } from '$lib/constants';
import { ToolPermissionDecision, ToolSource } from '$lib/enums';
import { toolsStore } from '$lib/stores/tools.svelte';
interface Props {
@@ -15,7 +15,7 @@
onDecision: (decision: ToolPermissionDecision) => void;
}
let { toolName, serverLabel, onDecision }: Props = $props();
let { onDecision, serverLabel, toolName }: Props = $props();
</script>
<ChatMessageActionCard icon={ShieldQuestion}>
@@ -40,7 +40,7 @@
<DropdownMenu.Trigger
class={cn(
buttonVariants({ variant: 'secondary', size: 'sm' }),
buttonVariants({ size: 'sm', variant: 'secondary' }),
'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
)}
aria-label="More allow options"
@@ -1,14 +1,14 @@
<script lang="ts">
import { Edit, Copy, RefreshCw, Trash2, ArrowRight, GitBranch } from '@lucide/svelte';
import { ArrowRight, Copy, Edit, GitBranch, RefreshCw, Trash2 } from '@lucide/svelte';
import {
ActionIcon,
ChatMessageActionIconsBranchingControls,
DialogConfirmation
} from '$lib/components/app';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { Switch } from '$lib/components/ui/switch';
import { MessageRole } from '$lib/enums';
import { activeConversation } from '$lib/stores/conversations.svelte';
@@ -42,21 +42,21 @@
actionsPosition,
deletionInfo,
justify,
onCopy,
onEdit,
onConfirmDelete,
onContinue,
onCopy,
onDelete,
onEdit,
onForkConversation,
onNavigateToSibling,
onShowDeleteDialogChange,
onRawOutputToggle,
onRegenerate,
onShowDeleteDialogChange,
rawOutputEnabled = false,
role,
siblingInfo = null,
showDeleteDialog,
showRawOutputSwitch = false,
rawOutputEnabled = false,
onRawOutputToggle
siblingInfo = null
}: Props = $props();
let showForkDialog = $state(false);
@@ -77,7 +77,7 @@
}
function handleConfirmFork() {
onForkConversation?.({ name: forkName.trim(), includeAttachments: forkIncludeAttachments });
onForkConversation?.({ includeAttachments: forkIncludeAttachments, name: forkName.trim() });
showForkDialog = false;
}
</script>
@@ -8,7 +8,7 @@
onNavigateToSibling?: (siblingId: string) => void;
}
let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
let { class: className = '', onNavigateToSibling, siblingInfo }: Props = $props();
let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
@@ -1,29 +1,28 @@
<script lang="ts">
import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
import {
ChatMessageStatistics,
MarkdownContent,
ChatMessageActionCardContinueRequest,
ChatMessageActionCardPermissionRequest,
ChatMessageActionCardContinueRequest
ChatMessageStatistics,
MarkdownContent
} from '$lib/components/app';
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
import {
agenticExecutingToolCallId,
agenticLastError,
agenticPendingContinueRequest,
agenticPendingPermissionRequest,
agenticResolveContinue,
agenticResolvePermission
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
import type {
ChatMessageAgenticTimings,
ChatMessageAgenticTurnStats,
DatabaseMessage
} from '$lib/types';
import { deriveAgenticSections, type AgenticSection } from '$lib/utils';
import {
agenticPendingPermissionRequest,
agenticResolvePermission,
agenticPendingContinueRequest,
agenticResolveContinue,
agenticLastError,
agenticExecutingToolCallId
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
import { type AgenticSection, deriveAgenticSections } from '$lib/utils';
interface Props {
message: DatabaseMessage;
@@ -33,10 +32,10 @@
}
let {
message,
toolMessages = [],
isLastAssistantMessage = false,
isStreaming = false,
isLastAssistantMessage = false
message,
toolMessages = []
}: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
@@ -60,6 +59,7 @@
$effect(() => {
if (pendingPermission !== prevPendingRef) {
prevPendingRef = pendingPermission;
if (pendingPermission) {
permissionDismissed = false;
}
@@ -81,6 +81,7 @@
$effect(() => {
if (pendingContinue !== prevContinueRef) {
prevContinueRef = pendingContinue;
if (pendingContinue) {
continueDismissed = false;
}
@@ -105,6 +106,7 @@
const turnGroups: TurnGroup[] = $derived.by(() => {
const groups: TurnGroup[] = [];
let currentTurn: AgenticSection[] = [];
let currentIndices: number[] = [];
let prevWasTool = false;
@@ -117,7 +119,7 @@
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
if (!isTool && prevWasTool && currentTurn.length > 0) {
groups.push({ sections: currentTurn, flatIndices: currentIndices });
groups.push({ flatIndices: currentIndices, sections: currentTurn });
currentTurn = [];
currentIndices = [];
}
@@ -128,7 +130,7 @@
}
if (currentTurn.length > 0) {
groups.push({ sections: currentTurn, flatIndices: currentIndices });
groups.push({ flatIndices: currentIndices, sections: currentTurn });
}
return groups;
@@ -166,11 +168,11 @@
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return {
turns: 1,
llm: stats.llm,
toolCalls: stats.toolCalls,
toolCallsCount: stats.toolCalls.length,
toolsMs: stats.toolsMs,
toolCalls: stats.toolCalls,
llm: stats.llm
turns: 1
};
}
</script>
@@ -1,8 +1,8 @@
<script lang="ts">
import { X, AlertTriangle } from '@lucide/svelte';
import { AlertTriangle, X } from '@lucide/svelte';
import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
@@ -19,6 +19,7 @@
let hasUnsavedChanges = $derived.by(() => {
if (editCtx.editedContent !== editCtx.originalContent) return true;
if (editCtx.editedUploadedFiles.length > 0) return true;
const extrasChanged =
@@ -71,17 +72,20 @@
function handleAttachmentRemove(index: number) {
const newExtras = [...editCtx.editedExtras];
newExtras.splice(index, 1);
editCtx.setExtras(newExtras);
}
function handleUploadedFileRemove(fileId: string) {
const newFiles = editCtx.editedUploadedFiles.filter((f) => f.id !== fileId);
editCtx.setUploadedFiles(newFiles);
}
async function handleFilesAdd(files: File[]) {
const processed = await processFilesToChatUploaded(files);
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
}
@@ -1,8 +1,8 @@
<script lang="ts">
import { Lightbulb } from '@lucide/svelte';
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { AgenticSectionType } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
@@ -17,12 +17,12 @@
}
let {
section,
open,
isStreaming,
hasReasoningError = false,
attachments,
onToggle
hasReasoningError = false,
isStreaming,
onToggle,
open,
section
}: Props = $props();
const currentConfig = config();
@@ -38,9 +38,11 @@
if (isPending && !isStreaming) {
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
}
if (section.wasInterrupted) {
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
}
return isStreaming ? '' : undefined;
});
const shimmerTitle = $derived(isPending && isStreaming);
@@ -55,6 +57,7 @@
function isAtBottom(): boolean {
if (!scrollEl) return false;
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
@@ -63,8 +66,10 @@
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
// User may scroll between scheduling and paint.
if (scrollEl && !userScrolledUp) {
scrollEl.scrollTop = scrollEl.scrollHeight;
@@ -74,18 +79,23 @@
function handleScrollEvent() {
if (!scrollEl) return;
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
void section.content;
if (!scrollEl || !isPending || !isStreaming) return;
scrollToBottomOnFrame();
});
@@ -95,10 +105,11 @@
if (!scrollEl || !isPending || !isStreaming) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
observer.observe(scrollEl, {
characterData: true,
childList: true,
subtree: true,
characterData: true
subtree: true
});
return () => observer.disconnect();
@@ -1,11 +1,11 @@
<script lang="ts">
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
import { BookOpenText, Clock, Gauge, Layers, Sparkles, WholeWord, Wrench } from '@lucide/svelte';
import { ChatMessageStatisticsBadge } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView, ChatMessageStatisticsMode } from '$lib/enums';
import { DEFAULT_PERFORMANCE_TIME, MS_PER_SECOND } from '$lib/constants';
import { ChatMessageStatisticsMode, ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
import type { Component } from 'svelte';
interface Props {
@@ -23,17 +23,17 @@
}
let {
predictedTokens,
predictedMs,
promptTokens,
promptMs,
agenticTimings,
hideSummary = false,
initialView = ChatMessageStatsView.GENERATION,
isLive = false,
isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION,
agenticTimings,
mode = ChatMessageStatisticsMode.SWITCHABLE,
onActiveViewChange,
hideSummary = false,
mode = ChatMessageStatisticsMode.SWITCHABLE
predictedMs,
predictedTokens,
promptMs,
promptTokens
}: Props = $props();
let isSwitchable = $derived(mode === ChatMessageStatisticsMode.SWITCHABLE);
@@ -168,35 +168,35 @@
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive}
{@render viewButton({
view: ChatMessageStatsView.READING,
icon: BookOpenText,
label: 'Reading',
tooltipText: 'Processing'
tooltipText: 'Processing',
view: ChatMessageStatsView.READING
})}
{/if}
{@render viewButton({
view: ChatMessageStatsView.GENERATION,
disabled: isGenerationDisabled,
icon: Sparkles,
label: 'Generation',
tooltipText: isGenerationDisabled ? 'Waiting for tokens...' : 'Generation',
disabled: isGenerationDisabled
view: ChatMessageStatsView.GENERATION
})}
{#if hasAgenticStats}
{@render viewButton({
view: ChatMessageStatsView.TOOLS,
icon: Wrench,
label: 'Tools',
tooltipText: 'Tool calls'
tooltipText: 'Tool calls',
view: ChatMessageStatsView.TOOLS
})}
{#if !hideSummary}
{@render viewButton({
view: ChatMessageStatsView.SUMMARY,
icon: Layers,
label: 'Summary',
tooltipText: 'Agentic summary'
tooltipText: 'Agentic summary',
view: ChatMessageStatsView.SUMMARY
})}
{/if}
{/if}
@@ -11,7 +11,7 @@
tooltipLabel?: string;
}
let { class: className = '', icon: IconComponent, value, tooltipLabel }: Props = $props();
let { class: className = '', icon: IconComponent, tooltipLabel, value }: Props = $props();
function handleClick() {
void copyToClipboard(String(value));
@@ -2,21 +2,21 @@
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import {
agenticClearSteeringMessage,
agenticInjectSteeringMessage,
agenticPendingSteeringMessageContent,
agenticPendingSteeringMessageExtras
} from '$lib/stores/agentic.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import {
chatPendingMessageContent,
chatPendingMessageExtras,
chatClearPendingMessage,
chatInjectPendingMessage
chatInjectPendingMessage,
chatPendingMessageContent,
chatPendingMessageExtras
} from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { activeConversation, conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import {
agenticPendingSteeringMessageContent,
agenticPendingSteeringMessageExtras,
agenticClearSteeringMessage,
agenticInjectSteeringMessage
} from '$lib/stores/agentic.svelte';
import {
buildSiblingInfoMap,
copyToClipboard,
@@ -30,13 +30,19 @@
onMessagesReady?: (messageCount: number) => void;
}
let { messages = [], onUserAction, onMessagesReady }: Props = $props();
let { messages = [], onMessagesReady, onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
setChatActionsContext({
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
},
copy: async (message: DatabaseMessage) => {
const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(
@@ -44,6 +50,7 @@
message.extra,
asPlainText
);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
},
@@ -52,8 +59,14 @@
refreshAllMessages();
},
navigateToSibling: async (siblingId: string) => {
await conversationsStore.navigateToSibling(siblingId);
editUserMessagePreserveResponses: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
},
editWithBranching: async (
@@ -76,33 +89,21 @@
refreshAllMessages();
},
editUserMessagePreserveResponses: async (
forkConversation: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
options: { name: string; includeAttachments: boolean }
) => {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
await conversationsStore.forkConversation(message.id, options);
},
navigateToSibling: async (siblingId: string) => {
await conversationsStore.navigateToSibling(siblingId);
},
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
},
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
},
forkConversation: async (
message: DatabaseMessage,
options: { name: string; includeAttachments: boolean }
) => {
await conversationsStore.forkConversation(message.id, options);
}
});
@@ -141,7 +142,6 @@
const filteredMessages = currentConfig.showSystemMessage
? messages
: messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
// Build display entries, grouping agentic sessions into single entries.
// An agentic session = assistant(with tool_calls) → tool → assistant → tool → ... → assistant(final)
const result: Array<{
@@ -160,6 +160,7 @@
if (msg.role === MessageRole.TOOL) continue;
const toolMessages: DatabaseMessage[] = [];
if (msg.role === MessageRole.ASSISTANT && hasAgenticContent(msg)) {
let j = i + 1;
@@ -190,27 +191,29 @@
}
const siblingInfo = siblingInfoByMessageId.get(msg.id) ?? {
currentIndex: 0,
message: msg,
siblingIds: [msg.id],
currentIndex: 0,
totalSiblings: 1
};
result.push({
message: msg,
toolMessages,
isLastAssistantMessage: false,
isLastUserMessage: false,
message: msg,
nextAssistantMessage: null,
siblingInfo
siblingInfo,
toolMessages
});
}
let lastAssistantIdx = -1;
for (let i = result.length - 1; i >= 0; i--) {
if (result[i].message.role === MessageRole.ASSISTANT) {
result[i].isLastAssistantMessage = true;
lastAssistantIdx = i;
break;
}
}
@@ -225,6 +228,7 @@
for (let j = i + 1; j < result.length; j++) {
if (result[j].message.role === MessageRole.ASSISTANT) {
result[i].nextAssistantMessage = result[j].message;
break;
}
}
@@ -235,7 +239,7 @@
</script>
<div>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
{message}
@@ -1,41 +1,41 @@
<script lang="ts">
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import { page } from '$app/state';
import {
ChatScreenForm,
ChatMessages,
ChatScreenDragOverlay,
ChatScreenForm,
ChatScreenServerError,
ChatScreenStreamResumeStatus,
ServerLoadingSplash,
ChatScreenServerError
ServerLoadingSplash
} from '$lib/components/app';
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { device } from '$lib/stores/device.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import {
chatStore,
errorDialog,
isLoading,
isChatStreaming,
isEditing
isEditing,
isLoading
} from '$lib/stores/chat.svelte';
import {
conversationsStore,
activeConversation,
activeMessages,
activeConversation
conversationsStore
} from '$lib/stores/conversations.svelte';
import { device } from '$lib/stores/device.svelte';
import { serverError, serverLoading } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { serverLoading, serverError } from '$lib/stores/server.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onDestroy, onMount, tick } from 'svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
let { showCenteredEmpty = false } = $props();
@@ -56,8 +56,11 @@
let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!isMobile.current) return '1rem';
if (device.isStandalone) return '1.5rem';
if (device.isIOSSafari) return '0.25rem';
return '0.5rem';
});
@@ -65,12 +68,12 @@
const scroll = useChatScreenScroll(autoScroll);
const activeModel = useChatScreenActiveModel();
const fileUpload = useChatScreenFileUpload({
activeModelId: () => activeModel.activeModelId,
capabilities: () => ({
hasVision: activeModel.hasVisionModality,
hasAudio: activeModel.hasAudioModality,
hasVideo: activeModel.hasVideoModality
}),
activeModelId: () => activeModel.activeModelId
hasVideo: activeModel.hasVideoModality,
hasVision: activeModel.hasVisionModality
})
});
const dragAndDrop = useChatScreenDragAndDrop({
onDrop: fileUpload.handleFileUpload
@@ -87,10 +90,12 @@
if (!isMobile.current) return;
const container = scroll.chatScrollContainer;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.clientHeight - container.scrollTop;
isMobileUserScrolledUp = distanceFromBottom > 300;
}
@@ -113,18 +118,22 @@
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
showEmptyFileDialog = true;
if (files) {
const emptyFileNamesSet = new Set(result.emptyFiles);
fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
(file) => !emptyFileNamesSet.has(file.name)
);
}
return false;
}
handleSendLikeScroll();
await chatStore.sendMessage(message, result?.extras);
return true;
}
@@ -138,28 +147,42 @@
// height settles, bailing out on user scroll or conversation change.
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
const id = activeConversation()?.id ?? null;
if (!id || id === lastScrolledConversationId) return;
lastScrolledConversationId = id;
await tick();
autoScroll.scrollToBottom();
const container = scroll.chatScrollContainer;
if (!container) return;
const started = performance.now();
let stableFrames = 0;
let lastHeight = container.scrollHeight;
const settle = () => {
if (autoScroll.userScrolledUp) return;
if (activeConversation()?.id !== id) return;
autoScroll.scrollToBottom();
const height = container.scrollHeight;
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
lastHeight = height;
if (stableFrames >= LANDING_STABLE_FRAMES) return;
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
requestAnimationFrame(settle);
};
requestAnimationFrame(settle);
}
@@ -170,6 +193,7 @@
setTimeout(() => {
const container = scroll.chatScrollContainer;
if (!container) return;
const lastUserBubble = container.querySelector(
@@ -182,16 +206,17 @@
const baseHeight = container.scrollHeight - innerHeight;
container.scrollTo({
top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight,
behavior: 'smooth'
behavior: 'smooth',
top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
});
} else if (lastUserBubble) {
// On desktop, place the last user message near the top of the viewport
const topPadding = 24;
const bubbleRect = lastUserBubble.getBoundingClientRect();
container.scrollTo({
top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding),
behavior: 'smooth'
behavior: 'smooth',
top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
});
} else {
autoScroll.scrollToBottom();
@@ -215,13 +240,16 @@
if (draft.message || draft.files.length > 0) {
chatStore.savePendingDraft(draft.message, draft.files);
}
await chatStore.addSystemPrompt();
}
$effect(() => {
const shouldDisableAutoScroll =
config().disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
if (!shouldDisableAutoScroll) {
autoScroll.enable();
}
@@ -229,6 +257,7 @@
onMount(() => {
const pendingDraft = chatStore.consumePendingDraft();
if (pendingDraft) {
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
@@ -260,6 +289,7 @@
onscroll={(e) => {
scroll.handleScroll(e);
handleMobileScroll();
if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
mobileScrollDownHint = false;
}
@@ -314,8 +344,8 @@
onclick={() => {
mobileScrollDownHint = false;
scroll.chatScrollContainer?.scrollTo({
top: scroll.chatScrollContainer.scrollHeight,
behavior: 'smooth'
behavior: 'smooth',
top: scroll.chatScrollContainer.scrollHeight
});
}}
/>
@@ -1,7 +1,7 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ArrowDown } from '@lucide/svelte';
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
let { onclick }: { onclick: (e?: MouseEvent) => void } = $props();
</script>
@@ -1,21 +1,21 @@
<script lang="ts">
import { Trash2 } from '@lucide/svelte';
import { ErrorDialogType } from '$lib/enums';
import {
DialogChatError,
DialogConfirmation,
DialogEmptyFileAlert,
DialogFileUploadError
} from '$lib/components/app';
import { ErrorDialogType } from '$lib/enums';
let {
showDeleteDialog,
handleDeleteConfirm,
showEmptyFileDialog,
emptyFileNames,
activeErrorDialog,
emptyFileNames,
fileUpload,
handleDeleteConfirm,
handleErrorDialogOpenChange,
fileUpload
showDeleteDialog,
showEmptyFileDialog
} = $props();
</script>
@@ -2,9 +2,9 @@
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { onMount } from 'svelte';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
interface Props {
class?: string;
@@ -40,16 +40,19 @@
if (!formWrapperEl) return;
const formEl = formWrapperEl.querySelector('form') as HTMLElement | null;
if (!formEl) return;
const updateHeight = () => {
const height = Math.round(formEl.getBoundingClientRect().height);
document.documentElement.style.setProperty('--chat-form-height', `${height}px`);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(formEl);
return () => {
@@ -64,11 +67,11 @@
const { clearDraft } = useDraftMessages({
getChatId: () => chatId,
getMessage: () => message,
getFiles: () => uploadedFiles,
setMessage: (m) => (message = m),
getInitialMessage: () => initialMessage,
getMessage: () => message,
setFiles: (f) => (uploadedFiles = f),
getInitialMessage: () => initialMessage
setMessage: (m) => (message = m)
});
function handleFilesAdd(files: File[]) {
@@ -99,7 +102,7 @@
}
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
onSystemPromptAdd?.({ files: uploadedFiles, message });
}
function handleUploadedFileRemove(fileId: string) {
@@ -110,7 +113,9 @@
// message editor opened just before a navigation)
function focusFormUnlessCaptured() {
const active = document.activeElement;
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return;
chatFormRef?.focus();
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
let hasError = $derived(!!serverError());
@@ -1,7 +1,7 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import { StreamConnectionState } from '$lib/enums';
import { Loader2 } from '@lucide/svelte';
import { StreamConnectionState } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
let state = $derived(chatStore.streamConnectionState);
</script>