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 (
+
+
+

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)} />
+ )}
-
-
+
{
+ 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