fix: preserve message parts order in chat display (#151)

- Fix bug where text after tool calls was merged with initial text
- Group consecutive text/file parts into bubbles while keeping tools in order
- Parts now display as: plan -> tool_result -> additional text
- Remove debug logs from fixToolCallInputs function

Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
This commit is contained in:
Dayuan Jiang
2025-12-07 19:56:31 +09:00
committed by GitHub
parent 86420a42c6
commit 8c431ee6ed
2 changed files with 199 additions and 147 deletions

View File

@@ -67,41 +67,23 @@ function isMinimalDiagram(xml: string): boolean {
// Helper function to fix tool call inputs for Bedrock API // Helper function to fix tool call inputs for Bedrock API
// Bedrock requires toolUse.input to be a JSON object, not a string // Bedrock requires toolUse.input to be a JSON object, not a string
function fixToolCallInputs(messages: any[]): any[] { function fixToolCallInputs(messages: any[]): any[] {
return messages.map((msg, msgIndex) => { return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) { if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg return msg
} }
const fixedContent = msg.content.map((part: any, partIndex: number) => { const fixedContent = msg.content.map((part: any) => {
if (part.type === "tool-call") { if (part.type === "tool-call") {
console.log(
`[fixToolCallInputs] msg[${msgIndex}].content[${partIndex}] tool-call:`,
{
toolName: part.toolName,
inputType: typeof part.input,
input: part.input,
},
)
if (typeof part.input === "string") { if (typeof part.input === "string") {
try { try {
const parsed = JSON.parse(part.input) const parsed = JSON.parse(part.input)
console.log(
`[fixToolCallInputs] Parsed string input to JSON:`,
parsed,
)
return { ...part, input: parsed } return { ...part, input: parsed }
} catch { } catch {
// If parsing fails, wrap the string in an object // If parsing fails, wrap the string in an object
console.log(
`[fixToolCallInputs] Failed to parse, wrapping in object`,
)
return { ...part, input: { rawInput: part.input } } return { ...part, input: { rawInput: part.input } }
} }
} }
// Input is already an object, but verify it's not null/undefined // Input is already an object, but verify it's not null/undefined
if (part.input === null || part.input === undefined) { if (part.input === null || part.input === undefined) {
console.log(
`[fixToolCallInputs] Input is null/undefined, using empty object`,
)
return { ...part, input: {} } return { ...part, input: {} }
} }
} }

View File

@@ -509,139 +509,209 @@ export function ChatMessageDisplay({
</div> </div>
</div> </div>
) : ( ) : (
/* Text content in bubble */ /* Render parts in order, grouping consecutive text/file parts into bubbles */
message.parts?.some( (() => {
(part) => const parts = message.parts || []
part.type === "text" || const groups: {
part.type === "file", type: "content" | "tool"
) && ( parts: typeof parts
<div startIndex: number
className={`px-4 py-3 text-sm leading-relaxed ${ }[] = []
message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm" parts.forEach((part, index) => {
: message.role === const isToolPart =
"system" part.type?.startsWith(
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md" "tool-",
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" )
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`} const isContentPart =
role={ part.type === "text" ||
message.role === "user" && part.type === "file"
isLastUserMessage &&
onEditMessage if (isToolPart) {
? "button" groups.push({
: undefined type: "tool",
} parts: [part],
tabIndex={ startIndex: index,
message.role === "user" && })
isLastUserMessage && } else if (isContentPart) {
onEditMessage const lastGroup =
? 0 groups[
: undefined groups.length - 1
} ]
onClick={() => {
if ( if (
message.role === lastGroup?.type ===
"user" && "content"
isLastUserMessage &&
onEditMessage
) { ) {
setEditingMessageId( lastGroup.parts.push(
message.id, part,
) )
setEditText( } else {
userMessageText, groups.push({
type: "content",
parts: [part],
startIndex: index,
})
}
}
})
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
return renderToolPart(
group
.parts[0] as ToolPartLike,
) )
} }
}}
onKeyDown={(e) => { // Content bubble
if ( return (
(e.key === "Enter" || <div
e.key === " ") && key={`${message.id}-content-${group.startIndex}`}
message.role === className={`px-4 py-3 text-sm leading-relaxed ${
"user" && message.role ===
isLastUserMessage && "user"
onEditMessage ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
) { : message.role ===
e.preventDefault() "system"
setEditingMessageId( ? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
message.id, : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
) } ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`}
setEditText( role={
userMessageText, message.role ===
) "user" &&
} isLastUserMessage &&
}} onEditMessage
title={ ? "button"
message.role === "user" && : undefined
isLastUserMessage && }
onEditMessage tabIndex={
? "Click to edit" message.role ===
: undefined "user" &&
} isLastUserMessage &&
> onEditMessage
{message.parts?.map( ? 0
(part, index) => { : undefined
switch (part.type) { }
case "text": onClick={() => {
return ( if (
<div message.role ===
key={`${message.id}-text-${index}`} "user" &&
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${ isLastUserMessage &&
message.role === onEditMessage
"user" ) {
? "[&_*]:!text-primary-foreground prose-code:bg-white/20" setEditingMessageId(
: "dark:prose-invert" message.id,
}`} )
> setEditText(
<ReactMarkdown> userMessageText,
{ )
part.text }
} }}
</ReactMarkdown> onKeyDown={(e) => {
</div> if (
) (e.key ===
case "file": "Enter" ||
return ( e.key ===
<div " ") &&
key={`${message.id}-file-${part.url}`} message.role ===
className="mt-2" "user" &&
> isLastUserMessage &&
<Image onEditMessage
src={ ) {
part.url e.preventDefault()
} setEditingMessageId(
width={ message.id,
200 )
} setEditText(
height={ userMessageText,
200 )
} }
alt={`Uploaded diagram or image for AI analysis`} }}
className="rounded-lg border border-white/20" title={
style={{ message.role ===
objectFit: "user" &&
"contain", isLastUserMessage &&
}} onEditMessage
/> ? "Click to edit"
</div> : undefined
) }
default: >
return null {group.parts.map(
} (
}, part,
)} partIndex,
</div> ) => {
) if (
)} part.type ===
{/* Tool calls outside bubble */} "text"
{message.parts?.map((part) => { ) {
if (part.type?.startsWith("tool-")) { return (
return renderToolPart( <div
part as ToolPartLike, key={`${message.id}-text-${group.startIndex}-${partIndex}`}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert"
}`}
>
<ReactMarkdown>
{
(
part as {
text: string
}
)
.text
}
</ReactMarkdown>
</div>
)
}
if (
part.type ===
"file"
) {
return (
<div
key={`${message.id}-file-${group.startIndex}-${partIndex}`}
className="mt-2"
>
<Image
src={
(
part as {
url: string
}
)
.url
}
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit:
"contain",
}}
/>
</div>
)
}
return null
},
)}
</div>
)
},
) )
} })()
return null )}
})}
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{message.role === "assistant" && ( {message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">