diff --git a/ui/web/src/components/shared/image-lightbox.tsx b/ui/web/src/components/shared/image-lightbox.tsx new file mode 100644 index 00000000..5441cc0e --- /dev/null +++ b/ui/web/src/components/shared/image-lightbox.tsx @@ -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 ( +
+ + {alt e.stopPropagation()} + /> +
+ ); +} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index b45d9672..31cab6eb 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -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 (
+ {lightbox && ( + setLightbox(null)} /> + )} - {alt - + {alt { + e.preventDefault(); + if (src) openLightbox(src, alt ?? "image"); + }} + {...props} + /> ); }, table({ children, ...props }) { diff --git a/ui/web/src/hooks/use-auto-scroll.ts b/ui/web/src/hooks/use-auto-scroll.ts index 1ea07ae3..ddd4d210 100644 --- a/ui/web/src/hooks/use-auto-scroll.ts +++ b/ui/web/src/hooks/use-auto-scroll.ts @@ -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( deps: unknown[], threshold = 100, + forceTrigger = 0, ) { const ref = useRef(null); const isNearBottom = useRef(true); @@ -24,6 +26,7 @@ export function useAutoScroll( 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( // 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 }; } diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 3ba0ae28..89636655 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -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 ? ( diff --git a/ui/web/src/pages/chat/chat-thread.tsx b/ui/web/src/pages/chat/chat-thread.tsx index e6ef9e29..0f149e6b 100644 --- a/ui/web/src/pages/chat/chat-thread.tsx +++ b/ui/web/src/pages/chat/chat-thread.tsx @@ -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( [messages.length, streamText, thinkingText, toolStream.length], + 100, + scrollTrigger, ); // Build map of tool_call_id → error content for tool results that indicate errors