From eaffafead5603317cd8e82411cfd12c7782b7b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 09:10:10 +0000 Subject: [PATCH 1/3] feat(ui): compact chat composer, Enter-to-send, and smarter auto-scroll - Auto-growing textarea (1 row min, ~8 rows max) replaces the fixed min-h-[100px] composer; send/voice/cancel buttons are inlined so the idle composer height drops from ~220px to ~60px - Enter sends the message (Shift+Enter for newline), with an IME composition guard so CJK input confirmation doesn't trigger a send - Status/token row only renders while active instead of permanently - Messages column is centered at max-w-3xl; break-all -> break-words; reduced vertical padding - Auto-scroll only follows when the user is near the bottom, with a floating 'Latest' button to jump back down; sending always re-engages follow scrolling Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016d9SxeefoRSLZmQ1oKo17j --- ui/src/components/chat/ChatInterface.tsx | 132 +++++++++++++++++------ ui/src/components/chat/ChatMessage.tsx | 2 +- 2 files changed, 103 insertions(+), 31 deletions(-) diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 98b828a54..425e1122d 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useState, useRef, useEffect, useMemo } from "react"; -import { ArrowBigUp, X, Loader2, Mic, Square } from "lucide-react"; +import { ArrowBigUp, ArrowDown, X, Loader2, Mic, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -52,6 +52,12 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const substrateSandbox = useChatSubstrateSandbox(); const router = useRouter(); const containerRef = useRef(null); + const textareaRef = useRef(null); + // Tracks whether the user is scrolled near the bottom of the message list. + // Auto-scroll only follows new content while this is true, so reading + // history isn't interrupted by streaming updates. + const isNearBottomRef = useRef(true); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); const [currentInputMessage, setCurrentInputMessage] = useState(""); const [chatStatus, setChatStatus] = useState("ready"); @@ -214,16 +220,48 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, selectedAgentName, selectedNamespace, isFirstMessage]); + const getScrollViewport = () => + containerRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null; + + const scrollToBottom = () => { + const viewport = getScrollViewport(); + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + isNearBottomRef.current = true; + setShowJumpToLatest(false); + }; + + // Track scroll position so auto-scroll only engages when the user is + // already at (or near) the bottom of the conversation. useEffect(() => { - if (containerRef.current) { - const viewport = containerRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; - if (viewport) { - viewport.scrollTop = viewport.scrollHeight; - } + const viewport = getScrollViewport(); + if (!viewport) return; + const onScroll = () => { + const nearBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + isNearBottomRef.current = nearBottom; + setShowJumpToLatest(!nearBottom); + }; + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", onScroll); + }, []); + + useEffect(() => { + if (!isNearBottomRef.current) return; + const viewport = getScrollViewport(); + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; } }, [storedMessages, streamingMessages, streamingContent]); - + // Auto-grow the composer textarea with its content, capped so long drafts + // scroll internally instead of pushing the messages out of view. + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, [currentInputMessage]); const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); @@ -260,6 +298,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se setCurrentInputMessage(""); setChatStatus("thinking"); + // Sending a message always re-engages follow-scrolling. + scrollToBottom(); setStoredMessages(prev => [...prev, ...streamingMessages]); setStreamingMessages([]); setStreamingContent(""); // Reset streaming content for new message @@ -925,11 +965,15 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se }; const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - if (currentInputMessage.trim() && selectedAgentName && selectedNamespace && chatStatus === "ready") { - handleSendMessage(e); - } + if (e.key !== "Enter") return; + // Don't send while an IME composition is in progress (e.g. Chinese/Japanese + // input) — Enter is confirming the composition, not submitting. + if (e.nativeEvent.isComposing) return; + // Shift+Enter inserts a newline. + if (e.shiftKey) return; + e.preventDefault(); + if (currentInputMessage.trim() && selectedAgentName && selectedNamespace && chatStatus === "ready") { + handleSendMessage(e); } }; @@ -950,8 +994,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se return (
- -
+ +
{/* Never show loading for first message/new session */} {isLoading && sessionId && !isFirstMessage && !isCreatingSessionRef.current ? (
+ + {showJumpToLatest && ( + + )}
-
-
- - {sessionStats.total > 0 && } -
+
+ {(chatStatus !== "ready" || sessionStats.total > 0) && ( +
+ {chatStatus !== "ready" ? : } + {sessionStats.total > 0 && } +
+ )} -
+