feat(ui): add image lightbox and auto-scroll on send

- Replace target=_blank image links with in-page lightbox modal
  (click image to view full-size, click backdrop or Escape to close)
- Force scroll-to-bottom when user sends a message, regardless of
  current scroll position
This commit is contained in:
viettranx committed 2026-03-08 00:41:49 +07:00
1 parent a53f3e092f
commit 73e061ee6d
5 files changed
+81 -9

No files matched your search

@@ -0,0 +1,43 @@
import { useEffect, useCallback } from "react";
import { X } from "lucide-react";
interface ImageLightboxProps {
src: string;
alt?: string;
onClose: () => void;
}
export function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
>
<X className="h-5 w-5" />
</button>
<img
src={src}
alt={alt ?? "image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
@@ -1,8 +1,10 @@
import { useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { useClipboard } from "@/hooks/use-clipboard";
import { Check, Copy } from "lucide-react";
import { ImageLightbox } from "./image-lightbox";
function CodeBlock({
className,
@@ -41,8 +43,14 @@ interface MarkdownRendererProps {
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
const openLightbox = useCallback((src: string, alt: string) => setLightbox({ src, alt }), []);
return (
<div className={`prose prose-sm dark:prose-invert max-w-none break-words ${className ?? ""}`}>
{lightbox && (
<ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />
)}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
@@ -67,15 +75,17 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
},
img({ src, alt, ...props }) {
return (
<a href={src} target="_blank" rel="noopener noreferrer">
<img
src={src}
alt={alt ?? "image"}
className="max-w-sm rounded-lg border shadow-sm"
loading="lazy"
{...props}
/>
</a>
<img
src={src}
alt={alt ?? "image"}
className="max-w-sm rounded-lg border shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e) => {
e.preventDefault();
if (src) openLightbox(src, alt ?? "image");
}}
{...props}
/>
);
},
table({ children, ...props }) {
+11
View File
@@ -3,10 +3,12 @@ import { useEffect, useRef, useCallback } from "react";
/**
* Auto-scroll to bottom of a container when content changes.
* Only auto-scrolls if user is near the bottom (within threshold).
* Pass a `forceTrigger` counter to force scroll regardless of position (e.g. on send).
*/
export function useAutoScroll<T extends HTMLElement>(
deps: unknown[],
threshold = 100,
forceTrigger = 0,
) {
const ref = useRef<T>(null);
const isNearBottom = useRef(true);
@@ -24,6 +26,7 @@ export function useAutoScroll<T extends HTMLElement>(
el.scrollTop = el.scrollHeight;
}, []);
// Auto-scroll when content changes (only if near bottom)
useEffect(() => {
if (isNearBottom.current) {
scrollToBottom();
@@ -31,5 +34,13 @@ export function useAutoScroll<T extends HTMLElement>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
// Force scroll when trigger increments (e.g. user sends a message)
useEffect(() => {
if (forceTrigger > 0) {
isNearBottom.current = true;
scrollToBottom();
}
}, [forceTrigger, scrollToBottom]);
return { ref, onScroll: checkScroll, scrollToBottom };
}
+4
View File
@@ -18,6 +18,8 @@ export function ChatPage() {
const connected = useAuthStore((s) => s.connected);
const userId = useAuthStore((s) => s.userId);
const [scrollTrigger, setScrollTrigger] = useState(0);
const [agentId, setAgentId] = useState(() => {
if (urlSessionKey) {
const { agentId: parsed } = parseSessionKey(urlSessionKey);
@@ -115,6 +117,7 @@ export function ChatPage() {
}
// Pass key directly so send() doesn't use a stale closure value
send(message, key);
setScrollTrigger((n) => n + 1);
},
[sessionKey, send, buildNewSessionKey, navigate],
);
@@ -206,6 +209,7 @@ export function ChatPage() {
toolStream={toolStream}
isRunning={isRunning}
loading={messagesLoading}
scrollTrigger={scrollTrigger}
/>
{isOwn ? (
+4
View File
@@ -15,6 +15,7 @@ interface ChatThreadProps {
toolStream: ToolStreamEntry[];
isRunning: boolean;
loading?: boolean;
scrollTrigger?: number;
}
export function ChatThread({
@@ -24,9 +25,12 @@ export function ChatThread({
toolStream,
isRunning,
loading,
scrollTrigger = 0,
}: ChatThreadProps) {
const { ref, onScroll } = useAutoScroll<HTMLDivElement>(
[messages.length, streamText, thinkingText, toolStream.length],
100,
scrollTrigger,
);
// Build map of tool_call_id → error content for tool results that indicate errors