From cab9338f14937bcbe4f8410b2cb9ecd2d35e0327 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 20 Mar 2026 19:27:51 +0700 Subject: [PATCH] fix(ui): chat running status, streaming layout, file links, scroll jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isBusy derived state (isRunning || teamTasks active) for stop button and top bar status — reflects team task activity after main agent completes - Align ActiveRunZone with MessageBubble layout: bubble wrapper for streaming text, compact tool cards with divide-y borders - Detect relative file paths (./path) in markdown and render as file chips routed through /v1/files/ endpoint - Fix auto-scroll jitter during streaming by using instant scroll and ignoring programmatic scroll events in the near-bottom check --- .../src/components/chat/active-run-zone.tsx | 25 ++++++++++++----- ui/web/src/components/chat/chat-input.tsx | 9 +++--- ui/web/src/components/chat/chat-top-bar.tsx | 14 ++++++++-- .../components/shared/markdown-renderer.tsx | 28 +++++++++++++++---- ui/web/src/hooks/use-auto-scroll.ts | 18 ++++++++++-- ui/web/src/pages/chat/chat-page.tsx | 16 ++++++----- ui/web/src/pages/chat/chat-thread.tsx | 5 ++-- .../src/pages/chat/hooks/use-chat-messages.ts | 5 ++++ 8 files changed, 90 insertions(+), 30 deletions(-) diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx index d750e2d2..a9d7379f 100644 --- a/ui/web/src/components/chat/active-run-zone.tsx +++ b/ui/web/src/components/chat/active-run-zone.tsx @@ -42,15 +42,26 @@ export function ActiveRunZone({ ))} - {toolStream.map((entry) => ( - - ))} - - {thinkingText !== null && ( - + {/* Tool cards: match MessageBubble's compact grouped layout */} + {toolStream.length > 0 && ( +
+ {toolStream.map((entry) => ( + + ))} +
)} - {streamText !== null && } + {/* Streaming text: wrap in bubble matching MessageBubble's assistant style */} + {(thinkingText !== null || streamText !== null) && ( +
+ {thinkingText !== null && ( +
+ +
+ )} + {streamText !== null && } +
+ )} {isRunning && ( diff --git a/ui/web/src/components/chat/chat-input.tsx b/ui/web/src/components/chat/chat-input.tsx index 6a959cf5..9d85463f 100644 --- a/ui/web/src/components/chat/chat-input.tsx +++ b/ui/web/src/components/chat/chat-input.tsx @@ -12,13 +12,14 @@ export interface AttachedFile { interface ChatInputProps { onSend: (message: string, files?: AttachedFile[]) => void; onAbort: () => void; - isRunning: boolean; + /** True when main agent or team tasks are active — controls stop button, file attach */ + isBusy: boolean; disabled?: boolean; files: AttachedFile[]; onFilesChange: (files: AttachedFile[]) => void; } -export function ChatInput({ onSend, onAbort, isRunning, disabled, files, onFilesChange }: ChatInputProps) { +export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesChange }: ChatInputProps) { const { t } = useTranslation("common"); const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -101,7 +102,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled, files, onFiles variant="ghost" size="icon-lg" onClick={handleFileSelect} - disabled={disabled || isRunning} + disabled={disabled || isBusy} title={t("attachFile")} className="text-muted-foreground hover:text-foreground" > @@ -126,7 +127,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled, files, onFiles rows={1} className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" /> - {isRunning ? ( + {isBusy ? (
+ ) : isBusy ? ( +
+ + Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active + +
) : ( Ready )} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index f7015c72..101a2fcb 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -50,9 +50,26 @@ interface MarkdownRendererProps { className?: string; } -/** Check if a URL points to a local file via /v1/files/ endpoint */ +/** Common file extensions for generated/local files */ +const LOCAL_FILE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|mp3|wav|ogg|flac|aac|m4a|mp4|webm|mkv|avi|mov|pdf|doc|docx|xls|xlsx|csv|txt|md|json|zip)$/i; + +/** Check if a URL points to a local file (via /v1/files/ or relative path) */ function isFileLink(href: string | undefined): boolean { - return !!href && (href.startsWith("/v1/files/") || href.includes("/v1/files/")); + if (!href) return false; + if (href.startsWith("/v1/files/") || href.includes("/v1/files/")) return true; + // Detect relative paths with file extensions (e.g. ./system/generated/file.png) + if ((href.startsWith("./") || href.startsWith("../")) && LOCAL_FILE_EXT_RE.test(href)) return true; + return false; +} + +/** Convert a local file path to a /v1/files/ URL for serving */ +function toFileUrl(href: string): string { + if (href.startsWith("/v1/files/")) return href; + if (href.includes("/v1/files/")) return href; + // For relative paths, try resolving via /v1/files/ with the path + // Strip leading ./ or ../ + const cleaned = href.replace(/^\.\.?\//, ""); + return `/v1/files/${cleaned}`; } /** File type detection from name */ @@ -122,19 +139,20 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) }, a({ href, children }) { if (isFileLink(href)) { - const name = typeof children === "string" ? children : fileNameFromHref(href ?? ""); + const resolvedHref = toFileUrl(href!); + const name = typeof children === "string" ? children : fileNameFromHref(resolvedHref); return ( e.stopPropagation()} diff --git a/ui/web/src/hooks/use-auto-scroll.ts b/ui/web/src/hooks/use-auto-scroll.ts index 16040c27..3b476594 100644 --- a/ui/web/src/hooks/use-auto-scroll.ts +++ b/ui/web/src/hooks/use-auto-scroll.ts @@ -4,6 +4,10 @@ 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). + * + * Uses instant scroll (not smooth) to avoid jitter during rapid streaming updates. + * Ignores scroll events fired by programmatic scrolls so `isNearBottom` isn't + * incorrectly flipped to false mid-animation. */ export function useAutoScroll( deps: unknown[], @@ -12,8 +16,12 @@ export function useAutoScroll( ) { const ref = useRef(null); const isNearBottom = useRef(true); + // Guard: ignore scroll events triggered by our own programmatic scrolls. + const programmaticScroll = useRef(false); const checkScroll = useCallback(() => { + // Skip scroll events fired by our own scrollToBottom calls. + if (programmaticScroll.current) return; const el = ref.current; if (!el) return; const { scrollTop, scrollHeight, clientHeight } = el; @@ -23,17 +31,23 @@ export function useAutoScroll( const scrollToBottom = useCallback((instant = false) => { const el = ref.current; if (!el) return; + programmaticScroll.current = true; if (instant) { el.scrollTop = el.scrollHeight; } else { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } + // Re-enable user scroll detection after the browser paints. + requestAnimationFrame(() => { + programmaticScroll.current = false; + }); }, []); - // Auto-scroll when content changes (only if near bottom) — smooth + // Auto-scroll when content changes (only if near bottom). + // Uses instant scroll to prevent jitter from rapid streaming chunk updates. useEffect(() => { if (isNearBottom.current) { - scrollToBottom(false); + scrollToBottom(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index c856cddc..c24b2221 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -51,6 +51,7 @@ export function ChatPage() { thinkingText, toolStream, isRunning, + isBusy, loading: messagesLoading, activity, blockReplies, @@ -59,14 +60,14 @@ export function ChatPage() { addLocalMessage, } = useChatMessages(sessionKey, agentId); - // Refresh sessions when run completes - const prevIsRunningRef = useRef(false); + // Refresh sessions when all work completes (main agent + team tasks) + const prevIsBusyRef = useRef(false); useEffect(() => { - if (prevIsRunningRef.current && !isRunning) { + if (prevIsBusyRef.current && !isBusy) { refreshSessions(); } - prevIsRunningRef.current = isRunning; - }, [isRunning, refreshSessions]); + prevIsBusyRef.current = isBusy; + }, [isBusy, refreshSessions]); const isOwn = !sessionKey || isOwnSession(sessionKey, userId); @@ -212,7 +213,7 @@ export function ChatPage() { )} - + {sendError && (
@@ -230,6 +231,7 @@ export function ChatPage() { activity={activity} teamTasks={teamTasks} isRunning={isRunning} + isBusy={isBusy} loading={messagesLoading} scrollTrigger={scrollTrigger} /> @@ -238,7 +240,7 @@ export function ChatPage() { ( @@ -83,7 +84,7 @@ export const ChatThread = memo(function ChatThread({ const displayItems = useMemo(() => buildDisplayItems(messages), [messages]); - if (messages.length === 0 && !isRunning) { + if (messages.length === 0 && !isBusy) { if (loading) { return (
diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index 5ec37dc8..3f3b7b63 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -411,12 +411,17 @@ export function useChatMessages(sessionKey: string, agentId: string) { setMessages((prev) => [...prev, msg]); }, []); + // isBusy: true when main agent is running OR team tasks are active. + // Used for stop button visibility, top bar status, empty state check. + const isBusy = isRunning || teamTasks.length > 0; + return { messages, streamText, thinkingText, toolStream, isRunning, + isBusy, loading, activity, blockReplies,