feat(webui): support image uploads in composer and message bubbles

This commit is contained in:
Xubin Ren
2026-04-23 00:07:27 +08:00
committed by Xubin Ren
parent c1e7aa5504
commit 61a28c2c0a
39 changed files with 3670 additions and 124 deletions
+199
View File
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { UIImage } from "@/lib/types";
interface ImageLightboxProps {
images: UIImage[];
index: number | null;
onIndexChange: (index: number) => void;
onOpenChange: (open: boolean) => void;
}
/**
* Modal image viewer. Uses the Radix Dialog primitives directly so we can
* fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg,
* which is much too small for a photo preview).
*
* Implementation notes:
* - `translate3d` + `will-change: transform` promote the image to a GPU
* compositing layer so open/swap stays at 60 FPS on long threads.
* - Adjacent images are rendered in hidden `<img>` tags so the browser
* decodes them eagerly; pressing left/right feels instant.
* - Radix handles `Escape` + focus trapping; we only wire up ←/→ + Home/End.
* - Respects `prefers-reduced-motion` by dropping the fade + zoom-in
* keyframes via `motion-reduce:*` variants.
*/
export function ImageLightbox({
images,
index,
onIndexChange,
onOpenChange,
}: ImageLightboxProps) {
const { t } = useTranslation();
const open = index !== null;
const total = images.length;
const current = index !== null ? images[index] : null;
const go = useCallback(
(delta: number) => {
if (index === null || total <= 1) return;
const next = (index + delta + total) % total;
onIndexChange(next);
},
[index, onIndexChange, total],
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "Home") {
e.preventDefault();
onIndexChange(0);
} else if (e.key === "End") {
e.preventDefault();
onIndexChange(total - 1);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go, onIndexChange, open, total]);
// Neighbours we want the browser to decode eagerly.
const preload = useMemo(() => {
if (index === null || total <= 1) return [] as UIImage[];
const prev = images[(index - 1 + total) % total];
const next = images[(index + 1) % total];
return [prev, next].filter((i) => i && i.url);
}, [images, index, total]);
if (!current || !current.url) return null;
const hasMany = total > 1;
const counter = hasMany ? `${index! + 1} / ${total}` : null;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
)}
/>
<DialogPrimitive.Content
aria-label={current.name ?? t("lightbox.title")}
className={cn(
"fixed inset-0 z-50 flex items-center justify-center",
"focus:outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
)}
>
<DialogPrimitive.Title className="sr-only">
{current.name ?? t("lightbox.title")}
</DialogPrimitive.Title>
<div
className="relative flex max-h-[92vh] max-w-[94vw] items-center justify-center"
style={{
transform: "translate3d(0,0,0)",
willChange: "transform",
}}
>
<img
key={current.url}
src={current.url}
alt={current.name ?? ""}
decoding="async"
draggable={false}
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
/>
</div>
{hasMany ? (
<>
<NavButton
side="left"
label={t("lightbox.prev")}
onClick={(e) => {
e.stopPropagation();
go(-1);
}}
/>
<NavButton
side="right"
label={t("lightbox.next")}
onClick={(e) => {
e.stopPropagation();
go(1);
}}
/>
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white/90 tabular-nums">
{counter}
</div>
</>
) : null}
<DialogPrimitive.Close
aria-label={t("lightbox.close")}
className={cn(
"absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full",
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
"transition-colors motion-reduce:transition-none",
)}
>
<X className="h-4 w-4" aria-hidden />
</DialogPrimitive.Close>
{/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */}
<div aria-hidden className="hidden">
{preload.map((img, i) => (
<img key={`${img.url}-${i}`} src={img.url} alt="" />
))}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
interface NavButtonProps {
side: "left" | "right";
label: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
function NavButton({ side, label, onClick }: NavButtonProps) {
const Icon = side === "left" ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={cn(
"absolute top-1/2 -translate-y-1/2 grid h-11 w-11 place-items-center rounded-full",
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
"transition-colors motion-reduce:transition-none",
side === "left" ? "left-4" : "right-4",
)}
>
<Icon className="h-5 w-5" aria-hidden />
</button>
);
}
+134 -12
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { ChevronRight, ImageIcon, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import type { UIImage, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
@@ -27,22 +28,28 @@ export function MessageBubble({ message }: MessageBubbleProps) {
}
if (message.role === "user") {
const images = message.images ?? [];
const hasImages = images.length > 0;
const hasText = message.content.trim().length > 0;
return (
<div
className={cn(
"group ml-auto flex max-w-[min(85%,36rem)] items-center gap-2",
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
baseAnim,
)}
>
<p
className={cn(
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
"text-right text-[18px]/[1.8] whitespace-pre-wrap break-words",
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
)}
>
{message.content}
</p>
{hasImages ? <UserImages images={images} /> : null}
{hasText ? (
<p
className={cn(
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
"text-right text-[18px]/[1.8] whitespace-pre-wrap break-words",
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
)}
>
{message.content}
</p>
) : null}
</div>
);
}
@@ -62,6 +69,121 @@ export function MessageBubble({ message }: MessageBubbleProps) {
);
}
/**
* Right-aligned preview row for images attached to a user turn.
*
* Visual follows agent-chat-ui: a single wrapping row of fixed-size square
* thumbnails that stay modest next to the text pill regardless of how many
* images are attached.
*
* The URL is expected to be a self-contained ``data:`` URL (the Composer
* hands the normalized base64 payload to the optimistic bubble so that the
* preview survives React StrictMode double-mount — blob URLs would be
* revoked by the Composer's cleanup before remount). Historical replays
* have no URL (the backend strips data URLs before persisting), so we
* render a labelled placeholder tile instead of a broken ``<img>``.
*/
function UserImages({ images }: { images: UIImage[] }) {
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
// placeholders (no URL) have nothing to zoom into.
const viewable = images
.map((img, i) => ({ img, i }))
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
const viewableImages = viewable.map(({ img }) => img);
const originalToViewable = new Map<number, number>(
viewable.map(({ i }, v) => [i, v]),
);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
return (
<>
<div className="ml-auto flex flex-wrap items-end justify-end gap-2">
{images.map((img, i) => (
<UserImageCell
key={`${img.url ?? "placeholder"}-${i}`}
image={img}
placeholderLabel={t("message.imageAttachment")}
openLabel={t("lightbox.open")}
onOpen={
originalToViewable.has(i)
? () => setLightboxIndex(originalToViewable.get(i)!)
: undefined
}
/>
))}
</div>
<ImageLightbox
images={viewableImages}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onOpenChange={(open) => {
if (!open) setLightboxIndex(null);
}}
/>
</>
);
}
function UserImageCell({
image,
placeholderLabel,
openLabel,
onOpen,
}: {
image: UIImage;
placeholderLabel: string;
openLabel: string;
onOpen?: () => void;
}) {
const hasUrl = typeof image.url === "string" && image.url.length > 0;
const tileClasses = cn(
"relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40",
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
);
if (hasUrl && onOpen) {
return (
<button
type="button"
onClick={onOpen}
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
title={image.name ?? undefined}
className={cn(
tileClasses,
"cursor-zoom-in transition-transform duration-150 motion-reduce:transition-none",
"hover:scale-[1.02] hover:ring-2 hover:ring-primary/30",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
)}
>
<img
src={image.url}
alt={image.name ?? ""}
loading="lazy"
decoding="async"
draggable={false}
className="h-full w-full object-cover"
/>
</button>
);
}
return (
<div className={tileClasses} title={image.name ?? undefined}>
<div
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground"
aria-label={placeholderLabel}
>
<ImageIcon className="h-4 w-4 flex-none" aria-hidden />
<span className="line-clamp-2 text-center leading-tight">
{image.name ?? placeholderLabel}
</span>
</div>
</div>
);
}
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
const { t } = useTranslation();
@@ -0,0 +1,72 @@
import { AlertTriangle, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { StreamError } from "@/lib/nanobot-client";
interface StreamErrorNoticeProps {
error: StreamError;
onDismiss: () => void;
}
/**
* Dismissible banner that surfaces transport-level faults the user needs to
* know about. Rendered above the composer so the message the fault referred
* to remains in view just above. ``role="alert"`` + ``aria-live="assertive"``
* ensures screen readers announce the failure.
*/
export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) {
const { t } = useTranslation();
const { title, body } = resolveCopy(error, t);
return (
<div
role="alert"
aria-live="assertive"
className={cn(
"mb-2 flex items-start gap-2 rounded-lg border border-destructive/30",
"bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive",
"animate-in fade-in-0 slide-in-from-bottom-1",
)}
>
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden
/>
<div className="flex-1">
<p className="font-medium">{title}</p>
<p className="mt-0.5 text-destructive/80">{body}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
aria-label={t("common.dismiss")}
className="h-6 w-6 shrink-0 text-destructive hover:bg-destructive/15 hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function resolveCopy(
error: StreamError,
t: (key: string) => string,
): { title: string; body: string } {
switch (error.kind) {
case "message_too_big":
return {
title: t("errors.messageTooBig.title"),
body: t("errors.messageTooBig.body"),
};
default: {
// Exhaustiveness guard: if a new StreamError kind is added, TS will
// complain here until we add a corresponding i18n branch.
const _exhaustive: never = error.kind;
return { title: String(_exhaustive), body: "" };
}
}
}
+305 -9
View File
@@ -1,12 +1,43 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
ArrowUp,
ImageIcon,
Loader2,
Paperclip,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
useAttachedImages,
type AttachedImage,
type AttachmentError,
MAX_IMAGES_PER_MESSAGE,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
* deliberately excluded to avoid an embedded-script XSS surface. */
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
interface ThreadComposerProps {
onSend: (content: string) => void;
onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean;
placeholder?: string;
modelLabel?: string | null;
@@ -22,11 +53,47 @@ export function ThreadComposer({
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const resolvedPlaceholder =
placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
const formatRejection = useCallback(
(reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`;
return t(key, { max: MAX_IMAGES_PER_MESSAGE });
},
[t],
);
const addFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
const { rejected } = enqueue(files);
if (rejected.length > 0) {
setInlineError(formatRejection(rejected[0].reason));
} else {
setInlineError(null);
}
},
[enqueue, formatRejection],
);
const {
isDragging,
onPaste,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useClipboardAndDrop(addFiles);
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
@@ -35,11 +102,43 @@ export function ThreadComposer({
return () => cancelAnimationFrame(id);
}, [disabled]);
const readyImages = useMemo(
() => images.filter((img): img is AttachedImage & { dataUrl: string } =>
img.status === "ready" && typeof img.dataUrl === "string",
),
[images],
);
const hasErrors = images.some((img) => img.status === "error");
const canSend =
!disabled
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
// Share the same normalized ``data:`` URL with both the wire payload and
// the optimistic bubble preview: data URLs are self-contained (no blob
// lifetime, safe under React StrictMode double-mount) and keep the
// bubble in sync with whatever the backend actually sees.
const payload: SendImage[] | undefined =
readyImages.length > 0
? readyImages.map((img) => ({
media: {
data_url: img.dataUrl,
name: img.file.name,
},
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
onSend(trimmed, payload);
setValue("");
setInlineError(null);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
@@ -47,9 +146,9 @@ export function ThreadComposer({
el.focus();
}
});
}, [disabled, onSend, value]);
}, [canSend, clear, onSend, readyImages, value]);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
@@ -62,12 +161,55 @@ export function ThreadComposer({
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
const onFilePick: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
addFiles(files);
};
const removeChip = useCallback(
(id: string) => {
const { nextFocusId } = remove(id);
setInlineError(null);
requestAnimationFrame(() => {
const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null;
if (el) {
el.focus();
} else {
textareaRef.current?.focus();
}
});
},
[remove],
);
const onChipKey = useCallback(
(id: string) => (e: ReactKeyboardEvent<HTMLButtonElement>) => {
if (
e.key === "Delete" ||
e.key === "Backspace" ||
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
removeChip(id);
}
},
[removeChip],
);
const attachButtonDisabled = disabled || full;
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
<div
@@ -78,14 +220,44 @@ export function ThreadComposer({
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card/55",
"focus-within:bg-card/70 focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
)}
>
{images.length > 0 ? (
<div
className="flex flex-wrap gap-2 px-3 pt-3"
aria-label={t("thread.composer.attachImage")}
>
{images.map((img) => (
<AttachmentChip
key={img.id}
image={img}
labelRemove={t("thread.composer.remove")}
labelEncoding={t("thread.composer.encoding")}
normalizedHint={(orig, current) =>
t("thread.composer.normalizedSizeHint", {
orig: formatBytes(orig),
current: formatBytes(current),
})
}
formatError={formatRejection}
onRemove={() => removeChip(img.id)}
onKeyDown={onChipKey(img.id)}
registerRef={(el) => {
if (el) chipRefs.current.set(img.id, el);
else chipRefs.current.delete(img.id);
}}
/>
))}
</div>
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
@@ -100,6 +272,17 @@ export function ThreadComposer({
"disabled:cursor-not-allowed",
)}
/>
{inlineError ? (
<div
role="alert"
className={cn(
"mx-3 mb-1 rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive",
)}
>
{inlineError}
</div>
) : null}
<div
className={cn(
"flex items-center justify-between gap-2",
@@ -107,6 +290,28 @@ export function ThreadComposer({
)}
>
<div className="flex min-w-0 items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
hidden
onChange={onFilePick}
/>
<Button
type="button"
size="icon"
variant="ghost"
disabled={attachButtonDisabled}
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
)}
>
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
</Button>
{modelLabel ? (
<span
title={modelLabel}
@@ -131,12 +336,12 @@ export function ThreadComposer({
<Button
type="submit"
size="icon"
disabled={disabled || !value.trim()}
disabled={!canSend}
aria-label={t("thread.composer.send")}
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
value.trim() && !disabled && "hover:scale-[1.03] active:scale-95",
canSend && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
@@ -146,3 +351,94 @@ export function ThreadComposer({
</form>
);
}
interface AttachmentChipProps {
image: AttachedImage;
labelRemove: string;
labelEncoding: string;
normalizedHint: (origBytes: number, currentBytes: number) => string;
formatError: (reason: AttachmentError) => string;
onRemove: () => void;
onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>) => void;
registerRef: (el: HTMLButtonElement | null) => void;
}
function AttachmentChip({
image,
labelRemove,
labelEncoding,
normalizedHint,
formatError,
onRemove,
onKeyDown,
registerRef,
}: AttachmentChipProps) {
const sizeLabel =
image.status === "ready" && image.normalized && image.encodedBytes
? normalizedHint(image.file.size, image.encodedBytes)
: formatBytes(image.file.size);
const tone =
image.status === "error"
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border/70 bg-muted/60";
return (
<div
className={cn(
"group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5",
"transition-colors motion-reduce:transition-none",
tone,
)}
data-testid="composer-chip"
>
<div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
{image.previewUrl ? (
<img
src={image.previewUrl}
alt=""
aria-hidden
loading="eager"
draggable={false}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
)}
{image.status === "encoding" ? (
<div
className="absolute inset-0 flex items-center justify-center bg-background/60"
aria-label={labelEncoding}
>
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden />
</div>
) : null}
</div>
<div className="flex min-w-0 flex-col text-[11.5px] leading-4">
<span className="truncate max-w-[14rem] font-medium" title={image.file.name}>
{image.file.name}
</span>
<span className="truncate text-muted-foreground">
{image.status === "error" && image.error
? formatError(image.error)
: sizeLabel}
</span>
</div>
<button
type="button"
ref={registerRef}
onClick={onRemove}
onKeyDown={onKeyDown}
aria-label={labelRemove}
className={cn(
"ml-1 grid h-5 w-5 flex-none place-items-center rounded-full",
"text-muted-foreground/80 hover:bg-foreground/8 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30",
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
}
+42 -29
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
@@ -47,10 +48,14 @@ export function ThreadShell({
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
);
const {
messages,
isStreaming,
send,
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial);
const showHeroComposer = messages.length === 0 && !loading;
useEffect(() => {
@@ -140,31 +145,39 @@ export function ThreadShell({
isStreaming={isStreaming}
emptyState={emptyState}
composer={
session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
</>
}
/>
</section>