Compare commits

...
Author SHA1 Message Date
abeatrix e3e832ec7c duration playground 2026-01-15 10:48:25 -08:00
12 changed files with 309 additions and 250 deletions
+2
View File
@@ -123,6 +123,7 @@ message ClineSayTool {
string regex = 5;
string file_pattern = 6;
bool operation_is_located_in_workspace = 7;
int32 duration = 8;
}
// Message for ClineSayBrowserAction
@@ -221,6 +222,7 @@ message ClineMessage {
ClineAskNewTask ask_new_task = 21;
ClineApiReqInfo api_req_info = 22;
ClineModelInfo model_info = 23;
int32 duration = 24;
}
message ShowWebviewEvent {
+3
View File
@@ -8,6 +8,7 @@ export interface TextStreamContent {
type: "text"
content: string
partial: boolean
duration?: number // duration in milliseconds
}
export const toolParamNames = [
@@ -71,6 +72,7 @@ export interface ToolUse {
* Thought signature associated with this tool use, used by Gemini
*/
signature?: string
duration?: number // duration in milliseconds
}
export interface ReasoningStreamContent {
@@ -101,4 +103,5 @@ export interface ReasoningStreamContent {
* Indicates whether this is a partial reasoning block
*/
partial: boolean
duration?: number // duration in milliseconds
}
+22 -5
View File
@@ -9,6 +9,7 @@ import {
ClineReasoningDetailParam,
} from "@/shared/messages/content"
import { ClineDefaultTool } from "@/shared/tools"
import { AutoApprove } from "./tools/autoApprove"
export interface PendingToolUse {
id: string
@@ -18,6 +19,7 @@ export interface PendingToolUse {
signature?: string
jsonParser?: JSONParser
call_id?: string
start_time_ms: number
}
interface ToolUseDeltaBlock {
@@ -42,6 +44,7 @@ export interface PendingReasoning {
signature: string
redactedThinking: ClineAssistantRedactedThinkingBlock[]
summary: unknown[] | ClineReasoningDetailParam[]
start_time_ms: number
}
const ESCAPE_MAP: Record<string, string> = {
@@ -55,8 +58,13 @@ const ESCAPE_MAP: Record<string, string> = {
const ESCAPE_PATTERN = /\\[ntr"\\]/g
export class StreamResponseHandler {
private toolUseHandler = new ToolUseHandler()
private reasoningHandler = new ReasoningHandler()
constructor(private autoApprove: AutoApprove) {
this.toolUseHandler = new ToolUseHandler(this.autoApprove)
this.reasonsHandler = new ReasoningHandler()
}
private toolUseHandler: ToolUseHandler
private reasonsHandler: ReasoningHandler
private _requestId: string | undefined
@@ -73,14 +81,14 @@ export class StreamResponseHandler {
public getHandlers() {
return {
toolUseHandler: this.toolUseHandler,
reasonsHandler: this.reasoningHandler,
reasonsHandler: this.reasonsHandler,
}
}
public reset() {
this._requestId = undefined
this.toolUseHandler = new ToolUseHandler()
this.reasoningHandler = new ReasoningHandler()
this.toolUseHandler = new ToolUseHandler(this.autoApprove)
this.reasonsHandler = new ReasoningHandler()
}
}
@@ -88,6 +96,7 @@ export class StreamResponseHandler {
* Handles streaming native tool use blocks and converts them to ClineAssistantToolUseBlock format
*/
class ToolUseHandler {
constructor(private autoApprove: AutoApprove) {}
private pendingToolUses = new Map<string, PendingToolUse>()
processToolUseDelta(delta: ToolUseDeltaBlock, call_id?: string): void {
@@ -142,6 +151,8 @@ class ToolUseHandler {
input,
signature: pending.signature,
call_id: pending.call_id,
approved: this.autoApprove.shouldAutoApproveTool(pending.name as ClineDefaultTool) === true,
duration_ms: Date.now() - pending.start_time_ms,
}
}
@@ -198,6 +209,7 @@ class ToolUseHandler {
isNativeToolCall: true,
signature: pending.signature,
call_id: pending.call_id,
duration: Date.now() - pending.start_time_ms,
})
} else {
const params: Record<string, string> = {}
@@ -214,6 +226,7 @@ class ToolUseHandler {
signature: pending.signature,
isNativeToolCall: true,
call_id: pending.call_id,
duration: Date.now() - pending.start_time_ms,
})
}
}
@@ -235,6 +248,7 @@ class ToolUseHandler {
jsonParser,
call_id,
signature: undefined,
start_time_ms: Date.now(),
}
jsonParser.onValue = (info: any) => {
@@ -276,6 +290,7 @@ class ReasoningHandler {
signature: "",
redactedThinking: [],
summary: [],
start_time_ms: Date.now(),
}
}
@@ -302,6 +317,7 @@ class ReasoningHandler {
type: "redacted_thinking",
data: delta.redacted_data,
call_id: delta.id || this.pendingReasoning.id,
duration_ms: Date.now() - this.pendingReasoning.start_time_ms,
})
}
}
@@ -332,6 +348,7 @@ class ReasoningHandler {
signature: this.pendingReasoning.signature,
summary: this.pendingReasoning.summary,
call_id: this.pendingReasoning.id,
duration_ms: Date.now() - this.pendingReasoning.start_time_ms,
}
}
+193 -175
View File
@@ -105,6 +105,7 @@ import { MessageStateHandler } from "./message-state"
import { StreamResponseHandler } from "./StreamResponseHandler"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { AutoApprove } from "./tools/autoApprove"
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
import { buildUserFeedbackContent } from "./utils/buildUserFeedbackContent"
@@ -134,6 +135,18 @@ type TaskParams = {
taskLockAcquired: boolean
}
/**
* Result from handlePartialMessage indicating what happened
*/
interface HandlePartialMessageResult {
/** The timestamp of the message */
ts: number
/** Whether an existing partial message was updated (vs creating a new message) */
wasUpdated: boolean
/** Whether this was a partial=true case that should throw to interrupt ask flow */
shouldThrowForAsk: boolean
}
export class Task {
// Core task variables
readonly taskId: string
@@ -303,7 +316,7 @@ export class Task {
this.urlContentFetcher = new UrlContentFetcher(controller.context)
this.browserSession = new BrowserSession(stateManager)
this.contextManager = new ContextManager()
this.streamHandler = new StreamResponseHandler()
this.streamHandler = new StreamResponseHandler(new AutoApprove(stateManager))
this.cwd = cwd
this.stateManager = stateManager
this.workspaceManager = workspaceManager
@@ -569,6 +582,147 @@ export class Task {
// Communicate with webview
/**
* Handle partial message logic for both ask() and say() methods.
* Extracts the common pattern of updating/creating partial messages.
*
* @param messageCategory "ask" or "say"
* @param messageType The specific ask or say type
* @param text Message text content
* @param partial Partial state: true (streaming), false (complete), undefined (single message)
* @param options Additional options for the message
* @returns Result indicating what action was taken
*/
private async handlePartialMessage(
messageCategory: "ask" | "say",
messageType: ClineAsk | ClineSay,
text?: string,
partial?: boolean,
options?: {
images?: string[]
files?: string[]
duration?: number
modelInfo?: ClineMessageModelInfo
},
): Promise<HandlePartialMessageResult> {
const clineMessages = this.messageStateHandler.getClineMessages()
const lastMessage = clineMessages.at(-1)
const lastMessageIndex = clineMessages.length - 1
// Check if we're updating an existing partial message of the same type
const isUpdatingPreviousPartial =
lastMessage &&
lastMessage.partial &&
lastMessage.type === messageCategory &&
(messageCategory === "ask" ? lastMessage.ask === messageType : lastMessage.say === messageType)
if (partial === true) {
// partial=true: streaming in progress
if (isUpdatingPreviousPartial) {
// Update existing partial message
if (messageCategory === "say") {
// For say, we mutate the message directly (existing behavior)
lastMessage.text = text
lastMessage.images = options?.images
lastMessage.files = options?.files
lastMessage.partial = partial
lastMessage.duration = options?.duration
} else {
// For ask, we use updateClineMessage
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial,
})
}
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
return { ts: lastMessage.ts, wasUpdated: true, shouldThrowForAsk: messageCategory === "ask" }
} else {
// Create new partial message
const ts = Date.now()
this.taskState.lastMessageTs = ts
const newMessage: ClineMessage = {
ts,
type: messageCategory,
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
text,
partial,
...(options?.images && { images: options.images }),
...(options?.files && { files: options.files }),
...(options?.modelInfo && { modelInfo: options.modelInfo }),
...(options?.duration !== undefined && { duration: options.duration }),
// Carry over duration from previous message for ask (existing behavior)
...(messageCategory === "ask" && lastMessage?.duration && { duration: lastMessage.duration }),
}
await this.messageStateHandler.addToClineMessages(newMessage)
await this.postStateToWebview()
return { ts, wasUpdated: false, shouldThrowForAsk: messageCategory === "ask" }
}
} else if (partial === false) {
// partial=false: completing a previously partial message
if (isUpdatingPreviousPartial) {
// Complete the existing partial message
const ts = lastMessage.ts
this.taskState.lastMessageTs = ts
if (messageCategory === "say") {
// For say, mutate directly and save (existing behavior)
lastMessage.text = text
lastMessage.images = options?.images
lastMessage.files = options?.files
lastMessage.partial = false
lastMessage.duration = lastMessage.duration ?? options?.duration
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
} else {
// For ask, use updateClineMessage
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial: false,
})
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
}
return { ts, wasUpdated: true, shouldThrowForAsk: false }
} else {
// New message with partial=false (treated as complete message)
const ts = Date.now()
this.taskState.lastMessageTs = ts
const newMessage: ClineMessage = {
ts,
type: messageCategory,
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
text,
...(options?.images && { images: options.images }),
...(options?.files && { files: options.files }),
...(options?.modelInfo && { modelInfo: options.modelInfo }),
...(options?.duration !== undefined && { duration: options.duration }),
}
await this.messageStateHandler.addToClineMessages(newMessage)
await this.postStateToWebview()
return { ts, wasUpdated: false, shouldThrowForAsk: false }
}
} else {
// partial=undefined: new non-partial message (individual complete message)
const ts = Date.now()
this.taskState.lastMessageTs = ts
const newMessage: ClineMessage = {
ts,
type: messageCategory,
...(messageCategory === "ask" ? { ask: messageType as ClineAsk } : { say: messageType as ClineSay }),
text,
...(options?.images && { images: options.images }),
...(options?.files && { files: options.files }),
...(options?.modelInfo && { modelInfo: options.modelInfo }),
...(options?.duration !== undefined && { duration: options.duration }),
}
await this.messageStateHandler.addToClineMessages(newMessage)
await this.postStateToWebview()
return { ts, wasUpdated: false, shouldThrowForAsk: false }
}
}
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
async ask(
type: ClineAsk,
@@ -580,116 +734,37 @@ export class Task {
images?: string[]
files?: string[]
askTs?: number
duration?: number
}> {
// Allow resume asks even when aborted to enable resume button after cancellation
if (this.taskState.abort && type !== "resume_task" && type !== "resume_completed_task") {
throw new Error("Cline instance aborted")
}
let askTs: number
if (partial !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const lastMessage = clineMessages.at(-1)
const lastMessageIndex = clineMessages.length - 1
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial,
})
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
// await this.saveClineMessagesAndUpdateHistory()
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
throw new Error("Current ask promise was ignored 1")
} else {
// this is a new partial message, so add it with partial state
// this.askResponse = undefined
// this.askResponseText = undefined
// this.askResponseImages = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
partial,
})
await this.postStateToWebview()
throw new Error("Current ask promise was ignored 2")
}
} else {
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
const result = await this.handlePartialMessage("ask", type, text, partial)
/*
Bug for the history books:
In the webview we use the ts as the chatrow key for the virtuoso list. Since we would update this ts right at the end of streaming, it would cause the view to flicker. The key prop has to be stable otherwise react has trouble reconciling items between renders, causing unmounting and remounting of components (flickering).
The lesson here is if you see flickering when rendering lists, it's likely because the key prop is not stable.
So in this case we must make sure that the message ts is never altered after first setting it.
*/
askTs = lastMessage.ts
this.taskState.lastMessageTs = askTs
// lastMessage.ts = askTs
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial: false,
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
} else {
// this is a new partial=false message, so add it like normal
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
})
await this.postStateToWebview()
}
}
} else {
// this is a new non-partial message, so add it like normal
// const lastMessage = this.clineMessages.at(-1)
// For ask, partial=true cases should throw to interrupt the ask flow
if (result.shouldThrowForAsk) {
throw new Error("Current ask promise was ignored")
}
// Clear response state when not streaming (partial=true)
if (!partial) {
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
})
await this.postStateToWebview()
}
const askTs = result.ts
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
interval: 100,
})
if (this.taskState.lastMessageTs !== askTs) {
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
}
const result = {
const askResult = {
response: this.taskState.askResponse!,
text: this.taskState.askResponseText,
images: this.taskState.askResponseImages,
@@ -699,7 +774,7 @@ export class Task {
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
return result
return askResult
}
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[], files?: string[]) {
@@ -715,6 +790,7 @@ export class Task {
images?: string[],
files?: string[],
partial?: boolean,
duration?: number,
): Promise<number | undefined> {
// Allow hook messages even when aborted to enable proper cleanup
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
@@ -728,87 +804,15 @@ export class Task {
mode: providerInfo.mode,
}
if (partial !== undefined) {
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = partial
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
return undefined
} else {
// this is a new partial message, so add it with partial state
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
partial,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
} else {
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
// lastMessage.ts = sayTs
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files // Ensure files is updated
lastMessage.partial = false
const result = await this.handlePartialMessage("say", type, text, partial, {
images,
files,
duration,
modelInfo,
})
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
return undefined
} else {
// this is a new partial=false message, so add it like normal
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
}
} else {
// this is a new non-partial message, so add it like normal
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
// For say, return undefined when updating existing partial, otherwise return the timestamp
return result.wasUpdated ? undefined : result.ts
}
async sayAndCreateMissingParamError(toolName: ClineDefaultTool, paramName: string, relPath?: string) {
@@ -2566,7 +2570,14 @@ export class Task {
if (!this.taskState.abort) {
const thinkingBlock = reasonsHandler.getCurrentReasoning()
if (thinkingBlock?.thinking && chunk.reasoning) {
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
await this.say(
"reasoning",
thinkingBlock.thinking,
undefined,
undefined,
true,
thinkingBlock.duration_ms,
)
}
}
@@ -2599,7 +2610,14 @@ export class Task {
const currentReasoning = reasonsHandler.getCurrentReasoning()
if (currentReasoning?.thinking && assistantMessage.length === 0) {
// Complete the reasoning message (only once)
await this.say("reasoning", currentReasoning.thinking, undefined, undefined, false)
await this.say(
"reasoning",
currentReasoning.thinking,
undefined,
undefined,
false,
currentReasoning.duration_ms,
)
}
if (chunk.signature) {
assistantTextSignature = chunk.signature
@@ -158,6 +158,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
...sharedMessageProps,
content: diff || content,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
duration: block.duration,
// ? formatResponse.createPrettyPatch(
// relPath,
// this.diffViewProvider.originalContent,
+2
View File
@@ -132,6 +132,7 @@ export interface ClineMessage {
conversationHistoryIndex?: number
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
modelInfo?: ClineMessageModelInfo
duration?: number // duration in milliseconds
}
export type ClineAsk =
@@ -208,6 +209,7 @@ export interface ClineSayTool {
regex?: string
filePattern?: string
operationIsLocatedInWorkspace?: boolean
duration?: number // duration in milliseconds
}
export interface ClineSayHook {
+8 -8
View File
@@ -16,6 +16,8 @@ export interface ClineReasoningDetailParam {
interface ClineSharedMessageParam {
// The id of the response that the block belongs to
call_id?: string
duration_ms?: number
approved?: boolean
}
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
@@ -36,7 +38,9 @@ export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, Cline
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {
error?: string
}
/**
* Assistant only content types
@@ -127,18 +131,14 @@ export function convertClineStorageToAnthropicMessage(
return { role, content: cleanedContent }
}
const CLINE_UNIQUE_FIELDS = ["reasoning_details", "call_id", "summary", "signature", "duration_ms", "approved"]
/**
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
*/
export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
// Fast path: if no Cline-specific fields exist, return as-is
const hasClineFields =
"reasoning_details" in block ||
"call_id" in block ||
"summary" in block ||
(block.type !== "thinking" && "signature" in block)
if (!hasClineFields) {
if (!CLINE_UNIQUE_FIELDS.some((f) => f in block) || (block.type !== "thinking" && "signature" in block)) {
return block as Anthropic.ContentBlock
}
@@ -209,6 +209,7 @@ export function convertClineMessageToProto(message: AppClineMessage): ProtoCline
askNewTask: undefined,
apiReqInfo: undefined,
modelInfo: message.modelInfo ?? undefined,
duration: message.duration ?? 0,
}
return protoMessage
@@ -276,5 +277,9 @@ export function convertProtoToClineMessage(protoMessage: ProtoClineMessage): App
]
}
if (protoMessage.duration !== 0) {
message.duration = protoMessage.duration
}
return message
}
+12 -9
View File
@@ -786,6 +786,8 @@ export const ChatRowContent = memo(
)
}
console.log("Rendering ChatRowContent for message:", message.duration)
switch (message.type) {
case "say":
switch (message.say) {
@@ -913,6 +915,8 @@ export const ChatRowContent = memo(
return activities
}, [clineMessages])
console.log("API Req State:", message)
return (
<div>
{apiReqState === "pre" && (
@@ -935,15 +939,14 @@ export const ChatRowContent = memo(
</div>
</div>
)}
{reasoningContent && (
<ThinkingRow
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
isVisible={true}
onToggle={handleToggle}
reasoningContent={reasoningContent}
showTitle={false}
/>
)}
<ThinkingRow
duration={message.duration}
isExpanded={isExpanded || showStreamingThinking || showCollapsedThinking}
isVisible={!!reasoningContent}
onToggle={handleToggle}
reasoningContent={reasoningContent}
showTitle={false}
/>
{apiReqState === "error" && (
<ErrorRow
@@ -370,6 +370,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<MessagesArea
chatState={chatState}
groupedMessages={groupedMessages}
key={task.ts}
messageHandlers={messageHandlers}
modifiedMessages={modifiedMessages}
scrollBehavior={scrollBehavior}
+59 -52
View File
@@ -9,66 +9,73 @@ interface ThinkingRowProps {
isVisible: boolean
isExpanded: boolean
onToggle?: () => void
duration?: number
}
export const ThinkingRow = memo(({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle }: ThinkingRowProps) => {
const scrollRef = useRef<HTMLDivElement>(null)
export const ThinkingRow = memo(
({ showTitle = false, reasoningContent, isVisible, isExpanded, onToggle, duration }: ThinkingRowProps) => {
const scrollRef = useRef<HTMLDivElement>(null)
// Only auto-scroll to bottom during streaming (showCursor=true)
// For expanded collapsed thinking, start at top
useEffect(() => {
if (scrollRef.current && isVisible) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [reasoningContent, isVisible])
// Only auto-scroll to bottom during streaming (showCursor=true)
// For expanded collapsed thinking, start at top
useEffect(() => {
if (scrollRef.current && isVisible) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [reasoningContent, isVisible])
if (!isVisible) {
return null
}
return (
<div
className={cn(
"ml-1 transition-opacity duration-200",
!isVisible && "hidden opacity-0",
isVisible && "starting:opacity-0",
)}
hidden={!isVisible}>
{showTitle ? (
<Button
className="inline-flex justify-baseline gap-0.5 text-left select-none cursor-pointer text-description px-0 w-full"
onClick={onToggle}
variant="icon">
{isExpanded ? <ChevronDownIcon className="opacity-70" /> : <ChevronRightIcon className="opacity-70" />}
<span className="font-semibold">Thinking:</span>
<span className="italic break-words truncate [direction:rtl] w-full">
{!isExpanded ? reasoningContent : ""}
</span>
</Button>
) : (
<div>{duration}</div>
)}
return (
<div className="ml-1">
{showTitle ? (
<Button
className="inline-flex justify-baseline gap-0.5 text-left select-none cursor-pointer text-description px-0 w-full"
onClick={onToggle}
variant="icon">
{isExpanded ? <ChevronDownIcon className="opacity-70" /> : <ChevronRightIcon className="opacity-70" />}
<span className="font-semibold">Thinking:</span>
<span className="italic break-words truncate [direction:rtl] w-full">
{!isExpanded ? reasoningContent : ""}
</span>
</Button>
) : null}
{isExpanded && (
<Button
className={cn(
"flex gap-0 overflow-hidden w-full min-w-0 max-h-0 opacity-0 items-baseline justify-baseline text-left p-0",
"disabled:cursor-text disabled:opacity-100",
{
"max-h-[200px] opacity-100": isVisible,
"transition-[max-height] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] [transition:max-height_250ms_cubic-bezier(0.4,0,0.2,1),opacity_150ms_ease-out]":
isVisible,
},
)}
disabled={!showTitle}
onClick={onToggle}
variant="text">
<div
{isExpanded && (
<Button
className={cn(
"flex max-h-[150px] overflow-y-auto text-description leading-normal truncated whitespace-pre-wrap break-words flex-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [direction:ltr]",
"flex gap-0 overflow-hidden w-full min-w-0 max-h-0 opacity-0 items-baseline justify-baseline text-left p-0",
"disabled:cursor-text disabled:opacity-100",
{
"pl-2 border-l border-description/50": showTitle,
"max-h-[200px] opacity-100": isVisible,
"transition-[max-height] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] [transition:max-height_250ms_cubic-bezier(0.4,0,0.2,1),opacity_150ms_ease-out]":
isVisible,
},
)}
ref={scrollRef}>
<span>{reasoningContent}</span>
</div>
</Button>
)}
</div>
)
})
disabled={!showTitle}
onClick={onToggle}
variant="text">
<div
className={cn(
"flex max-h-[150px] overflow-y-auto text-description leading-normal truncated whitespace-pre-wrap break-words flex-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [direction:ltr]",
{
"pl-2 border-l border-description/50": showTitle,
},
)}
ref={scrollRef}>
<span>{reasoningContent}</span>
</div>
</Button>
)}
</div>
)
},
)
ThinkingRow.displayName = "ThinkingRow"
@@ -84,7 +84,7 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
)
return (
<div className="overflow-hidden flex flex-col h-full relative">
<div className="overflow-hidden flex flex-col h-full relative animate-fade-in">
{/* Sticky User Message - positioned absolutely to avoid layout shifts */}
<div
className={cn(