* 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 <redacted>
* 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 <redacted>
* 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 <redacted>
agenticPendingPermissionRequest,
agenticResolvePermission,
agenticPendingContinueRequest,
- agenticResolveContinue
+ agenticResolveContinue,
+ agenticLastError
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
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(
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING}
+ {@const reasoningSubtitle = section.wasInterrupted
+ ? hasReasoningError
+ ? 'Error'
+ : 'Cancelled'
+ : isStreaming
+ ? ''
+ : undefined}
+
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title="Reasoning"
+ subtitle={reasoningSubtitle}
+ rawContent={section.content}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
- {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
+ {@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
icon={Brain}
title={reasoningTitle}
subtitle={reasoningSubtitle}
+ rawContent={section.content}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
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';
iconClass?: string;
title: string;
subtitle?: string;
+ preview?: string;
+ rawContent?: string;
isStreaming?: boolean;
onToggle?: () => void;
children: Snippet;
iconClass = 'h-4 w-4',
title,
subtitle,
+ preview,
+ rawContent,
isStreaming = false,
onToggle,
children
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(() => {
class={className}
>
<Card class="gap-0 border-muted bg-muted/30 py-0">
- <Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
- <div class="flex items-center gap-2 text-muted-foreground">
- {#if IconComponent}
- <IconComponent class={iconClass} />
- {/if}
+ <Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
+ <div class="flex min-w-0 items-center gap-2">
+ <div class="flex items-center gap-2 text-muted-foreground">
+ {#if IconComponent}
+ <IconComponent class={iconClass} />
+ {/if}
+
+ <span class="font-mono text-sm font-medium">{title}</span>
- <span class="font-mono text-sm font-medium">{title}</span>
+ {#if subtitle}
+ <span class="text-xs italic">{subtitle}</span>
+ {/if}
+ </div>
- {#if subtitle}
- <span class="text-xs italic">{subtitle}</span>
+ {#if displayedPreview && !showThoughtInProgress}
+ <div class="flex min-w-0 items-baseline justify-between gap-2">
+ <div class="w-3/4 truncate text-xs text-muted-foreground/80">
+ {displayedPreview}
+ </div>
+ {#if displayedOverflow > 0}
+ <span class="shrink-0 text-xs text-muted-foreground/60"
+ >{displayedOverflow}+ chars</span
+ >
+ {/if}
+ </div>
{/if}
</div>
/** 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 */
--- /dev/null
+/**
+ * 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;
+ }
+ };
+}
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
+ wasInterrupted?: boolean;
}
/**
const isPending = isStreaming && !hasContentAfterReasoning;
sections.push({
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
- content: message.reasoningContent
+ content: message.reasoningContent,
+ wasInterrupted: !isStreaming && !hasContentAfterReasoning
});
}
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';
/**
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 };
+}
formatJsonPretty,
formatTime,
formatPerformanceTime,
- formatAttachmentText
+ formatAttachmentText,
+ formatReasoningPreview
} from './formatters';
// IME utilities