diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 0253235b..a6df84b8 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -1,4 +1,5 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" +import { Portal } from "solid-js/web" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import { partHasRenderableText } from "../types/message" @@ -43,6 +44,57 @@ export default function MessageItem(props: MessageItemProps) { const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + type ImagePreviewState = { + url: string + name: string + anchor: HTMLElement + } + + const [imagePreview, setImagePreview] = createSignal(null) + + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + + const getImagePreviewPosition = () => { + const state = imagePreview() + if (!state) return null + + const rect = state.anchor.getBoundingClientRect() + + // Outer box: 320px image + 8px padding on each side. + const padding = 8 + const maxImage = 320 + const gap = 8 + const chrome = padding * 2 + const outerWidth = maxImage + chrome + const outerHeight = maxImage + chrome + + const viewportW = window.innerWidth + const viewportH = window.innerHeight + + const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8)) + + const fitsAbove = rect.top >= outerHeight + gap + 8 + const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap + const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8)) + + return { left, top } + } + + createEffect(() => { + const active = imagePreview() + if (!active) return + + // If the user scrolls (message stream scroll container) or resizes, the anchor moves. + // Hide the popover to avoid showing it in the wrong place. + const hide = () => setImagePreview(null) + window.addEventListener("scroll", hide, true) + window.addEventListener("resize", hide) + onCleanup(() => { + window.removeEventListener("scroll", hide, true) + window.removeEventListener("resize", hide) + }) + }) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id)) let topRowEl: HTMLDivElement | undefined @@ -178,6 +230,11 @@ export default function MessageItem(props: MessageItemProps) { } } + const showImagePreview = (anchor: HTMLElement, url: string, name: string) => { + if (!url) return + setImagePreview({ anchor, url, name }) + } + const errorMessage = () => { const info = props.messageInfo if (!info || info.role !== "assistant" || !info.error) return null @@ -521,6 +578,12 @@ export default function MessageItem(props: MessageItemProps) {
{ + if (!isImage) return + const el = e.currentTarget as HTMLElement + showImagePreview(el, attachment.url || "", name) + }} + onMouseLeave={() => setImagePreview(null)} > @@ -549,11 +612,6 @@ export default function MessageItem(props: MessageItemProps) { - -
- {name} -
-
) }} @@ -561,6 +619,31 @@ export default function MessageItem(props: MessageItemProps) { + + {(stateAccessor) => { + const state = stateAccessor() + const pos = () => getImagePreviewPosition() + return ( + + + {(posAccessor) => { + const coords = posAccessor() + return ( + + ) + }} + + + ) + }} + +
{t("messageItem.status.sending")} diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 14bd58da..5970b11a 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -203,6 +203,27 @@ border-top: 1px solid var(--border-base); } +/* Image attachment preview popover. + Rendered via a Portal to avoid being clipped by the message stream scroller. */ +.attachment-image-popover { + position: fixed; + padding: 8px; + background-color: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: 10px; + box-shadow: var(--popover-shadow); + z-index: 1000; + pointer-events: none; +} + +.attachment-image-popover img { + display: block; + max-width: 320px; + max-height: 320px; + border-radius: 8px; + object-fit: contain; +} + .message-error { @apply text-xs mt-1; color: var(--status-error);