Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function ChatSkeleton() {
<div className="relative flex flex-col h-full flex-1 min-h-0">
<div className="flex flex-col h-full flex-1 min-h-0 overflow-y-auto">
{/* Messages area with conversation skeleton */}
<div className="flex-1 overflow-y-visible p-8">
<div className="flex-1 overflow-y-visible p-4 sm:p-8">
<div className="max-w-[var(--chat-max-width)] mx-auto space-y-4">
{/* User message */}
<div className="flex justify-end">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,17 @@ export function ChatActions({
<TooltipTrigger asChild>
<Button
variant="ghost"
className="p-1"
className="hidden md:inline-flex p-1"
size="icon"
onClick={onRename}
aria-label={tCommon('actions.rename')}
>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tCommon('actions.rename')}</TooltipContent>
<TooltipContent side="bottom">
{tCommon('actions.rename')}
</TooltipContent>
</Tooltip>

<Tooltip>
Expand All @@ -98,7 +100,9 @@ export function ChatActions({
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{tCommon('actions.delete')}</TooltipContent>
<TooltipContent side="bottom">
{tCommon('actions.delete')}
</TooltipContent>
</Tooltip>
</HStack>
</TooltipProvider>
Expand All @@ -111,7 +115,8 @@ export function ChatActions({
description={
<>
{tChat('deleteConfirmation', { title: chat.title })}
<br /><br />
<br />
<br />
<span className="text-muted-foreground">
{tChat('deleteArchiveMessage')}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function ChatHistorySidebar({
const [editingChatId, setEditingChatId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const clickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Comment thread
Israeltheminer marked this conversation as resolved.

// Load chat threads for current user
const threadsData = useQuery(api.threads.listThreads, {});
Expand Down Expand Up @@ -149,12 +150,12 @@ export function ChatHistorySidebar({
return (
<Stack
gap={4}
className={cn('flex-[1_1_0] pb-4 px-2 overflow-y-auto', className)}
className={cn(
'flex-[1_1_0] pb-4 px-2.5 py-3.5 overflow-y-auto',
className,
)}
{...restProps}
>
<div className="text-xs font-medium text-muted-foreground tracking-[-0.072px] text-nowrap sticky top-0 bg-background z-10 pt-3">
{t('history.recent')}
</div>
<Stack gap={1}>
{!chats ? (
<div className="text-sm text-muted-foreground text-nowrap px-2">
Expand All @@ -180,31 +181,50 @@ export function ChatHistorySidebar({
)}
>
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveRename(chat._id);
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
<div
className="w-full"
style={{
transform: 'matrix(0.9, 0, 0, 0.9, 0, 0)',
transformOrigin: 'left center',
}}
onBlur={() => handleInputBlur(chat._id)}
className="flex-1 h-6 text-sm px-1.5 py-0 leading-none -mx-1.5"
/>
>
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveRename(chat._id);
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
}}
onBlur={() => handleInputBlur(chat._id)}
className="w-full h-6 px-0 py-0 leading-none focus:border-0 ring-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:ring-0 focus:outline-none shadow-none"
/>
</div>
) : (
<>
<button
onClick={() => handleChatClick(chat._id)}
onClick={() => {
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = null;
handleStartRename(chat._id, chat.title);
} else {
clickTimeoutRef.current = setTimeout(() => {
clickTimeoutRef.current = null;
handleChatClick(chat._id);
}, 250);
}
}}
className="flex-1 truncate text-left cursor-pointer"
>
{chat.title}
</button>
Comment thread
Israeltheminer marked this conversation as resolved.
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<div className="opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<ChatActions
chat={{ id: chat._id, title: chat.title }}
currentChatId={currentThreadId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,21 +486,43 @@ 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();
}
}, [optimisticMessage?.content]);

useEffect(() => {
if (
optimisticMessage?.content &&
uiMessages !== undefined &&
threadMessages?.some((m) => {
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)
);
})
) {
setOptimisticMessage(null);
});

if (matchingMessage) {
setOptimisticMessage(null);
optimisticMessageTimestampRef.current = null;
}
}
}, [
uiMessages,
Expand Down Expand Up @@ -684,7 +706,7 @@ export function ChatInterface({
<div
ref={contentRef}
className={cn(
'flex-1 overflow-y-visible p-8',
'flex-1 overflow-y-visible p-4 sm:p-8',
!threadId &&
threadMessages?.length === 0 &&
!userDraftMessage &&
Expand Down Expand Up @@ -800,9 +822,11 @@ 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. Tools are actively executing (even if text has started streaming) */}
2. Streaming started but no text content yet (prevents blank gap), OR
3. Tools are actively executing (even if text has started streaming) */}
<div ref={aiResponseAreaRef}>
{((isPending && userDraftMessage && !streamingMessage) ||
{((isPending && !streamingMessage) ||
(streamingMessage && !streamingMessage.text) ||
hasActiveTools) && (
<ThinkingAnimation
threadId={threadId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ interface IncrementalMarkdownProps {
components?: Record<string, ComponentType<any>>;
/** Additional CSS class */
className?: string;
/** Whether to show the typing cursor */
showCursor?: boolean;
}

// ============================================================================
Expand Down Expand Up @@ -109,6 +111,19 @@ const StableMarkdown = memo(
// STREAMING MARKDOWN COMPONENT
// ============================================================================

/**
* Animated cursor that appears during typing.
* Uses CSS animation for smooth blinking without JS overhead.
*/
const TypewriterCursor = memo(function TypewriterCursor() {
return (
<span
className="inline-block w-0.5 h-[1.1em] bg-current ml-0.5 align-text-bottom animate-cursor-blink"
aria-hidden="true"
/>
);
});

/**
* Renders the streaming (incomplete) portion of markdown.
* Uses CSS mask for smooth character reveal animation.
Expand All @@ -118,25 +133,82 @@ const StreamingMarkdown = memo(
content,
revealedLength,
components,
showCursor,
}: {
content: string;
revealedLength: number;
// biome-ignore lint/suspicious/noExplicitAny: Required for react-markdown component types
components?: Record<string, ComponentType<any>>;
showCursor?: boolean;
}) {
if (!content) return null;
const revealedContent = content ? content.slice(0, revealedLength) : '';

// Create components that inject cursor at the end of the last element
// We track render order and append cursor to the last rendered block element
const componentsWithCursor = useMemo(() => {
if (!showCursor) return components;

// Helper to wrap children with cursor at the end
const withCursor = (children: React.ReactNode) => (
<>
{children}
<TypewriterCursor />
</>
);

// Create wrapper components for block elements that might be last
const createCursorWrapper = (
Tag: 'p' | 'li' | 'td' | 'th' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6',
// biome-ignore lint/suspicious/noExplicitAny: Required for react-markdown component types
CustomComponent?: ComponentType<any>,
) => {
// biome-ignore lint/suspicious/noExplicitAny: Required for react-markdown component types
return function CursorWrapper({ node, children, ...props }: any) {
// Check if this is the last element by looking at node position
const isLastElement =
node?.position?.end?.offset === revealedContent.length ||
// Fallback: check if we're near the end (within whitespace)
(node?.position?.end?.offset &&
revealedContent.slice(node.position.end.offset).trim() === '');

// Only parse and render the revealed portion
// This keeps parsing work minimal during animation
const revealedContent = content.slice(0, revealedLength);
if (CustomComponent) {
return (
<CustomComponent {...props}>
{isLastElement ? withCursor(children) : children}
</CustomComponent>
);
}

const Element = Tag;
return (
<Element {...props}>
{isLastElement ? withCursor(children) : children}
</Element>
);
};
};

return {
...components,
p: createCursorWrapper('p', components?.p),
li: createCursorWrapper('li', components?.li),
td: createCursorWrapper('td', components?.td),
th: createCursorWrapper('th', components?.th),
h1: createCursorWrapper('h1', components?.h1),
h2: createCursorWrapper('h2', components?.h2),
h3: createCursorWrapper('h3', components?.h3),
h4: createCursorWrapper('h4', components?.h4),
h5: createCursorWrapper('h5', components?.h5),
h6: createCursorWrapper('h6', components?.h6),
};
}, [components, showCursor, revealedContent.length]);

if (!content) return null;

return (
<div className="streaming-text-container">
{/* Hidden layer: Full content establishes layout dimensions */}
<div
className="streaming-text-layout-reference"
aria-hidden="true"
>
<div className="streaming-text-layout-reference" aria-hidden="true">
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
Expand All @@ -151,7 +223,7 @@ const StreamingMarkdown = memo(
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
components={componentsWithCursor}
>
{revealedContent}
</Markdown>
Expand All @@ -160,10 +232,11 @@ const StreamingMarkdown = memo(
);
},
(prevProps, nextProps) => {
// Re-render when revealed length or content changes
// Re-render when revealed length, content, or cursor state changes
return (
prevProps.content === nextProps.content &&
prevProps.revealedLength === nextProps.revealedLength
prevProps.revealedLength === nextProps.revealedLength &&
prevProps.showCursor === nextProps.showCursor
);
},
);
Expand Down Expand Up @@ -194,6 +267,7 @@ export function IncrementalMarkdown({
isStreaming,
components,
className,
showCursor,
}: IncrementalMarkdownProps) {
// Split content at anchor position
const { stableContent, streamingContent, streamingRevealLength } =
Expand Down Expand Up @@ -237,6 +311,7 @@ export function IncrementalMarkdown({
content={streamingContent}
revealedLength={streamingRevealLength}
components={components}
showCursor={showCursor}
/>
)}
</div>
Expand Down
Loading