Files
goose/ui/desktop/src/components/sessions/SessionViewComponents.tsx
2025-05-30 12:06:49 -07:00

222 lines
8.2 KiB
TypeScript

import React from 'react';
import { MessageSquare, AlertCircle } from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import ImagePreview from '../ImagePreview';
import {
ToolRequestMessageContent,
ToolResponseMessageContent,
TextContent,
} from '../../types/message';
import { type Message } from '../../types/message';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils';
/**
* Get tool responses map from messages
*/
export const getToolResponsesMap = (
messages: Message[],
messageIndex: number,
toolRequests: ToolRequestMessageContent[]
) => {
const responseMap = new Map();
if (messageIndex >= 0) {
for (let i = messageIndex + 1; i < messages.length; i++) {
const responses = messages[i].content
.filter((c) => c.type === 'toolResponse')
.map((c) => c as ToolResponseMessageContent);
for (const response of responses) {
const matchingRequest = toolRequests.find((req) => req.id === response.id);
if (matchingRequest) {
responseMap.set(response.id, response);
}
}
}
}
return responseMap;
};
/**
* Props for the SessionHeaderCard component
*/
export interface SessionHeaderCardProps {
onBack: () => void;
children: React.ReactNode;
}
/**
* Common header card for session views
*/
export const SessionHeaderCard: React.FC<SessionHeaderCardProps> = ({ onBack, children }) => {
return (
<Card className="rounded-none px-8 pt-6 pb-4 bg-bgAppInverse text-textProminentInverse flex items-center">
<BackButton
showText={false}
onClick={onBack}
iconSize="w-7 h-7"
className="!text-textProminentInverse dark:!text-textStandardInverse"
/>
{children}
</Card>
);
};
/**
* Props for the SessionMessages component
*/
export interface SessionMessagesProps {
messages: Message[];
isLoading: boolean;
error: string | null;
onRetry: () => void;
}
/**
* Common component for displaying session messages
*/
export const SessionMessages: React.FC<SessionMessagesProps> = ({
messages,
isLoading,
error,
onRetry,
}) => {
return (
<ScrollArea className="h-full w-full">
<div className="p-4">
<div className="flex flex-col space-y-4">
<div className="space-y-4 mb-6">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<div className="text-red-500 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-md mb-2">Error Loading Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : messages?.length > 0 ? (
messages
.map((message, index) => {
// Extract text content from the message
let textContent = message.content
.filter((c): c is TextContent => c.type === 'text')
.map((c) => c.text)
.join('\n');
// Extract image paths from the message
const imagePaths = extractImagePaths(textContent);
// Remove image paths from text for display
const displayText =
imagePaths.length > 0
? removeImagePathsFromText(textContent, imagePaths)
: textContent;
// Get tool requests from the message
const toolRequests = message.content
.filter((c) => c.type === 'toolRequest')
.map((c) => c as ToolRequestMessageContent);
// Get tool responses map using the helper function
const toolResponsesMap = getToolResponsesMap(messages, index, toolRequests);
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (message.role === 'user' && isOnlyToolResponse) {
return null;
}
return (
<Card
key={index}
className={`p-4 ${
message.role === 'user'
? 'bg-bgSecondary border border-borderSubtle'
: 'bg-bgSubtle'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-textStandard">
{message.role === 'user' ? 'You' : 'Goose'}
</span>
<span className="text-xs text-textSubtle">
{formatMessageTimestamp(message.created)}
</span>
</div>
<div className="flex flex-col w-full">
{/* Text content */}
{displayText && (
<div
className={`${toolRequests.length > 0 || imagePaths.length > 0 ? 'mb-4' : ''}`}
>
<MarkdownContent content={displayText} />
</div>
)}
{/* Render images if any */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{imagePaths.map((imagePath, imageIndex) => (
<ImagePreview
key={imageIndex}
src={imagePath}
alt={`Image ${imageIndex + 1}`}
/>
))}
</div>
)}
{/* Tool requests and responses */}
{toolRequests.length > 0 && (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
{toolRequests.map((toolRequest) => (
<ToolCallWithResponse
// In the session history page, if no tool response found for given request, it means the tool call
// is broken or cancelled.
isCancelledMessage={
toolResponsesMap.get(toolRequest.id) == undefined
}
key={toolRequest.id}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
/>
))}
</div>
)}
</div>
</Card>
);
})
.filter(Boolean) // Filter out null entries
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquare className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
);
};