From 526977068f919da0386e2c5463645643be142d1d Mon Sep 17 00:00:00 2001 From: MagicExists <106458387+gugugiyu@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:09:43 +0700 Subject: [PATCH] ui: added single line reasoning preview (#23601) * webui: added single line reasoning preview. * patch: reduce width slightly for the previewing section * refactor: move formatter constants to the right file * feat: reimplement reasoning preview with throttled dynamic per-line rendering * chore: fix spacing Co-authored-by: Aleksander Grygier * chore: refactor to requested changes * refactor: grouped by capture pattern instead of block-level + inline * ui: fax interrupt state only trigger for 1st reasoning message * chore: make reasoning preview respects showThoughtInProgress setting * chore; newline at EOF Co-authored-by: Aleksander Grygier * fix: thread rawContent so collapsible content can handle compute preview * patch: showThoughtInProgress accidentally blocks rawContent being passed * chore: fix lint * chore: change smoke test --------- Co-authored-by: Aleksander Grygier --- .../ChatMessageAgenticContent.svelte | 20 ++++++- .../content/CollapsibleContentBlock.svelte | 52 ++++++++++++++++--- tools/ui/src/lib/constants/formatters.ts | 27 ++++++++++ tools/ui/src/lib/hooks/use-throttle.svelte.ts | 32 ++++++++++++ tools/ui/src/lib/utils/agentic.ts | 4 +- tools/ui/src/lib/utils/formatters.ts | 36 ++++++++++++- tools/ui/src/lib/utils/index.ts | 3 +- 7 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 tools/ui/src/lib/hooks/use-throttle.svelte.ts diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index 3a9cc7e93..e21dff993 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -31,7 +31,8 @@ agenticPendingPermissionRequest, agenticResolvePermission, agenticPendingContinueRequest, - agenticResolveContinue + agenticResolveContinue, + agenticLastError } from '$lib/stores/agentic.svelte'; import { config } from '$lib/stores/settings.svelte'; @@ -56,6 +57,10 @@ const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean); const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean); + const hasReasoningError = $derived( + isLastAssistantMessage ? !!agenticLastError(message.convId) : false + ); + let permissionDismissed = $state(false); const pendingPermission = $derived( @@ -293,11 +298,21 @@ {:else if section.type === AgenticSectionType.REASONING} + {@const reasoningSubtitle = section.wasInterrupted + ? hasReasoningError + ? 'Error' + : 'Cancelled' + : isStreaming + ? '' + : undefined} + toggleExpanded(index, section)} >
@@ -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 b7297ab6b..8bab55d19 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 subtitle} + {subtitle} + {/if} +
+ + {#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 d6d1b883f..c417faea4 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 000000000..079551978 --- /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 52ff35793..d19f03434 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 24a2c1c94..de74ee868 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 00aa49c41..637db8812 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