fix(ui): chat running status, streaming layout, file links, scroll jitter

- 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
This commit is contained in:
viettranx committed 2026-03-20 19:27:51 +07:00
1 parent c9a484d5d7
commit cab9338f14
8 files changed
+90 -30

No files matched your search

+18 -7
View File
@@ -42,15 +42,26 @@ export function ActiveRunZone({
<BlockReplyBubble key={msg.timestamp ?? i} message={msg} />
))}
{toolStream.map((entry) => (
<ToolCallCard key={entry.toolCallId} entry={entry} />
))}
{thinkingText !== null && (
<ThinkingBlock text={thinkingText} isStreaming={isRunning && streamText === null} />
{/* Tool cards: match MessageBubble's compact grouped layout */}
{toolStream.length > 0 && (
<div className="rounded-md border bg-muted divide-y divide-border">
{toolStream.map((entry) => (
<ToolCallCard key={entry.toolCallId} entry={entry} compact />
))}
</div>
)}
{streamText !== null && <StreamingText text={streamText} />}
{/* Streaming text: wrap in bubble matching MessageBubble's assistant style */}
{(thinkingText !== null || streamText !== null) && (
<div className="max-w-[85%] rounded-lg px-4 py-2 bg-card text-card-foreground border border-border shadow-sm">
{thinkingText !== null && (
<div className={streamText !== null ? "mb-2" : ""}>
<ThinkingBlock text={thinkingText} isStreaming={isRunning && streamText === null} />
</div>
)}
{streamText !== null && <StreamingText text={streamText} />}
</div>
)}
{isRunning && (
<ActivityIndicator activity={activity} isRunning={isRunning} />
+5 -4
View File
@@ -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<HTMLTextAreaElement>(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 ? (
<div className="flex gap-1">
<Button
size="icon-lg"
+11 -3
View File
@@ -1,14 +1,16 @@
import { useState, useEffect } from "react";
import { Loader2, Bot } from "lucide-react";
import { Loader2, Bot, Users } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import type { RunActivity } from "@/types/chat";
import type { RunActivity, ActiveTeamTask } from "@/types/chat";
import type { AgentData } from "@/types/agent";
interface ChatTopBarProps {
agentId: string;
isRunning: boolean;
isBusy: boolean;
activity: RunActivity | null;
teamTasks: ActiveTeamTask[];
}
const phaseLabels: Record<RunActivity["phase"], string> = {
@@ -19,7 +21,7 @@ const phaseLabels: Record<RunActivity["phase"], string> = {
retrying: "Retrying…",
};
export function ChatTopBar({ agentId, isRunning, activity }: ChatTopBarProps) {
export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: ChatTopBarProps) {
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const [agent, setAgent] = useState<{ name: string; emoji?: string } | null>(null);
@@ -61,6 +63,12 @@ export function ChatTopBar({ agentId, isRunning, activity }: ChatTopBarProps) {
<span>{activity ? phaseLabels[activity.phase] : "Running…"}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : isBusy ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" />
<span>Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : (
<span className="text-xs text-muted-foreground/50">Ready</span>
)}
@@ -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 (
<span className="inline-flex items-center gap-0.5 rounded border bg-muted/50 text-[0.85em] font-medium">
<button
type="button"
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-primary hover:bg-muted cursor-pointer rounded-l"
onClick={(e) => { e.preventDefault(); handleFileClick(href!, name); }}
onClick={(e) => { e.preventDefault(); handleFileClick(resolvedHref, name); }}
>
<FileText className="h-3.5 w-3.5" />
{children}
</button>
<a
href={href}
href={resolvedHref}
download={name}
className="inline-flex items-center px-1 py-0.5 text-muted-foreground hover:bg-muted cursor-pointer rounded-r border-l"
onClick={(e) => e.stopPropagation()}
+16 -2
View File
@@ -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<T extends HTMLElement>(
deps: unknown[],
@@ -12,8 +16,12 @@ export function useAutoScroll<T extends HTMLElement>(
) {
const ref = useRef<T>(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<T extends HTMLElement>(
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);
+9 -7
View File
@@ -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() {
</div>
)}
<ChatTopBar agentId={agentId} isRunning={isRunning} activity={activity} />
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} />
{sendError && (
<div className="border-b bg-destructive/10 px-4 py-2 text-sm text-destructive">
@@ -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() {
<ChatInput
onSend={handleSend}
onAbort={handleAbort}
isRunning={isRunning}
isBusy={isBusy}
disabled={!connected}
files={files}
onFilesChange={setFiles}
+3 -2
View File
@@ -19,6 +19,7 @@ interface ChatThreadProps {
activity: RunActivity | null;
teamTasks: ActiveTeamTask[];
isRunning: boolean;
isBusy: boolean;
loading?: boolean;
scrollTrigger?: number;
}
@@ -72,7 +73,7 @@ function buildDisplayItems(messages: ChatMessage[]): DisplayItem[] {
export const ChatThread = memo(function ChatThread({
messages, streamText, thinkingText, toolStream, blockReplies,
activity, teamTasks, isRunning, loading, scrollTrigger = 0,
activity, teamTasks, isRunning, isBusy, loading, scrollTrigger = 0,
}: ChatThreadProps) {
const { t } = useTranslation("chat");
const { ref, onScroll } = useAutoScroll<HTMLDivElement>(
@@ -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 (
<div className="flex flex-1 items-center justify-center">
@@ -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,