Skip to content

Commit 5269770

Browse files
gugugiyuallozaur
andauthored
ui: added single line reasoning preview (ggml-org#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 <aleksander.grygier@gmail.com> * 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 <aleksander.grygier@gmail.com> * 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 <aleksander.grygier@gmail.com>
1 parent 0dbfa66 commit 5269770

7 files changed

Lines changed: 161 additions & 13 deletions

File tree

tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
agenticPendingPermissionRequest,
3232
agenticResolvePermission,
3333
agenticPendingContinueRequest,
34-
agenticResolveContinue
34+
agenticResolveContinue,
35+
agenticLastError
3536
} from '$lib/stores/agentic.svelte';
3637
import { config } from '$lib/stores/settings.svelte';
3738
@@ -56,6 +57,10 @@
5657
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
5758
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
5859
60+
const hasReasoningError = $derived(
61+
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
62+
);
63+
5964
let permissionDismissed = $state(false);
6065
6166
const pendingPermission = $derived(
@@ -293,11 +298,21 @@
293298
</div>
294299
</CollapsibleContentBlock>
295300
{:else if section.type === AgenticSectionType.REASONING}
301+
{@const reasoningSubtitle = section.wasInterrupted
302+
? hasReasoningError
303+
? 'Error'
304+
: 'Cancelled'
305+
: isStreaming
306+
? ''
307+
: undefined}
308+
296309
<CollapsibleContentBlock
297310
open={isExpanded(index, section)}
298311
class="my-2"
299312
icon={Brain}
300313
title="Reasoning"
314+
subtitle={reasoningSubtitle}
315+
rawContent={section.content}
301316
onToggle={() => toggleExpanded(index, section)}
302317
>
303318
<div class="pt-3">
@@ -308,14 +323,15 @@
308323
</CollapsibleContentBlock>
309324
{:else if section.type === AgenticSectionType.REASONING_PENDING}
310325
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
311-
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
326+
{@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
312327

313328
<CollapsibleContentBlock
314329
open={isExpanded(index, section)}
315330
class="my-2"
316331
icon={Brain}
317332
title={reasoningTitle}
318333
subtitle={reasoningSubtitle}
334+
rawContent={section.content}
319335
{isStreaming}
320336
onToggle={() => toggleExpanded(index, section)}
321337
>

tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import { buttonVariants } from '$lib/components/ui/button/index.js';
55
import { Card } from '$lib/components/ui/card';
66
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
7+
import { useThrottle } from '$lib/hooks/use-throttle.svelte';
8+
import { formatReasoningPreview } from '$lib/utils';
9+
import { config } from '$lib/stores/settings.svelte';
710
import type { Snippet } from 'svelte';
811
import type { Component } from 'svelte';
912
@@ -14,6 +17,8 @@
1417
iconClass?: string;
1518
title: string;
1619
subtitle?: string;
20+
preview?: string;
21+
rawContent?: string;
1722
isStreaming?: boolean;
1823
onToggle?: () => void;
1924
children: Snippet;
@@ -26,13 +31,29 @@
2631
iconClass = 'h-4 w-4',
2732
title,
2833
subtitle,
34+
preview,
35+
rawContent,
2936
isStreaming = false,
3037
onToggle,
3138
children
3239
}: Props = $props();
3340
3441
let contentContainer: HTMLDivElement | undefined = $state();
3542
43+
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
44+
45+
let previewKey = useThrottle(() => rawContent ?? preview ?? '', 500);
46+
let displayedPreview = $state('');
47+
let displayedOverflow = $state(0);
48+
49+
$effect(() => {
50+
void previewKey.key;
51+
const content = rawContent ?? preview ?? '';
52+
const result = formatReasoningPreview(content);
53+
displayedPreview = result.preview;
54+
displayedOverflow = result.overflow;
55+
});
56+
3657
const autoScroll = createAutoScrollController();
3758
3859
$effect(() => {
@@ -58,16 +79,31 @@
5879
class={className}
5980
>
6081
<Card class="gap-0 border-muted bg-muted/30 py-0">
61-
<Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
62-
<div class="flex items-center gap-2 text-muted-foreground">
63-
{#if IconComponent}
64-
<IconComponent class={iconClass} />
65-
{/if}
82+
<Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
83+
<div class="flex min-w-0 items-center gap-2">
84+
<div class="flex items-center gap-2 text-muted-foreground">
85+
{#if IconComponent}
86+
<IconComponent class={iconClass} />
87+
{/if}
88+
89+
<span class="font-mono text-sm font-medium">{title}</span>
6690

67-
<span class="font-mono text-sm font-medium">{title}</span>
91+
{#if subtitle}
92+
<span class="text-xs italic">{subtitle}</span>
93+
{/if}
94+
</div>
6895

69-
{#if subtitle}
70-
<span class="text-xs italic">{subtitle}</span>
96+
{#if displayedPreview && !showThoughtInProgress}
97+
<div class="flex min-w-0 items-baseline justify-between gap-2">
98+
<div class="w-3/4 truncate text-xs text-muted-foreground/80">
99+
{displayedPreview}
100+
</div>
101+
{#if displayedOverflow > 0}
102+
<span class="shrink-0 text-xs text-muted-foreground/60"
103+
>{displayedOverflow}+ chars</span
104+
>
105+
{/if}
106+
</div>
71107
{/if}
72108
</div>
73109

tools/ui/src/lib/constants/formatters.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,30 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
66

77
/** Default display value when no performance time is available */
88
export const DEFAULT_PERFORMANCE_TIME = '0s';
9+
10+
/** Max length before reasoning preview is truncated */
11+
export const MAX_PREVIEW_LENGTH = 120;
12+
13+
export const STRIP_MARKDOWN_CAPTURE_PATTERNS: [RegExp, string][] = [
14+
[/^```(.*)/gm, '$1'],
15+
[/(.*)```$/gm, '$1'],
16+
[/`([^`]*)`/g, '$1'],
17+
[/\*\*(.*?)\*\*/g, '$1'],
18+
[/__(.*?)__/g, '$1'],
19+
[/\*(.*?)\*/g, '$1'],
20+
[/_(.*?)_/g, '$1']
21+
];
22+
23+
/* eslint-disable no-misleading-character-class */
24+
export const STRIP_MARKDOWN_INLINE_REGEX = new RegExp(
25+
[
26+
'<[^>]*>',
27+
'^>\\s*',
28+
'^#{1,6}\\s+',
29+
'^[\\s]*[-*+]\\s+',
30+
'^[\\s]*\\d+[.)]\\s+',
31+
'[\\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}]'
32+
].join('|'),
33+
'gmu'
34+
);
35+
/* eslint-enable no-misleading-character-class */
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Creates a reactive throttle key that increments when `getValue()` changes
3+
* and the throttle window has elapsed since the last increment.
4+
*
5+
* Useful for throttling animations that should not fire on every rapid update.
6+
*
7+
* @param getValue - A reactive getter for the value to watch
8+
* @param ms - Throttle window in milliseconds
9+
* @returns A reactive number that increments when the throttled value changes
10+
*/
11+
export function useThrottle(getValue: () => string | undefined, ms: number) {
12+
let key = $state(0);
13+
let throttleEnd = $state(0);
14+
let lastValue: string | undefined = getValue();
15+
16+
$effect(() => {
17+
const value = getValue();
18+
if (value === lastValue) return;
19+
const now = Date.now();
20+
if (now >= throttleEnd) {
21+
lastValue = value;
22+
key++;
23+
throttleEnd = now + ms;
24+
}
25+
});
26+
27+
return {
28+
get key() {
29+
return key;
30+
}
31+
};
32+
}

tools/ui/src/lib/utils/agentic.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface AgenticSection {
1818
toolArgs?: string;
1919
toolResult?: string;
2020
toolResultExtras?: DatabaseMessageExtra[];
21+
wasInterrupted?: boolean;
2122
}
2223

2324
/**
@@ -51,7 +52,8 @@ function deriveSingleTurnSections(
5152
const isPending = isStreaming && !hasContentAfterReasoning;
5253
sections.push({
5354
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
54-
content: message.reasoningContent
55+
content: message.reasoningContent,
56+
wasInterrupted: !isStreaming && !hasContentAfterReasoning
5557
});
5658
}
5759

tools/ui/src/lib/utils/formatters.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {
33
SECONDS_PER_MINUTE,
44
SECONDS_PER_HOUR,
55
SHORT_DURATION_THRESHOLD,
6-
MEDIUM_DURATION_THRESHOLD
6+
MEDIUM_DURATION_THRESHOLD,
7+
MAX_PREVIEW_LENGTH,
8+
STRIP_MARKDOWN_INLINE_REGEX,
9+
STRIP_MARKDOWN_CAPTURE_PATTERNS,
10+
NEWLINE_SEPARATOR
711
} from '$lib/constants';
812

913
/**
@@ -151,3 +155,33 @@ export function formatAttachmentText(
151155
const header = extra ? `${name} (${extra})` : name;
152156
return `\n\n--- ${label}: ${header} ---\n${content}`;
153157
}
158+
159+
export function formatReasoningPreview(content: string): { preview: string; overflow: number } {
160+
if (!content) return { preview: '', overflow: 0 };
161+
162+
const lines = content.split(NEWLINE_SEPARATOR);
163+
let lastLine = '';
164+
165+
for (let i = lines.length - 1; i >= 0; i--) {
166+
let cleaned = lines[i].trim();
167+
if (!cleaned) continue;
168+
169+
cleaned = cleaned.replace(STRIP_MARKDOWN_INLINE_REGEX, '');
170+
for (const [pattern, replacement] of STRIP_MARKDOWN_CAPTURE_PATTERNS) {
171+
cleaned = cleaned.replace(pattern, replacement);
172+
}
173+
174+
if (cleaned.length > 0) {
175+
lastLine = cleaned;
176+
break;
177+
}
178+
}
179+
180+
const fullLength = lastLine.length;
181+
const overflow = Math.max(0, fullLength - MAX_PREVIEW_LENGTH);
182+
if (fullLength > MAX_PREVIEW_LENGTH) {
183+
lastLine = lastLine.slice(0, MAX_PREVIEW_LENGTH) + '...';
184+
}
185+
186+
return { preview: lastLine, overflow };
187+
}

tools/ui/src/lib/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export {
7676
formatJsonPretty,
7777
formatTime,
7878
formatPerformanceTime,
79-
formatAttachmentText
79+
formatAttachmentText,
80+
formatReasoningPreview
8081
} from './formatters';
8182

8283
// IME utilities

0 commit comments

Comments
 (0)