mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
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:
1 parent
c9a484d5d7
commit
cab9338f14
8 files changed
+90
-30
No files matched your search
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user