Skip to content

Commit f9aa32b

Browse files
feat(chatbot): render model reasoning in a Cursor-style thinking pane (#310) (#311)
* feat(chatbot): render model reasoning in a Cursor-style thinking pane (#310) * feat(chatbot): restore the breathing accent glow on the reasoning pane (#310)
1 parent 3f2064a commit f9aa32b

3 files changed

Lines changed: 53 additions & 15 deletions

File tree

packages/filigran-chatbot/src/components/ChatThinking.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import type { AgentStatusState, IconProps } from '../types';
33
import {
44
BrainIcon,
@@ -111,38 +111,57 @@ function resolveStatusVisual(agentStatus: AgentStatusState | null, t: (key: stri
111111
}
112112
}
113113

114-
function stripMarkdown(text: string): string {
114+
/**
115+
* Light markdown cleanup for reasoning prose. Preserves paragraph breaks so
116+
* multi-step reasoning stays readable inside the small scrolling window
117+
* instead of collapsing into one unbroken blob.
118+
*/
119+
function cleanReasoningText(text: string): string {
115120
return text
116121
.replace(/```[\s\S]*?```/g, ' ')
117122
.replace(/`([^`]+)`/g, '$1')
118123
.replace(/\*\*(.+?)\*\*/g, '$1')
119124
.replace(/__(.+?)__/g, '$1')
120-
.replace(/\*(.+?)\*/g, '$1')
121-
.replace(/_(.+?)_/g, '$1')
122125
.replace(/#{1,6}\s+/g, '')
123-
.replace(/[*\->]+/g, ' ')
124-
.replace(/\s+/g, ' ')
126+
.replace(/^[ \t]*[-*>]+[ \t]*/gm, '')
127+
.replace(/[ \t]+/g, ' ')
128+
.replace(/\n{3,}/g, '\n\n')
125129
.trim();
126130
}
127131

132+
/**
133+
* Cursor-style reasoning pane: smaller, lighter text inside a capped-height
134+
* window that stays pinned to the newest line while tokens stream in, with
135+
* a soft fade at the top so older reasoning appears to scroll away — framed
136+
* by the signature breathing accent left-border glow.
137+
*/
128138
export function ThinkingTextBubble({ content }: { content: string }) {
129139
const ref = useRef<HTMLDivElement>(null);
130-
const cleaned = stripMarkdown(content);
140+
const [isOverflowing, setIsOverflowing] = useState(false);
141+
const cleaned = cleanReasoningText(content);
131142

132143
useEffect(() => {
133-
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
144+
const el = ref.current;
145+
if (!el) return;
146+
el.scrollTop = el.scrollHeight;
147+
setIsOverflowing(el.scrollHeight > el.clientHeight + 1);
134148
}, [cleaned]);
135149

136150
if (cleaned.length < 3) return null;
137151

138152
return (
139153
<div
140-
ref={ref}
141-
className="ml-11 max-w-[70%] max-h-20 overflow-hidden relative rounded-md border-l-2 bg-[var(--chat-accent)]/[0.03] pl-3 pr-3 py-2"
154+
className="ml-11 max-w-[75%] rounded-md border-l-2 bg-[var(--chat-accent)]/[0.03] py-2 pl-3 pr-3"
142155
style={{ animation: 'reasoningGlow 3s ease-in-out infinite, chat-fade-in 0.5s ease-out' }}
143156
>
144-
<p className="text-[13px] leading-[1.35rem] text-gray-400 dark:text-white/40 break-words m-0">{cleaned}</p>
145-
<div className="absolute inset-x-0 bottom-0 h-5 bg-gradient-to-t from-white/90 dark:from-[#1e1e2e]/90 to-transparent pointer-events-none" />
157+
<div
158+
ref={ref}
159+
className={`max-h-40 overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden${
160+
isOverflowing ? ' [mask-image:linear-gradient(to_bottom,transparent_0,black_3rem)]' : ''
161+
}`}
162+
>
163+
<p className="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-gray-500 dark:text-white/45">{cleaned}</p>
164+
</div>
146165
</div>
147166
);
148167
}

packages/filigran-chatbot/src/hooks/protocols/parseAgUiEvent.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ export function parseAgUiEvent(evt: Record<string, unknown>, ctx: ProtocolContex
105105
}
106106

107107
if (type === 'REASONING_MESSAGE_CONTENT' || type === 'REASONING_MESSAGE_CHUNK') {
108-
// Reasoning text — show as thinking status (content not surfaced to chat)
108+
// Reasoning text — surface it in the dedicated thinking pane
109+
const delta = evt.delta as string | undefined;
110+
if (delta) {
111+
return { action: 'status', status: 'thinking_text', thinkingContent: delta };
112+
}
109113
return { action: 'status', status: 'thinking' };
110114
}
111115

packages/filigran-chatbot/src/hooks/useChat.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,20 @@ export function useChat({
448448
ctx.hasUsedTools = ctx.hasUsedTools || hasUsedToolsRef.current;
449449

450450
switch (parsed.action) {
451-
case 'status':
451+
case 'status': {
452452
if (parsed.status === 'tool_start') hasUsedToolsRef.current = true;
453+
// Text streamed before a tool call (or a new loop iteration)
454+
// is the model thinking out loud, not the final answer.
455+
// Fold it into the reasoning pane so it shrinks into the
456+
// dimmed thinking window instead of streaming at full size
457+
// and then abruptly vanishing when the real answer starts.
458+
const isIterationBoundary =
459+
parsed.status === 'tool_start' || parsed.status === 'thinking' || parsed.status === 'analyzing';
460+
const folded = isIterationBoundary && accumulated.trim() ? accumulated : '';
461+
if (folded) {
462+
accumulated = '';
463+
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: '' } : m)));
464+
}
453465
if (parsed.status === 'thinking_text') {
454466
setAgentStatus((prev) => ({
455467
...prev,
@@ -460,10 +472,13 @@ export function useChat({
460472
setAgentStatus((prev) => ({
461473
status: parsed.status,
462474
tools: parsed.tools,
463-
thinkingContent: prev?.thinkingContent,
475+
thinkingContent: folded
476+
? (prev?.thinkingContent ? `${prev.thinkingContent}\n\n` : '') + folded
477+
: prev?.thinkingContent,
464478
}));
465479
}
466480
break;
481+
}
467482

468483
case 'stream':
469484
accumulated += parsed.content;

0 commit comments

Comments
 (0)