Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 128 additions & 98 deletions ui/desktop/src/components/GooseMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useRef } from 'react';
import LinkPreview from './LinkPreview';
import ImagePreview from './ImagePreview';
import GooseResponseForm from './GooseResponseForm';
import { extractUrls } from '../utils/urlUtils';
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent';
import ToolCallWithResponse from './ToolCallWithResponse';
import ToolCallChain from './ToolCallChain';
import { identifyConsecutiveToolCalls, shouldHideMessage, getChainForMessage } from '../utils/toolCallChaining';
import {
Message,
getTextContent,
Expand Down Expand Up @@ -35,39 +36,21 @@ interface GooseMessageProps {
export default function GooseMessage({
messageHistoryIndex,
message,
metadata,
messages,
metadata: _metadata,
toolCallNotifications,
append,
append: _append,
appendMessage,
isStreaming = false,
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
// Track which tool confirmations we've already handled to prevent infinite loops
const contentRef = useRef<HTMLDivElement>(null);
const handledToolConfirmations = useRef<Set<string>>(new Set());

// Extract text content from the message
let textContent = getTextContent(message);

// Utility to split Chain-of-Thought (CoT) from the visible assistant response.
// If the text contains a <think>...</think> block, everything inside is treated as the
// CoT and removed from the user-visible text.
const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => {
const regex = /<think>([\s\S]*?)<\/think>/i;
const match = text.match(regex);
if (!match) {
return { visibleText: text, cotText: null };
}

const cotRaw = match[1].trim();
const visible = text.replace(match[0], '').trim();
return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null };
};
// Extract image paths from the message content
const imagePaths = extractImagePaths(getTextContent(message));

const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent);

// Extract image paths from the visible part of the message (exclude CoT)
const imagePaths = extractImagePaths(textWithoutCot);
// Get text content without Chain of Thought
const textWithoutCot = getTextContent(message);

// Remove image paths from text for display
const displayText =
Expand All @@ -79,11 +62,53 @@ export default function GooseMessage({
// Get tool requests from the message
const toolRequests = getToolRequests(message);

// Get current message index
const messageIndex = messages.findIndex((msg) => msg.id === message.id);

// Enhanced chain detection that works during streaming
const toolCallChains = useMemo(() => {
// Always run chain detection, but handle streaming messages specially
const chains = identifyConsecutiveToolCalls(messages);

// If this message is streaming and has tool calls but no text,
// check if it should extend an existing chain
if (isStreaming && toolRequests.length > 0 && !displayText.trim()) {
// Look for an existing chain that this message could extend
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
if (previousMessage) {
const prevToolRequests = getToolRequests(previousMessage);

// If previous message has tool calls (with or without text), extend its chain
if (prevToolRequests.length > 0) {
// Find if previous message is part of a chain
const prevChain = chains.find(chain => chain.includes(messageIndex - 1));
if (prevChain) {
// Extend the existing chain to include this streaming message
const extendedChains = chains.map(chain =>
chain === prevChain ? [...chain, messageIndex] : chain
);
return extendedChains;
} else {
// Create a new chain with previous and current message
return [...chains, [messageIndex - 1, messageIndex]];
}
}
}
}

return chains;
}, [messages, isStreaming, messageIndex, toolRequests, displayText]);

// Check if this message should be hidden (part of chain but not first)
const shouldHide = shouldHideMessage(messageIndex, toolCallChains);

// Get the chain this message belongs to
const messageChain = getChainForMessage(messageIndex, toolCallChains);

// Extract URLs under a few conditions
// 1. The message is purely text
// 2. The link wasn't also present in the previous message
// 3. The message contains the explicit http:// or https:// protocol at the beginning
const messageIndex = messages?.findIndex((msg) => msg.id === message.id);
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : [];
Expand Down Expand Up @@ -132,7 +157,10 @@ export default function GooseMessage({
handledToolConfirmations.current.add(toolConfirmationContent.id);

appendMessage(
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
createToolErrorResponseMessage(
toolConfirmationContent.id,
'Tool execution was cancelled or interrupted.'
)
);
}
}
Expand All @@ -145,76 +173,97 @@ export default function GooseMessage({
appendMessage,
]);

return (
<div className="goose-message flex w-[90%] justify-start min-w-0">
<div className="flex flex-col w-full min-w-0">
{/* Chain-of-Thought (hidden by default) */}
{cotText && (
<details className="bg-bgSubtle border border-borderSubtle rounded p-2 mb-2">
<summary className="cursor-pointer text-sm text-textSubtle select-none">
Show thinking
</summary>
<div className="mt-2">
<MarkdownContent content={cotText} />
</div>
</details>
)}
// If this message should be hidden (part of chain but not first), don't render it
if (shouldHide) {
return null;
}

// Determine rendering logic based on chain membership and content
const isFirstInChain = messageChain && messageChain[0] === messageIndex;
const hasText = displayText.trim().length > 0;

{/* Visible assistant response */}
return (
<div className="group relative w-full">
<div className="flex flex-col items-start w-full">
{/* Regular text content - ALWAYS show if present */}
{displayText && (
<div className="flex flex-col group">
<div className={`goose-message-content py-2`}>
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
<div className="flex flex-col w-full">
<div ref={contentRef} className="w-full">
<MarkdownContent content={displayText} />
</div>

{/* Render images if any */}
{/* Image previews */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
<div className="mt-4">
{imagePaths.map((imagePath, index) => (
<ImagePreview key={index} src={imagePath} alt={`Image ${index + 1}`} />
<ImagePreview key={index} src={imagePath} />
))}
</div>
)}

{/* Only show timestamp and copy link when not streaming */}
<div className="relative flex justify-start">
{toolRequests.length === 0 && !isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{displayText &&
message.content.every((content) => content.type === 'text') &&
!isStreaming && (
{/* URLs */}
{urls.length > 0 && (
<div className="mt-4">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
)}

{/* Show timestamp for text-only messages */}
{toolRequests.length === 0 && (
<div className="relative flex justify-start">
{!isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{message.content.every((content) => content.type === 'text') && !isStreaming && (
<div className="absolute left-0 pt-1">
<MessageCopyLink text={displayText} contentRef={contentRef} />
</div>
)}
</div>
</div>
)}
</div>
)}

{/* Tool calls - either as chain or individual */}
{toolRequests.length > 0 && (
<div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => (
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
<ToolCallWithResponse
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/>
<>
{isFirstInChain ? (
// This is the first message in a chain - render the entire chain
<ToolCallChain
messages={messages}
chainIndices={messageChain}
toolCallNotifications={toolCallNotifications}
toolResponsesMap={toolResponsesMap}
messageHistoryIndex={messageHistoryIndex}
isStreaming={isStreaming}
/>
) : !messageChain ? (
// This message is not part of any chain - render individual tool calls
<div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => (
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
<ToolCallWithResponse
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/>
</div>
))}
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
))}
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
) : null}
</>
)}

{hasToolConfirmation && (
Expand All @@ -226,25 +275,6 @@ export default function GooseMessage({
/>
)}
</div>

{/* TODO(alexhancock): Re-enable link previews once styled well again */}
{/* eslint-disable-next-line no-constant-binary-expression */}
{false && urls.length > 0 && (
<div className="flex flex-wrap mt-[16px]">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
)}

{/* enable or disable prompts here */}
{/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */}
{/* eslint-disable-next-line no-constant-binary-expression */}
{false && metadata && (
<div className="flex mt-[16px]">
<GooseResponseForm message={displayText} metadata={metadata || null} append={append} />
</div>
)}
</div>
);
}
Loading