mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
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:
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 }) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user