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:
committed by
GitHub
parent
39e625d8ec
commit
844562749d
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user