|
1 | | -import { useEffect, useRef } from 'react'; |
| 1 | +import { useEffect, useRef, useState } from 'react'; |
2 | 2 | import type { AgentStatusState, IconProps } from '../types'; |
3 | 3 | import { |
4 | 4 | BrainIcon, |
@@ -111,38 +111,57 @@ function resolveStatusVisual(agentStatus: AgentStatusState | null, t: (key: stri |
111 | 111 | } |
112 | 112 | } |
113 | 113 |
|
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 { |
115 | 120 | return text |
116 | 121 | .replace(/```[\s\S]*?```/g, ' ') |
117 | 122 | .replace(/`([^`]+)`/g, '$1') |
118 | 123 | .replace(/\*\*(.+?)\*\*/g, '$1') |
119 | 124 | .replace(/__(.+?)__/g, '$1') |
120 | | - .replace(/\*(.+?)\*/g, '$1') |
121 | | - .replace(/_(.+?)_/g, '$1') |
122 | 125 | .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') |
125 | 129 | .trim(); |
126 | 130 | } |
127 | 131 |
|
| 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 | + */ |
128 | 138 | export function ThinkingTextBubble({ content }: { content: string }) { |
129 | 139 | const ref = useRef<HTMLDivElement>(null); |
130 | | - const cleaned = stripMarkdown(content); |
| 140 | + const [isOverflowing, setIsOverflowing] = useState(false); |
| 141 | + const cleaned = cleanReasoningText(content); |
131 | 142 |
|
132 | 143 | 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); |
134 | 148 | }, [cleaned]); |
135 | 149 |
|
136 | 150 | if (cleaned.length < 3) return null; |
137 | 151 |
|
138 | 152 | return ( |
139 | 153 | <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" |
142 | 155 | style={{ animation: 'reasoningGlow 3s ease-in-out infinite, chat-fade-in 0.5s ease-out' }} |
143 | 156 | > |
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> |
146 | 165 | </div> |
147 | 166 | ); |
148 | 167 | } |
|
0 commit comments