@@ -308,7 +323,7 @@
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
- {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
+ {@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
toggleExpanded(index, section)}
>
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
index b7297ab6b1aa..8bab55d19fb9 100644
--- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
+++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
@@ -4,6 +4,9 @@
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+ import { useThrottle } from '$lib/hooks/use-throttle.svelte';
+ import { formatReasoningPreview } from '$lib/utils';
+ import { config } from '$lib/stores/settings.svelte';
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
@@ -14,6 +17,8 @@
iconClass?: string;
title: string;
subtitle?: string;
+ preview?: string;
+ rawContent?: string;
isStreaming?: boolean;
onToggle?: () => void;
children: Snippet;
@@ -26,6 +31,8 @@
iconClass = 'h-4 w-4',
title,
subtitle,
+ preview,
+ rawContent,
isStreaming = false,
onToggle,
children
@@ -33,6 +40,20 @@
let contentContainer: HTMLDivElement | undefined = $state();
+ const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
+
+ let previewKey = useThrottle(() => rawContent ?? preview ?? '', 500);
+ let displayedPreview = $state('');
+ let displayedOverflow = $state(0);
+
+ $effect(() => {
+ void previewKey.key;
+ const content = rawContent ?? preview ?? '';
+ const result = formatReasoningPreview(content);
+ displayedPreview = result.preview;
+ displayedOverflow = result.overflow;
+ });
+
const autoScroll = createAutoScrollController();
$effect(() => {
@@ -58,16 +79,31 @@
class={className}
>
-
-
- {#if IconComponent}
-
- {/if}
+
+
+
+ {#if IconComponent}
+
+ {/if}
+
+ {title}
- {title}
+ {#if subtitle}
+ {subtitle}
+ {/if}
+
- {#if subtitle}
-
{subtitle}
+ {#if displayedPreview && !showThoughtInProgress}
+
+
+ {displayedPreview}
+
+ {#if displayedOverflow > 0}
+
{displayedOverflow}+ chars
+ {/if}
+
{/if}
diff --git a/tools/ui/src/lib/constants/formatters.ts b/tools/ui/src/lib/constants/formatters.ts
index d6d1b883ffe9..c417faea43d9 100644
--- a/tools/ui/src/lib/constants/formatters.ts
+++ b/tools/ui/src/lib/constants/formatters.ts
@@ -6,3 +6,30 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
/** Default display value when no performance time is available */
export const DEFAULT_PERFORMANCE_TIME = '0s';
+
+/** Max length before reasoning preview is truncated */
+export const MAX_PREVIEW_LENGTH = 120;
+
+export const STRIP_MARKDOWN_CAPTURE_PATTERNS: [RegExp, string][] = [
+ [/^```(.*)/gm, '$1'],
+ [/(.*)```$/gm, '$1'],
+ [/`([^`]*)`/g, '$1'],
+ [/\*\*(.*?)\*\*/g, '$1'],
+ [/__(.*?)__/g, '$1'],
+ [/\*(.*?)\*/g, '$1'],
+ [/_(.*?)_/g, '$1']
+];
+
+/* eslint-disable no-misleading-character-class */
+export const STRIP_MARKDOWN_INLINE_REGEX = new RegExp(
+ [
+ '<[^>]*>',
+ '^>\\s*',
+ '^#{1,6}\\s+',
+ '^[\\s]*[-*+]\\s+',
+ '^[\\s]*\\d+[.)]\\s+',
+ '[\\u{1F600}-\\u{1F64F}\\u{1F300}-\\u{1F5FF}\\u{1F680}-\\u{1F6FF}\\u{1F1E0}-\\u{1F1FF}\\u{2600}-\\u{26FF}\\u{2700}-\\u{27BF}\\u{FE00}-\\u{FE0F}\\u{1F900}-\\u{1F9FF}\\u{1FA00}-\\u{1FA6F}\\u{1FA70}-\\u{1FAFF}\\u{200D}\\u{20E3}\\u{231A}-\\u{231B}\\u{23E9}-\\u{23F3}\\u{23F8}-\\u{23FA}\\u{25AA}-\\u{25AB}\\u{25B6}\\u{25C0}\\u{25FB}-\\u{25FE}\\u{2934}-\\u{2935}\\u{2B05}-\\u{2B07}\\u{2B1B}-\\u{2B1C}\\u{2B50}\\u{2B55}\\u{3030}\\u{303D}\\u{3297}\\u{3299}]'
+ ].join('|'),
+ 'gmu'
+);
+/* eslint-enable no-misleading-character-class */
diff --git a/tools/ui/src/lib/hooks/use-throttle.svelte.ts b/tools/ui/src/lib/hooks/use-throttle.svelte.ts
new file mode 100644
index 000000000000..0795519787b8
--- /dev/null
+++ b/tools/ui/src/lib/hooks/use-throttle.svelte.ts
@@ -0,0 +1,32 @@
+/**
+ * Creates a reactive throttle key that increments when `getValue()` changes
+ * and the throttle window has elapsed since the last increment.
+ *
+ * Useful for throttling animations that should not fire on every rapid update.
+ *
+ * @param getValue - A reactive getter for the value to watch
+ * @param ms - Throttle window in milliseconds
+ * @returns A reactive number that increments when the throttled value changes
+ */
+export function useThrottle(getValue: () => string | undefined, ms: number) {
+ let key = $state(0);
+ let throttleEnd = $state(0);
+ let lastValue: string | undefined = getValue();
+
+ $effect(() => {
+ const value = getValue();
+ if (value === lastValue) return;
+ const now = Date.now();
+ if (now >= throttleEnd) {
+ lastValue = value;
+ key++;
+ throttleEnd = now + ms;
+ }
+ });
+
+ return {
+ get key() {
+ return key;
+ }
+ };
+}
diff --git a/tools/ui/src/lib/utils/agentic.ts b/tools/ui/src/lib/utils/agentic.ts
index 52ff35793063..d19f03434e6b 100644
--- a/tools/ui/src/lib/utils/agentic.ts
+++ b/tools/ui/src/lib/utils/agentic.ts
@@ -18,6 +18,7 @@ export interface AgenticSection {
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
+ wasInterrupted?: boolean;
}
/**
@@ -51,7 +52,8 @@ function deriveSingleTurnSections(
const isPending = isStreaming && !hasContentAfterReasoning;
sections.push({
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
- content: message.reasoningContent
+ content: message.reasoningContent,
+ wasInterrupted: !isStreaming && !hasContentAfterReasoning
});
}
diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts
index 24a2c1c94c18..de74ee8686d1 100644
--- a/tools/ui/src/lib/utils/formatters.ts
+++ b/tools/ui/src/lib/utils/formatters.ts
@@ -3,7 +3,11 @@ import {
SECONDS_PER_MINUTE,
SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD,
- MEDIUM_DURATION_THRESHOLD
+ MEDIUM_DURATION_THRESHOLD,
+ MAX_PREVIEW_LENGTH,
+ STRIP_MARKDOWN_INLINE_REGEX,
+ STRIP_MARKDOWN_CAPTURE_PATTERNS,
+ NEWLINE_SEPARATOR
} from '$lib/constants';
/**
@@ -151,3 +155,33 @@ export function formatAttachmentText(
const header = extra ? `${name} (${extra})` : name;
return `\n\n--- ${label}: ${header} ---\n${content}`;
}
+
+export function formatReasoningPreview(content: string): { preview: string; overflow: number } {
+ if (!content) return { preview: '', overflow: 0 };
+
+ const lines = content.split(NEWLINE_SEPARATOR);
+ let lastLine = '';
+
+ for (let i = lines.length - 1; i >= 0; i--) {
+ let cleaned = lines[i].trim();
+ if (!cleaned) continue;
+
+ cleaned = cleaned.replace(STRIP_MARKDOWN_INLINE_REGEX, '');
+ for (const [pattern, replacement] of STRIP_MARKDOWN_CAPTURE_PATTERNS) {
+ cleaned = cleaned.replace(pattern, replacement);
+ }
+
+ if (cleaned.length > 0) {
+ lastLine = cleaned;
+ break;
+ }
+ }
+
+ const fullLength = lastLine.length;
+ const overflow = Math.max(0, fullLength - MAX_PREVIEW_LENGTH);
+ if (fullLength > MAX_PREVIEW_LENGTH) {
+ lastLine = lastLine.slice(0, MAX_PREVIEW_LENGTH) + '...';
+ }
+
+ return { preview: lastLine, overflow };
+}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 00aa49c41765..637db8812c4d 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -76,7 +76,8 @@ export {
formatJsonPretty,
formatTime,
formatPerformanceTime,
- formatAttachmentText
+ formatAttachmentText,
+ formatReasoningPreview
} from './formatters';
// IME utilities
diff --git a/tools/ui/tests/stories/SidebarNavigation.stories.svelte b/tools/ui/tests/stories/SidebarNavigation.stories.svelte
index f64ee4f9b551..aae42f2a053c 100644
--- a/tools/ui/tests/stories/SidebarNavigation.stories.svelte
+++ b/tools/ui/tests/stories/SidebarNavigation.stories.svelte
@@ -58,10 +58,12 @@
name="Default"
play={async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
-
- waitFor(() => setTimeout(() => {
- conversationsStore.conversations = mockConversations;
- }, 0));
+
+ waitFor(() =>
+ setTimeout(() => {
+ conversationsStore.conversations = mockConversations;
+ }, 0)
+ );
}}
>
@@ -76,11 +78,13 @@
name="SearchActive"
play={async ({ userEvent }) => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
-
- waitFor(() => setTimeout(() => {
- conversationsStore.conversations = mockConversations;
- }, 0));
-
+
+ waitFor(() =>
+ setTimeout(() => {
+ conversationsStore.conversations = mockConversations;
+ }, 0)
+ );
+
const searchTrigger = screen.getByText('Search');
userEvent.click(searchTrigger);
}}
diff --git a/tools/ui/vite.config.ts b/tools/ui/vite.config.ts
index 5b57eae3ad54..13e889dbc101 100644
--- a/tools/ui/vite.config.ts
+++ b/tools/ui/vite.config.ts
@@ -7,11 +7,23 @@ import { defineConfig, searchForWorkspaceRoot } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { llamaCppBuildPlugin } from './scripts/vite-plugin-llama-cpp-build';
+import { playwright } from '@vitest/browser-playwright';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SERVER_ORIGIN = import.meta.env?.VITE_PUBLIC_SERVER_ORIGIN || 'http://localhost:8080';
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const browserBaseConfig: any = {
+ enabled: true,
+ provider: playwright({
+ launchOptions: {
+ args: ['--no-sandbox']
+ }
+ }),
+ instances: [{ browser: 'chromium' }]
+};
+
export default defineConfig({
resolve: {
alias: {
@@ -33,12 +45,7 @@ export default defineConfig({
extends: './vite.config.ts',
test: {
name: 'client',
- environment: 'browser',
- browser: {
- enabled: true,
- provider: 'playwright',
- instances: [{ browser: 'chromium' }]
- },
+ browser: browserBaseConfig,
include: ['tests/client/**/*.svelte.{test,spec}.{js,ts}'],
setupFiles: ['./vitest-setup-client.ts']
}
@@ -57,13 +64,7 @@ export default defineConfig({
extends: './vite.config.ts',
test: {
name: 'ui',
- environment: 'browser',
- browser: {
- enabled: true,
- provider: 'playwright',
- instances: [{ browser: 'chromium', headless: true }]
- },
- include: ['tests/stories/**/*.stories.{js,ts,svelte}'],
+ browser: { ...browserBaseConfig, instances: [{ browser: 'chromium', headless: true }] },
setupFiles: ['./.storybook/vitest.setup.ts']
},
plugins: [