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
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default async function LogInPage({
return (
<Suspense
fallback={
<AuthFormSkeleton title={t('login.title')} showMicrosoftButton />
<AuthFormSkeleton title={t('login.loginTitle')} showMicrosoftButton />
}
>
<LogInContent searchParams={searchParams} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@ export default async function SignUpPage() {
return (
<Suspense
fallback={
<AuthFormSkeleton
title={t('signup.title')}
showPasswordRequirements
showMicrosoftButton
/>
<AuthFormSkeleton title={t('signup.signupTitle')} showMicrosoftButton />
}
>
<SignUpContent />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function ChatHeader({ organizationId }: ChatHeaderProps) {
initial={{ x: -HISTORY_WIDTH }}
animate={{ x: isHistoryOpen ? 0 : -HISTORY_WIDTH }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
style={{ left: `calc(${HISTORY_WIDTH}px + 0.375rem)` }}
style={{ left: `${HISTORY_WIDTH}px` }}
className="absolute top-0 flex items-center px-2 sm:px-5 py-2 bg-background rounded-br-xl"
>
<TooltipProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,32 @@ function truncate(str: string, maxLength: number): string {
return str.slice(0, maxLength - 1) + '…';
}

/**
* Checks if any message in the list matches the optimistic message.
* Used to detect when the server has confirmed receipt of the user's message.
*/
function findMatchingServerMessage(
messages: ChatMessage[] | undefined,
optimisticContent: string,
optimisticTimestamp: number,
): boolean {
if (!messages) return false;

return messages.some((m) => {
if (m.role !== 'user') return false;

// Check timestamp - only match messages created after user clicked send
const messageTime = m._creationTime || m.timestamp.getTime();
if (messageTime < optimisticTimestamp) return false;

// Check content match
return (
m.content === optimisticContent ||
m.content.startsWith(optimisticContent)
);
});
}

interface ThinkingAnimationProps {
threadId?: string;
streamingMessage?: UIMessage;
Expand Down Expand Up @@ -327,36 +353,98 @@ export function ChatInterface({
// Convert UIMessage to ChatMessage format for compatibility
// Memoize to prevent unnecessary re-renders when typing
const threadMessages: ChatMessage[] = useMemo(() => {
return (uiMessages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => {
// Extract file parts (images) from UIMessage.parts
const fileParts = (m.parts || [])
.filter((p): p is FilePart => p.type === 'file')
.map((p) => ({
type: 'file' as const,
mediaType: p.mediaType,
filename: p.filename,
url: p.url,
}));
// Track seen message IDs to prevent duplicates
// useUIMessages can return multiple entries with different keys for the same message
const seenMessageIds = new Set<string>();
// Track seen assistant message content to prevent content duplicates
// This handles the case where streaming and persisted messages have different IDs/stepOrders
// but contain the same content (Issue #184)
const seenAssistantContent = new Set<string>();

// Find the minimum order of user messages to detect pagination boundary
// Stream messages with order < minUserOrder are orphaned (their user message was paginated out)
const userMessages = (uiMessages || []).filter((m) => m.role === 'user');
const minUserOrder =
userMessages.length > 0
? Math.min(...userMessages.map((m) => m.order))
: 0;

const filtered = (uiMessages || []).filter((m) => {
if (m.role !== 'user' && m.role !== 'assistant') return false;

// Filter out orphaned stream messages whose user message was paginated out
// These are assistant messages with order < minUserOrder from stream source
// Issue #184: When pagination kicks in, old stream messages stay in state
if (m.role === 'assistant' && m.order < minUserOrder) {
return false;
}

return {
// id: document ID for metadata lookup
// key: React key with step/part suffix for unique rendering
id: m.id,
key: m.key,
content: m.text,
role: m.role as 'user' | 'assistant',
timestamp: new Date(m._creationTime),
fileParts: fileParts.length > 0 ? fileParts : undefined,
_creationTime: m._creationTime,
// Mark messages with 'streaming' status as actively streaming
// This triggers the TypewriterText animation in MessageBubble
isStreaming: m.status === 'streaming',
};
});
// Deduplicate ALL messages by id (not just user messages)
if (seenMessageIds.has(m.id)) {
return false;
}
seenMessageIds.add(m.id);

// For assistant messages, also dedupe by content
// This catches duplicates with different IDs but same content
if (m.role === 'assistant' && m.text) {
// Use first 200 chars as content key to handle minor differences
const contentKey = m.text.substring(0, 200);
if (seenAssistantContent.has(contentKey)) {
return false;
}
seenAssistantContent.add(contentKey);
}

return true;
});

return filtered.map((m) => {
// Extract file parts (images) from UIMessage.parts
const fileParts = (m.parts || [])
.filter((p): p is FilePart => p.type === 'file')
.map((p) => ({
type: 'file' as const,
mediaType: p.mediaType,
filename: p.filename,
url: p.url,
}));

return {
// id: document ID for metadata lookup
// key: React key with step/part suffix for unique rendering
id: m.id,
key: m.key,
content: m.text,
role: m.role as 'user' | 'assistant',
timestamp: new Date(m._creationTime),
fileParts: fileParts.length > 0 ? fileParts : undefined,
_creationTime: m._creationTime,
// Mark messages with 'streaming' status as actively streaming
// This triggers the TypewriterText animation in MessageBubble
isStreaming: m.status === 'streaming',
};
});
}, [uiMessages]);

// Check if a server message matching the optimistic message already exists
// This prevents showing duplicates during the brief window between
// server message arrival and useEffect clearing the optimistic state
const hasMatchingServerMessage = useMemo(() => {
if (!optimisticMessage?.content) {
return false;
}
return findMatchingServerMessage(
threadMessages,
optimisticMessage.content,
optimisticMessage.timestamp,
);
}, [
threadMessages,
optimisticMessage?.content,
optimisticMessage?.timestamp,
]);
Comment thread
Israeltheminer marked this conversation as resolved.

// Find if there's currently a streaming assistant message
const streamingMessage = uiMessages?.find(
(m) => m.role === 'assistant' && m.status === 'streaming',
Expand Down Expand Up @@ -486,48 +574,25 @@ export function ChatInterface({
}, [streamingMessage, isPending, setIsPending]);

// Clear optimistic message when it appears in actual messages
// Track the timestamp when optimistic message was created to avoid matching older messages
const optimisticMessageTimestampRef = useRef<number | null>(null);

// Update timestamp when optimistic message is set
useEffect(() => {
if (optimisticMessage?.content) {
optimisticMessageTimestampRef.current = Date.now();
if (!optimisticMessage?.content || uiMessages === undefined) {
return;
}
}, [optimisticMessage?.content]);

useEffect(() => {
if (
optimisticMessage?.content &&
uiMessages !== undefined &&
optimisticMessageTimestampRef.current
) {
// Find user messages created after the optimistic message was set
// Use a small buffer (500ms before) to account for timing differences
const searchStartTime = optimisticMessageTimestampRef.current - 500;

const matchingMessage = threadMessages?.find((m) => {
if (m.role !== 'user') return false;
// Only consider messages created around or after the optimistic message
const messageTime = m._creationTime || m.timestamp.getTime();
if (messageTime < searchStartTime) return false;
// Check for exact match OR if the message starts with the optimistic content
// (handles case where images are appended as markdown)
return (
m.content === optimisticMessage.content ||
m.content.startsWith(optimisticMessage.content)
);
});
const hasMatch = findMatchingServerMessage(
threadMessages,
optimisticMessage.content,
optimisticMessage.timestamp,
);

if (matchingMessage) {
setOptimisticMessage(null);
optimisticMessageTimestampRef.current = null;
}
if (hasMatch) {
setOptimisticMessage(null);
}
}, [
uiMessages,
threadMessages,
optimisticMessage?.content,
optimisticMessage?.timestamp,
setOptimisticMessage,
]);

Expand Down Expand Up @@ -625,7 +690,13 @@ export function ChatInterface({
attachments,
};

setOptimisticMessage({ content: sanitizedContent, threadId, attachments });
const optimisticTimestamp = Date.now();
setOptimisticMessage({
content: sanitizedContent,
threadId,
attachments,
timestamp: optimisticTimestamp,
});
setInputValue('');

// Set pending immediately to show thinking animation right away
Expand Down Expand Up @@ -654,6 +725,7 @@ export function ChatInterface({
content: sanitizedContent,
threadId: newThreadId,
attachments,
timestamp: optimisticTimestamp,
});
router.push(`/dashboard/${organizationId}/chat/${newThreadId}`, {
scroll: false,
Expand Down Expand Up @@ -805,7 +877,7 @@ export function ChatInterface({
);
}
})}
{userDraftMessage && (
{userDraftMessage && !hasMatchingServerMessage && (
<MessageBubble
key={'user-draft'}
message={{
Expand All @@ -822,11 +894,16 @@ export function ChatInterface({
{/* AI Response area - ref used for scroll positioning */}
{/* Show ThinkingAnimation when:
1. Waiting for AI response (isPending, no streaming yet), OR
2. Streaming started but no text content yet (prevents blank gap), OR
3. Tools are actively executing (even if text has started streaming) */}
2. Message is streaming but has no text content yet, OR
3. Tools are actively executing (even if text has started streaming)

Note: We check status === 'streaming' explicitly to ensure the indicator
stays visible during gaps in tool state transitions (when one tool completes
but no text has been output yet). */}
<div ref={aiResponseAreaRef}>
{((isPending && !streamingMessage) ||
(streamingMessage && !streamingMessage.text) ||
(streamingMessage?.status === 'streaming' &&
!streamingMessage.text) ||
hasActiveTools) && (
<ThinkingAnimation
threadId={threadId}
Expand Down
1 change: 1 addition & 0 deletions services/platform/app/(app)/dashboard/[id]/chat/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface OptimisticMessage {
content: string;
threadId?: string;
attachments?: FileAttachment[];
timestamp: number;
}

interface ChatLayoutContextType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ export function ConversationPanel({
}}
/>
</div>
<div className="pt-2 mx-auto max-w-3xl flex-1 w-full">
<div className="pt-2 mx-auto max-w-3xl flex-1 w-full px-4">
{messageGroups.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-sm">{tConversations('panel.noMessages')}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function Message({ message }: MessageProps) {
<div className="relative">
<div
className={cn(
'max-w-[40rem] relative overflow-x-auto p-2 rounded-2xl shadow-sm mb-2',
'max-w-[40rem] relative overflow-x-auto rounded-2xl shadow-sm mb-2',
message.isCustomer
? 'bg-white text-foreground'
: 'bg-muted text-foreground',
Expand Down
Loading