| 1 | import { useState } from "react"; |
| 2 | import { ImageIcon } from "lucide-react"; |
| 3 | import { useTranslation } from "react-i18next"; |
| 4 | |
| 5 | import { ImageLightbox } from "@/components/ImageLightbox"; |
| 6 | import { MarkdownText } from "@/components/MarkdownText"; |
| 7 | import { QuestionCards } from "@/components/thread/QuestionCards"; |
| 8 | import { MemoryReviewCard } from "@/components/thread/MemoryReviewCard"; |
| 9 | import type { NextContinuousControl } from "@/components/thread/MemoryReviewCard"; |
| 10 | import { cn } from "@/lib/utils"; |
| 11 | import type { |
| 12 | MemoryReview, |
| 13 | MemoryWorkspaceAsset, |
| 14 | ShotMemoryAssetCreate, |
| 15 | UIImage, |
| 16 | UIMessage, |
| 17 | UIVideo, |
| 18 | } from "@/lib/types"; |
| 19 | |
| 20 | interface MessageBubbleProps { |
| 21 | message: UIMessage; |
| 22 | onAnswerQuestion?: (messageId: string, cardId: string, value: string) => void; |
| 23 | /** Agent turn finished (stream_end resuming:false); cards become tappable. */ |
| 24 | questionsReady?: boolean; |
| 25 | onMemoryReviewAction?: ( |
| 26 | review: MemoryReview, |
| 27 | action: "approve" | "reselect" | "manual_select" | "select_mode", |
| 28 | memoryId?: string, |
| 29 | timestampSec?: number, |
| 30 | selectionMode?: "manual" | "vlm", |
| 31 | retainedMemoryIds?: string[], |
| 32 | ) => void | Promise<void>; |
| 33 | memoryAssets?: MemoryWorkspaceAsset[]; |
| 34 | onCreateMemoryAsset?: ( |
| 35 | shotId: number, |
| 36 | asset: ShotMemoryAssetCreate, |
| 37 | ) => Promise<void>; |
| 38 | /** Resolve Shot a Memory → Shot a+1 continuous control; null hides switch. */ |
| 39 | getNextContinuous?: ( |
| 40 | review: MemoryReview, |
| 41 | ) => NextContinuousControl | null; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Render a single message. Following agent-chat-ui: user turns are a rounded |
| 46 | * "pill" right-aligned with a muted fill; assistant turns render as bare |
| 47 | * markdown so prose/code read like a document rather than a chat bubble. |
| 48 | * Each turn fades+slides in for a touch of motion polish. |
| 49 | * |
| 50 | * Trace rows are not rendered in the thread UI. |
| 51 | */ |
| 52 | export function MessageBubble({ |
| 53 | message, |
| 54 | onAnswerQuestion, |
| 55 | questionsReady = true, |
| 56 | onMemoryReviewAction, |
| 57 | memoryAssets = [], |
| 58 | onCreateMemoryAsset, |
| 59 | getNextContinuous, |
| 60 | }: MessageBubbleProps) { |
| 61 | const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; |
| 62 | if (message.kind === "trace") { |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | if (message.role === "user") { |
| 67 | const images = message.images ?? []; |
| 68 | const hasImages = images.length > 0; |
| 69 | const hasText = message.content.trim().length > 0; |
| 70 | return ( |
| 71 | <div |
| 72 | className={cn( |
| 73 | "group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5", |
| 74 | baseAnim, |
| 75 | )} |
| 76 | > |
| 77 | {hasImages ? <UserImages images={images} /> : null} |
| 78 | {hasText ? ( |
| 79 | <p |
| 80 | className={cn( |
| 81 | "ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2", |
| 82 | "text-left text-[15px]/[1.8] whitespace-pre-wrap break-words", |
| 83 | )} |
| 84 | > |
| 85 | {message.content} |
| 86 | </p> |
| 87 | ) : null} |
| 88 | </div> |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | const empty = message.content.trim().length === 0; |
| 93 | const showTypingDots = message.turnWaiting === true && empty; |
| 94 | const videos = message.videos ?? []; |
| 95 | const hasVideos = videos.length > 0; |
| 96 | const hasQuestions = (message.questions?.length ?? 0) > 0; |
| 97 | |
| 98 | // message.questions = [ |
| 99 | // { |
| 100 | // id: "1", |
| 101 | // question: "What is the capital of France?", |
| 102 | // options: [{ label: "Paris" }, { label: "London" }, { label: "Berlin" }], |
| 103 | // allowCustom: false, |
| 104 | // answered: null, |
| 105 | // }, |
| 106 | // ]; |
| 107 | |
| 108 | return ( |
| 109 | <div |
| 110 | className={cn("w-full text-sm", baseAnim)} |
| 111 | style={{ lineHeight: "var(--cjk-line-height)" }} |
| 112 | > |
| 113 | {showTypingDots ? ( |
| 114 | <TypingDots /> |
| 115 | ) : ( |
| 116 | <> |
| 117 | {!empty ? <MarkdownText>{message.content}</MarkdownText> : null} |
| 118 | {hasVideos ? <AssistantVideos videos={videos} /> : null} |
| 119 | {hasQuestions && !message.isStreaming ? ( |
| 120 | <QuestionCards |
| 121 | cards={message.questions!} |
| 122 | disabled={!questionsReady} |
| 123 | onAnswer={(cardId, value) => |
| 124 | onAnswerQuestion?.(message.id, cardId, value) |
| 125 | } |
| 126 | /> |
| 127 | ) : null} |
| 128 | {message.memoryReview ? ( |
| 129 | <MemoryReviewCard |
| 130 | review={message.memoryReview} |
| 131 | memoryAssets={memoryAssets} |
| 132 | onCreateMemoryAsset={onCreateMemoryAsset} |
| 133 | nextContinuous={ |
| 134 | getNextContinuous?.(message.memoryReview) ?? null |
| 135 | } |
| 136 | onApprove={ |
| 137 | onMemoryReviewAction |
| 138 | ? (retainedMemoryIds) => |
| 139 | onMemoryReviewAction( |
| 140 | message.memoryReview!, |
| 141 | "approve", |
| 142 | undefined, |
| 143 | undefined, |
| 144 | undefined, |
| 145 | retainedMemoryIds, |
| 146 | ) |
| 147 | : undefined |
| 148 | } |
| 149 | onReselect={ |
| 150 | onMemoryReviewAction |
| 151 | ? (memoryId) => |
| 152 | onMemoryReviewAction( |
| 153 | message.memoryReview!, |
| 154 | "reselect", |
| 155 | memoryId, |
| 156 | ) |
| 157 | : undefined |
| 158 | } |
| 159 | onManualSelect={ |
| 160 | onMemoryReviewAction |
| 161 | ? (memoryId, timestampSec) => |
| 162 | onMemoryReviewAction( |
| 163 | message.memoryReview!, |
| 164 | "manual_select", |
| 165 | memoryId, |
| 166 | timestampSec, |
| 167 | ) |
| 168 | : undefined |
| 169 | } |
| 170 | onSelectMode={ |
| 171 | onMemoryReviewAction |
| 172 | ? (selectionMode) => |
| 173 | onMemoryReviewAction( |
| 174 | message.memoryReview!, |
| 175 | "select_mode", |
| 176 | undefined, |
| 177 | undefined, |
| 178 | selectionMode, |
| 179 | ) |
| 180 | : undefined |
| 181 | } |
| 182 | /> |
| 183 | ) : null} |
| 184 | {message.isStreaming && !empty ? <StreamCursor /> : null} |
| 185 | </> |
| 186 | )} |
| 187 | </div> |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Right-aligned preview row for images attached to a user turn. |
| 193 | * |
| 194 | * Visual follows agent-chat-ui: a single wrapping row of fixed-size square |
| 195 | * thumbnails that stay modest next to the text pill regardless of how many |
| 196 | * images are attached. |
| 197 | * |
| 198 | * The URL is expected to be a self-contained ``data:`` URL (the Composer |
| 199 | * hands the normalized base64 payload to the optimistic bubble so that the |
| 200 | * preview survives React StrictMode double-mount — blob URLs would be |
| 201 | * revoked by the Composer's cleanup before remount). Historical replays |
| 202 | * have no URL (the backend strips data URLs before persisting), so we |
| 203 | * render a labelled placeholder tile instead of a broken ``<img>``. |
| 204 | */ |
| 205 | function UserImages({ images }: { images: UIImage[] }) { |
| 206 | const { t } = useTranslation(); |
| 207 | // Only real-URL images can open in the lightbox; historical-replay |
| 208 | // placeholders (no URL) have nothing to zoom into. |
| 209 | const viewable = images |
| 210 | .map((img, i) => ({ img, i })) |
| 211 | .filter(({ img }) => typeof img.url === "string" && img.url.length > 0); |
| 212 | const viewableImages = viewable.map(({ img }) => img); |
| 213 | const originalToViewable = new Map<number, number>( |
| 214 | viewable.map(({ i }, v) => [i, v]), |
| 215 | ); |
| 216 | |
| 217 | const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); |
| 218 | |
| 219 | return ( |
| 220 | <> |
| 221 | <div className="ml-auto flex flex-wrap items-end justify-end gap-2"> |
| 222 | {images.map((img, i) => ( |
| 223 | <UserImageCell |
| 224 | key={`${img.url ?? "placeholder"}-${i}`} |
| 225 | image={img} |
| 226 | placeholderLabel={t("message.imageAttachment")} |
| 227 | openLabel={t("lightbox.open")} |
| 228 | onOpen={ |
| 229 | originalToViewable.has(i) |
| 230 | ? () => setLightboxIndex(originalToViewable.get(i)!) |
| 231 | : undefined |
| 232 | } |
| 233 | /> |
| 234 | ))} |
| 235 | </div> |
| 236 | <ImageLightbox |
| 237 | images={viewableImages} |
| 238 | index={lightboxIndex} |
| 239 | onIndexChange={setLightboxIndex} |
| 240 | onOpenChange={(open) => { |
| 241 | if (!open) setLightboxIndex(null); |
| 242 | }} |
| 243 | /> |
| 244 | </> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | function UserImageCell({ |
| 249 | image, |
| 250 | placeholderLabel, |
| 251 | openLabel, |
| 252 | onOpen, |
| 253 | }: { |
| 254 | image: UIImage; |
| 255 | placeholderLabel: string; |
| 256 | openLabel: string; |
| 257 | onOpen?: () => void; |
| 258 | }) { |
| 259 | const hasUrl = typeof image.url === "string" && image.url.length > 0; |
| 260 | const tileClasses = cn( |
| 261 | "relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40", |
| 262 | "shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]", |
| 263 | ); |
| 264 | |
| 265 | if (hasUrl && onOpen) { |
| 266 | return ( |
| 267 | <button |
| 268 | type="button" |
| 269 | onClick={onOpen} |
| 270 | aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel} |
| 271 | title={image.name ?? undefined} |
| 272 | className={cn( |
| 273 | tileClasses, |
| 274 | "cursor-zoom-in transition-transform duration-150 motion-reduce:transition-none", |
| 275 | "hover:scale-[1.02] hover:ring-2 hover:ring-primary/30", |
| 276 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50", |
| 277 | )} |
| 278 | > |
| 279 | <img |
| 280 | src={image.url} |
| 281 | alt={image.name ?? ""} |
| 282 | loading="lazy" |
| 283 | decoding="async" |
| 284 | draggable={false} |
| 285 | className="h-full w-full object-cover" |
| 286 | /> |
| 287 | </button> |
| 288 | ); |
| 289 | } |
| 290 | |
| 291 | return ( |
| 292 | <div className={tileClasses} title={image.name ?? undefined}> |
| 293 | <div |
| 294 | className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground" |
| 295 | aria-label={placeholderLabel} |
| 296 | > |
| 297 | <ImageIcon className="h-4 w-4 flex-none" aria-hidden /> |
| 298 | <span className="line-clamp-2 text-center leading-tight"> |
| 299 | {image.name ?? placeholderLabel} |
| 300 | </span> |
| 301 | </div> |
| 302 | </div> |
| 303 | ); |
| 304 | } |
| 305 | |
| 306 | function AssistantVideos({ videos }: { videos: UIVideo[] }) { |
| 307 | return ( |
| 308 | <div className="mt-3 flex w-full flex-col gap-3"> |
| 309 | {videos.map((video, index) => ( |
| 310 | <figure |
| 311 | key={`${video.url}-${index}`} |
| 312 | className={cn( |
| 313 | "overflow-hidden rounded-2xl border border-border/60 bg-card/80", |
| 314 | "shadow-[0_18px_45px_-32px_rgba(0,0,0,0.5)]", |
| 315 | )} |
| 316 | > |
| 317 | <video |
| 318 | controls |
| 319 | playsInline |
| 320 | preload="metadata" |
| 321 | src={video.url} |
| 322 | className="block max-h-[26rem] w-full bg-black" |
| 323 | /> |
| 324 | {video.name ? ( |
| 325 | <figcaption className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground"> |
| 326 | {video.name} |
| 327 | </figcaption> |
| 328 | ) : null} |
| 329 | </figure> |
| 330 | ))} |
| 331 | </div> |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | /** Blinking cursor appended at the end of streaming text. */ |
| 336 | function StreamCursor() { |
| 337 | const { t } = useTranslation(); |
| 338 | return ( |
| 339 | <span |
| 340 | aria-label={t("message.streaming")} |
| 341 | className={cn( |
| 342 | "ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle", |
| 343 | "rounded-sm bg-foreground/70 animate-pulse", |
| 344 | )} |
| 345 | /> |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | /** Pre-token-arrival placeholder: three bouncing dots. */ |
| 350 | function TypingDots() { |
| 351 | const { t } = useTranslation(); |
| 352 | const label = t("message.assistantTyping"); |
| 353 | return ( |
| 354 | <div |
| 355 | aria-label={label} |
| 356 | className="flex items-center gap-3 py-2 text-xs text-muted-foreground" |
| 357 | > |
| 358 | <style>{` |
| 359 | @keyframes thinking-shimmer { |
| 360 | 0% { background-position: -200% 0; } |
| 361 | 100% { background-position: 200% 0; } |
| 362 | } |
| 363 | `}</style> |
| 364 | <span className="shrink-0">{label}</span> |
| 365 | <div |
| 366 | className="h-1.5 min-w-20 max-w-44 flex-1 rounded-full" |
| 367 | style={{ |
| 368 | background: |
| 369 | "linear-gradient(90deg, transparent 0%, hsl(var(--foreground) / 0.03) 15%, hsl(var(--foreground) / 0.10) 50%, hsl(var(--foreground) / 0.03) 85%, transparent 100%)", |
| 370 | backgroundSize: "200% 100%", |
| 371 | animation: "thinking-shimmer 1.8s ease-in-out infinite", |
| 372 | }} |
| 373 | /> |
| 374 | </div> |
| 375 | ); |
| 376 | } |
| 377 |