feat(ui): enable drag-and-drop attachments and image previews in chat (#390)

* feat(BottomTerminalDock): add close button next to the fullscreen toggle in the dock

* style: replace hardcoded gradient with theme value in shine text variant

* fix(header): adapt instance button for desktop only

* refactor(chat): polish sticky turn UX and message action rows for better readability

Switch to stable sticky-only turn behavior and redesign user/assistant action controls (placement, hover rules, ordering, spacing, selection-safe clamp) to reduce visual noise and improve interaction flow.

* feat(chat/message): refactor buttons in messages footer

* feat: enhance image preview functionality in chat messages

- Added a new ImagePreviewDialog component to handle image previews with navigation support.
- Updated ToolOutputDialog to utilize the new ImagePreviewDialog for displaying images.
- Modified the ToolPopupContent type to include a gallery of images and an index for the current image.
- Removed the old inline image display logic from ToolOutputDialog.
- Improved file handling in the file store, including better MIME type guessing and handling of server paths.
- Introduced a new API endpoint for handling large session message payloads, allowing for better management of multi-file attachments.
- Updated the VSCode bridge to support session message requests with appropriate headers and body handling.

* feat(proxy): implement SSE forwarding and enhance generic API request handling

* feat(chat): support submitting only queued messages

* feat: add image preview transition state

* fix: default VSCode view to draft and fixed sessions list regression
This commit is contained in:
Bohdan Triapitsyn
2026-02-11 19:28:22 +02:00
committed by GitHub
parent 39e625d8ec
commit 844562749d
25 changed files with 1621 additions and 489 deletions
@@ -181,11 +181,19 @@ const UserMessageBody: React.FC<{
);
})}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
<div className={cn(
"mt-1 flex items-center justify-end gap-2"
"absolute top-full left-0 right-0 z-10 pt-5 group/user-actions"
)}>
<div
className={cn(
"flex translate-x-5 items-center justify-end gap-1",
isMobile
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100"
)}
>
{onRevert && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
@@ -193,7 +201,7 @@ const UserMessageBody: React.FC<{
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Revert to this message"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
@@ -201,7 +209,7 @@ const UserMessageBody: React.FC<{
onRevert();
}}
>
<RiArrowGoBackLine className="h-3.5 w-3.5" />
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
@@ -214,14 +222,14 @@ const UserMessageBody: React.FC<{
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onFork();
}}
>
<RiGitBranchLine className="h-3.5 w-3.5" />
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
@@ -235,7 +243,7 @@ const UserMessageBody: React.FC<{
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
@@ -247,15 +255,16 @@ const UserMessageBody: React.FC<{
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
<RiFileCopyLine className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
</Tooltip>
)}
</div>
</div>
) : null}
</div>
@@ -872,7 +881,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const showErrorMessage = Boolean(errorMessage);
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const [isSummaryHovered, setIsSummaryHovered] = React.useState(false);
const turnDurationText = React.useMemo(() => {
if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
@@ -884,6 +892,44 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const footerButtons = (
<>
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
@@ -915,9 +961,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<TooltipContent sideOffset={6}>Start new multi-run from this answer</TooltipContent>
</Tooltip>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
{showMessageTTSButtons && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
@@ -937,49 +983,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
</>
);
<TooltipContent sideOffset={6}>{readAloudTooltip}</TooltipContent>
</Tooltip>
)}
</>
);
return (
@@ -1011,26 +1019,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<FadeInOnReveal key="summary-body">
<div
className="group/assistant-text relative break-words"
onMouseEnter={() => setIsSummaryHovered(true)}
onMouseLeave={() => setIsSummaryHovered(false)}
>
<SimpleMarkdownRenderer content={summaryBody} />
{shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div
className={cn(
"flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 focus-within:opacity-100 focus-within:pointer-events-auto",
isSummaryHovered && "opacity-100 pointer-events-auto",
)}
>
{footerButtons}
</div>
) : null}
</div>
)}
</div>
@@ -1039,16 +1040,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{!showSummaryBody && shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div className="flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
{footerButtons}
</div>
) : null}
</div>
)}
@@ -1,7 +1,8 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { RiBrainAi3Line, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
@@ -81,9 +82,285 @@ const getToolIcon = (toolName: string) => {
return <RiToolsLine className={iconClass} />;
};
const IMAGE_PREVIEW_ANIMATION_MS = 150;
const ImagePreviewDialog: React.FC<{
popup: ToolPopupContent;
onOpenChange: (open: boolean) => void;
isMobile: boolean;
}> = ({ popup, onOpenChange, isMobile }) => {
const gallery = React.useMemo(() => {
const baseImage = popup.image;
if (!baseImage) return [] as Array<{ url: string; mimeType?: string; filename?: string; size?: number }>;
const fromPopup = Array.isArray(baseImage.gallery)
? baseImage.gallery.filter((item): item is { url: string; mimeType?: string; filename?: string; size?: number } => Boolean(item?.url))
: [];
if (fromPopup.length > 0) {
return fromPopup;
}
return [{
url: baseImage.url,
mimeType: baseImage.mimeType,
filename: baseImage.filename,
size: baseImage.size,
}];
}, [popup.image]);
const [currentIndex, setCurrentIndex] = React.useState(0);
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
const [isRendered, setIsRendered] = React.useState(popup.open);
const [isVisible, setIsVisible] = React.useState(popup.open);
const [isTransitioning, setIsTransitioning] = React.useState(false);
const [viewport, setViewport] = React.useState<{ width: number; height: number }>({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
});
React.useEffect(() => {
if (!popup.open || gallery.length === 0) {
return;
}
const requestedIndex = typeof popup.image?.index === 'number' ? popup.image.index : -1;
if (requestedIndex >= 0 && requestedIndex < gallery.length) {
setCurrentIndex(requestedIndex);
return;
}
const matchingIndex = popup.image?.url
? gallery.findIndex((item) => item.url === popup.image?.url)
: -1;
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
const imageTitle = currentImage?.filename || popup.title || 'Image preview';
const hasMultipleImages = gallery.length > 1;
const showPrevious = React.useCallback(() => {
if (gallery.length <= 1) return;
setCurrentIndex((prev) => (prev - 1 + gallery.length) % gallery.length);
}, [gallery.length]);
const showNext = React.useCallback(() => {
if (gallery.length <= 1) return;
setCurrentIndex((prev) => (prev + 1) % gallery.length);
}, [gallery.length]);
React.useEffect(() => {
if (popup.open) {
setIsRendered(true);
setIsTransitioning(true);
if (typeof window === 'undefined') {
setIsVisible(true);
return;
}
const raf = window.requestAnimationFrame(() => {
setIsVisible(true);
});
const doneId = window.setTimeout(() => {
setIsTransitioning(false);
}, IMAGE_PREVIEW_ANIMATION_MS);
return () => {
window.cancelAnimationFrame(raf);
window.clearTimeout(doneId);
};
}
setIsVisible(false);
setIsTransitioning(true);
if (typeof window === 'undefined') {
setIsRendered(false);
return;
}
const timeoutId = window.setTimeout(() => {
setIsRendered(false);
setIsTransitioning(false);
}, IMAGE_PREVIEW_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [popup.open]);
React.useEffect(() => {
if (!popup.open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onOpenChange(false);
return;
}
if (event.key === 'ArrowLeft' && hasMultipleImages) {
event.preventDefault();
showPrevious();
return;
}
if (event.key === 'ArrowRight' && hasMultipleImages) {
event.preventDefault();
showNext();
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
React.useEffect(() => {
if (!popup.open || typeof window === 'undefined') {
return;
}
const onResize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight });
};
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [popup.open]);
React.useEffect(() => {
setImageNaturalSize(null);
}, [currentImage?.url]);
const imageDisplaySize = React.useMemo(() => {
const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75));
const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75));
if (!imageNaturalSize) {
return {
width: Math.round(maxWidth),
height: Math.round(maxHeight),
};
}
const widthScale = maxWidth / imageNaturalSize.width;
const heightScale = maxHeight / imageNaturalSize.height;
const scale = Math.min(widthScale, heightScale);
return {
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
};
}, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
if (!isRendered || !currentImage || typeof document === 'undefined') {
return null;
}
const content = (
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/25 backdrop-blur-md',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
onMouseDown={() => onOpenChange(false)}
/>
{hasMultipleImages && (
<>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Previous image"
>
<RiArrowLeftSLine className="h-6 w-6" />
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/25 text-foreground/90 backdrop-blur-sm hover:bg-black/35 focus:outline-none focus:ring-2 focus:ring-primary/60"
aria-label="Next image"
>
<RiArrowRightSLine className="h-6 w-6" />
</button>
</>
)}
<div
className={cn(
'absolute inset-0 flex items-center justify-center pointer-events-none',
isMobile ? 'p-2.5' : 'p-4'
)}
>
<div
className={cn(
'pointer-events-auto flex flex-col gap-2',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
style={{ width: `${imageDisplaySize.width}px` }}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1 text-foreground typography-ui-header font-semibold truncate" title={imageTitle}>
{imageTitle}
</div>
<button
type="button"
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => onOpenChange(false)}
aria-label="Close image preview"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
<img
src={currentImage.url}
alt={imageTitle}
className="block object-contain"
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
loading="lazy"
onLoad={(event) => {
const element = event.currentTarget;
const width = element.naturalWidth;
const height = element.naturalHeight;
if (width > 0 && height > 0) {
setImageNaturalSize((previous) => {
if (previous && previous.width === width && previous.height === height) {
return previous;
}
return { width, height };
});
}
}}
/>
</div>
</div>
</div>
);
return createPortal(content, document.body);
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
if (popup.image) {
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
}
return (
<Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent
@@ -385,24 +662,6 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
))}
</div>
) : null
) : popup.image ? (
<div className="p-4">
<div className="flex flex-col items-center gap-3">
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
<img
src={popup.image.url}
alt={popup.image.filename || popup.title || 'Image preview'}
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
loading="lazy"
/>
</div>
{popup.image.filename && (
<span className="typography-meta text-muted-foreground text-center">
{popup.image.filename}
</span>
)}
</div>
</div>
) : popup.content ? (
<div className="p-4">
{(() => {
@@ -19,20 +19,45 @@ const buildMentionUrl = (name: string): string => {
};
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
const CLAMP_LINES = 2;
const partWithText = part as PartWithText;
const rawText = partWithText.text;
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const [isExpanded, setIsExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
const textRef = React.useRef<HTMLDivElement>(null);
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
if (typeof window === 'undefined') {
return false;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
return element.contains(range.startContainer) || element.contains(range.endContainer);
}, []);
React.useEffect(() => {
const el = textRef.current;
if (!el || isExpanded) return;
if (!el) return;
const checkTruncation = () => {
setIsTruncated(el.scrollHeight > el.clientHeight);
if (!isExpanded) {
setIsTruncated(el.scrollHeight > el.clientHeight);
}
const styles = window.getComputedStyle(el);
const lineHeight = Number.parseFloat(styles.lineHeight);
const fontSize = Number.parseFloat(styles.fontSize);
const fallbackLineHeight = Number.isFinite(fontSize) ? fontSize * 1.4 : 20;
const resolvedLineHeight = Number.isFinite(lineHeight) ? lineHeight : fallbackLineHeight;
setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES)));
};
checkTruncation();
@@ -43,11 +68,28 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return () => resizeObserver.disconnect();
}, [textContent, isExpanded]);
const handleClick = React.useCallback(() => {
if (isTruncated || isExpanded) {
setIsExpanded((prev) => !prev);
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const element = textRef.current;
if (!element) {
return;
}
}, [isTruncated, isExpanded]);
if (hasActiveSelectionInElement(element)) {
return;
}
if (!isExpanded) {
if (isTruncated) {
setIsExpanded(true);
}
return;
}
const clickY = event.clientY - element.getBoundingClientRect().top;
if (clickY <= collapseZoneHeight) {
setIsExpanded(false);
}
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
if (!textContent || textContent.trim().length === 0) {
return null;
@@ -79,17 +121,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
};
return (
<div
className={cn(
"break-words whitespace-pre-wrap font-sans typography-markdown",
!isExpanded && "line-clamp-3",
(isTruncated || isExpanded) && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
key={part.id || `${messageId}-user-text`}
>
{renderContent()}
<div className="relative" key={part.id || `${messageId}-user-text`}>
<div
className={cn(
"break-words whitespace-pre-wrap font-sans typography-markdown",
!isExpanded && "line-clamp-2",
isTruncated && !isExpanded && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
>
{renderContent()}
</div>
</div>
);
};
@@ -20,5 +20,12 @@ export interface ToolPopupContent {
mimeType?: string;
filename?: string;
size?: number;
gallery?: Array<{
url: string;
mimeType?: string;
filename?: string;
size?: number;
}>;
index?: number;
};
}