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
@@ -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}