2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import ChatMessage from './ChatMessage';
|
|
|
|
|
import { PermissionCard } from './PermissionCard';
|
2026-01-09 21:38:22 +02:00
|
|
|
import { QuestionCard } from './QuestionCard';
|
2026-01-06 21:31:04 +02:00
|
|
|
import type { PermissionRequest } from '@/types/permission';
|
2026-01-09 21:38:22 +02:00
|
|
|
import type { QuestionRequest } from '@/types/question';
|
2025-12-07 19:32:53 +02:00
|
|
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
2025-12-27 02:37:29 +02:00
|
|
|
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
2026-01-25 19:16:26 +02:00
|
|
|
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
|
|
|
|
|
|
|
|
|
|
interface ChatMessageEntry {
|
|
|
|
|
info: Message;
|
|
|
|
|
parts: Part[];
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
interface MessageListProps {
|
2026-01-25 19:16:26 +02:00
|
|
|
messages: ChatMessageEntry[];
|
2026-01-06 21:31:04 +02:00
|
|
|
permissions: PermissionRequest[];
|
2026-01-09 21:38:22 +02:00
|
|
|
questions: QuestionRequest[];
|
2025-12-07 19:32:53 +02:00
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
hasMoreAbove: boolean;
|
|
|
|
|
isLoadingOlder: boolean;
|
|
|
|
|
onLoadOlder: () => void;
|
2026-01-20 03:23:39 +02:00
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2026-01-25 19:16:26 +02:00
|
|
|
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MessageRowProps {
|
|
|
|
|
message: ChatMessageEntry;
|
|
|
|
|
onContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
animationHandlers: AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
// Static MessageRow - does NOT subscribe to dynamic context
|
|
|
|
|
// Used for messages NOT in the last turn - no re-renders during streaming
|
|
|
|
|
const StaticMessageRow = React.memo<MessageRowProps>(({
|
|
|
|
|
message,
|
|
|
|
|
onContentChange,
|
|
|
|
|
animationHandlers,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
}) => {
|
|
|
|
|
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
|
|
|
|
|
const turnGroupingContext = useTurnGroupingContextStatic(message.info.id);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ChatMessage
|
|
|
|
|
message={message}
|
|
|
|
|
previousMessage={previousMessage}
|
|
|
|
|
nextMessage={nextMessage}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
animationHandlers={animationHandlers}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
turnGroupingContext={turnGroupingContext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
StaticMessageRow.displayName = 'StaticMessageRow';
|
|
|
|
|
|
|
|
|
|
// Dynamic MessageRow - subscribes to dynamic context for streaming state
|
|
|
|
|
// Used for messages in the LAST turn only
|
|
|
|
|
const DynamicMessageRow = React.memo<MessageRowProps>(({
|
|
|
|
|
message,
|
|
|
|
|
onContentChange,
|
|
|
|
|
animationHandlers,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
}) => {
|
|
|
|
|
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
|
|
|
|
|
const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ChatMessage
|
|
|
|
|
message={message}
|
|
|
|
|
previousMessage={previousMessage}
|
|
|
|
|
nextMessage={nextMessage}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
animationHandlers={animationHandlers}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
turnGroupingContext={turnGroupingContext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
DynamicMessageRow.displayName = 'DynamicMessageRow';
|
|
|
|
|
|
|
|
|
|
// Inner component that renders messages with access to context hooks
|
|
|
|
|
const MessageListContent: React.FC<{
|
|
|
|
|
displayMessages: ChatMessageEntry[];
|
|
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
|
|
|
|
}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
|
|
|
|
|
const lastTurnMessageIds = useLastTurnMessageIds();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{displayMessages.map((message) => {
|
|
|
|
|
const isInLastTurn = lastTurnMessageIds.has(message.info.id);
|
|
|
|
|
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<RowComponent
|
|
|
|
|
key={message.info.id}
|
|
|
|
|
message={message}
|
|
|
|
|
onContentChange={onMessageContentChange}
|
|
|
|
|
animationHandlers={getAnimationHandlers(message.info.id)}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const MessageList: React.FC<MessageListProps> = ({
|
|
|
|
|
messages,
|
|
|
|
|
permissions,
|
2026-01-09 21:38:22 +02:00
|
|
|
questions,
|
2025-12-07 19:32:53 +02:00
|
|
|
onMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
hasMoreAbove,
|
|
|
|
|
isLoadingOlder,
|
|
|
|
|
onLoadOlder,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
}) => {
|
|
|
|
|
React.useEffect(() => {
|
2026-01-09 21:38:22 +02:00
|
|
|
if (permissions.length === 0 && questions.length === 0) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
onMessageContentChange('permission');
|
2026-01-09 21:38:22 +02:00
|
|
|
}, [permissions, questions, onMessageContentChange]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const displayMessages = React.useMemo(() => {
|
2025-12-20 02:34:01 +02:00
|
|
|
const seenIds = new Set<string>();
|
2025-12-27 02:37:29 +02:00
|
|
|
return messages
|
|
|
|
|
.filter((message) => {
|
|
|
|
|
const messageId = message.info?.id;
|
|
|
|
|
if (typeof messageId === 'string') {
|
|
|
|
|
if (seenIds.has(messageId)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
seenIds.add(messageId);
|
2025-12-20 02:34:01 +02:00
|
|
|
}
|
2025-12-27 02:37:29 +02:00
|
|
|
return true;
|
|
|
|
|
})
|
2026-01-18 18:28:49 +06:00
|
|
|
.map((message) => {
|
|
|
|
|
const filteredParts = filterSyntheticParts(message.parts);
|
|
|
|
|
// Optimization: If parts haven't changed, return the original message object.
|
|
|
|
|
// This preserves referential equality and prevents unnecessary re-renders of ChatMessage (which is memoized).
|
|
|
|
|
if (filteredParts === message.parts) {
|
|
|
|
|
return message;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...message,
|
|
|
|
|
parts: filteredParts,
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-25 19:16:26 +02:00
|
|
|
<TurnGroupingProvider messages={displayMessages}>
|
|
|
|
|
<div>
|
|
|
|
|
{hasMoreAbove && (
|
|
|
|
|
<div className="flex justify-center py-3">
|
|
|
|
|
{isLoadingOlder ? (
|
|
|
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
|
|
|
|
|
Loading…
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onLoadOlder}
|
|
|
|
|
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
Load older messages
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
<MessageListContent
|
|
|
|
|
displayMessages={displayMessages}
|
|
|
|
|
onMessageContentChange={onMessageContentChange}
|
|
|
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
{(questions.length > 0 || permissions.length > 0) && (
|
|
|
|
|
<div>
|
|
|
|
|
{questions.map((question) => (
|
|
|
|
|
<QuestionCard key={question.id} question={question} />
|
|
|
|
|
))}
|
|
|
|
|
{permissions.map((permission) => (
|
|
|
|
|
<PermissionCard key={permission.id} permission={permission} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Bottom spacer - always 10% of viewport height */}
|
|
|
|
|
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
|
|
|
|
|
</div>
|
|
|
|
|
</TurnGroupingProvider>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default React.memo(MessageList);
|