feat(ui): redesign chat with real-time event handling

Add 8 new chat components and extend use-chat-messages hook to handle
block.reply, activity, run.retrying, and team.task.* events.

New components: ActiveRunZone, ActivityIndicator, BlockReplyBubble,
SystemNotification, TeamActivityPanel, MediaGallery, ChatTopBar, DropZone.
This commit is contained in:
viettranx committed 2026-03-19 19:01:37 +07:00
1 parent b1b7bfbe69
commit a9ec6302ca
12 files changed
+597 -3

No files matched your search

+1
View File
@@ -196,6 +196,7 @@ export const Events = {
TEAM_TASK_CLAIMED: "team.task.claimed",
TEAM_TASK_COMPLETED: "team.task.completed",
TEAM_TASK_CANCELLED: "team.task.cancelled",
TEAM_TASK_FAILED: "team.task.failed",
TEAM_TASK_REVIEWED: "team.task.reviewed",
TEAM_TASK_APPROVED: "team.task.approved",
TEAM_TASK_REJECTED: "team.task.rejected",
@@ -0,0 +1,61 @@
import { Bot } from "lucide-react";
import { ActivityIndicator } from "./activity-indicator";
import { BlockReplyBubble } from "./block-reply-bubble";
import { ThinkingBlock } from "./thinking-block";
import { StreamingText } from "./streaming-text";
import { ToolCallCard } from "./tool-call-card";
import type { RunActivity, ToolStreamEntry, ChatMessage } from "@/types/chat";
interface ActiveRunZoneProps {
isRunning: boolean;
activity: RunActivity | null;
thinkingText: string | null;
streamText: string | null;
toolStream: ToolStreamEntry[];
blockReplies: ChatMessage[];
}
export function ActiveRunZone({
isRunning,
activity,
thinkingText,
streamText,
toolStream,
blockReplies,
}: ActiveRunZoneProps) {
const hasContent =
blockReplies.length > 0 ||
toolStream.length > 0 ||
thinkingText !== null ||
streamText !== null;
if (!isRunning && !hasContent) return null;
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
<Bot className="h-4 w-4" />
</div>
<div className="flex-1 space-y-3">
{blockReplies.map((msg, i) => (
<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} />
)}
{streamText !== null && <StreamingText text={streamText} />}
{isRunning && (
<ActivityIndicator activity={activity} isRunning={isRunning} />
)}
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { Brain, Wrench, Pencil, Archive, RefreshCw } from "lucide-react";
import type { RunActivity } from "@/types/chat";
interface ActivityIndicatorProps {
activity: RunActivity | null;
isRunning: boolean;
}
export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProps) {
if (!isRunning) return null;
if (!activity) {
return (
<div className="flex items-center gap-1 px-1 py-1">
<span className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]" />
</span>
</div>
);
}
const config = getPhaseConfig(activity);
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<config.icon className={`h-4 w-4 animate-pulse ${config.color}`} />
<span className={config.color}>{config.label}</span>
</div>
);
}
function getPhaseConfig(activity: RunActivity) {
switch (activity.phase) {
case "thinking":
return { icon: Brain, color: "text-purple-500", label: "Thinking..." };
case "tool_exec":
return {
icon: Wrench,
color: "text-blue-500",
label: activity.tool ? `Running ${activity.tool}...` : "Running tools...",
};
case "streaming":
return { icon: Pencil, color: "text-foreground", label: "Writing..." };
case "compacting":
return { icon: Archive, color: "text-amber-500", label: "Optimizing context..." };
case "retrying":
return {
icon: RefreshCw,
color: "text-orange-500",
label: `Retrying (${activity.retryAttempt ?? 0}/${activity.retryMax ?? 0})...`,
};
default:
return { icon: Brain, color: "text-muted-foreground", label: "Working..." };
}
}
@@ -0,0 +1,16 @@
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import type { ChatMessage } from "@/types/chat";
interface BlockReplyBubbleProps {
message: ChatMessage;
}
export function BlockReplyBubble({ message }: BlockReplyBubbleProps) {
return (
<div className="border-l-2 border-muted-foreground/30 pl-3 py-1">
<div className="text-sm text-muted-foreground italic">
<MarkdownRenderer content={message.content} />
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { useState, useEffect } from "react";
import { Loader2, Bot } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import type { RunActivity } from "@/types/chat";
import type { AgentData } from "@/types/agent";
interface ChatTopBarProps {
agentId: string;
isRunning: boolean;
activity: RunActivity | null;
}
const phaseLabels: Record<RunActivity["phase"], string> = {
thinking: "Thinking…",
tool_exec: "Running tool…",
streaming: "Responding…",
compacting: "Compacting…",
retrying: "Retrying…",
};
export function ChatTopBar({ agentId, isRunning, activity }: ChatTopBarProps) {
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const [agent, setAgent] = useState<{ name: string; emoji?: string } | null>(null);
// Fetch agent display info (lightweight, cached per agentId)
useEffect(() => {
if (!connected || !agentId) return;
setAgent(null);
http
.get<{ agents: AgentData[] }>("/v1/agents")
.then((res) => {
const found = (res.agents ?? []).find((a) => a.agent_key === agentId);
if (found) {
const emoji = (found.other_config?.emoji as string) || undefined;
setAgent({ name: found.display_name || found.agent_key, emoji });
} else {
setAgent({ name: agentId });
}
})
.catch(() => setAgent({ name: agentId }));
}, [http, connected, agentId]);
const displayName = agent?.name ?? agentId;
const emoji = agent?.emoji;
return (
<div className="flex items-center justify-between border-b px-4 py-1.5">
<div className="flex items-center gap-2">
{emoji ? (
<span className="text-base">{emoji}</span>
) : (
<Bot className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-semibold">{displayName}</span>
</div>
{isRunning ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{activity ? phaseLabels[activity.phase] : "Running…"}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : (
<span className="text-xs text-muted-foreground/50">Ready</span>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useRef, useState } from "react";
import { Upload } from "lucide-react";
interface DropZoneProps {
onDrop: (files: File[]) => void;
children: React.ReactNode;
}
/** Drag-and-drop overlay for file uploads. Uses a counter to handle child boundary events. */
export function DropZone({ onDrop, children }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
return (
<div
className="relative flex min-h-0 flex-1 flex-col"
onDragOver={(e) => e.preventDefault()}
onDragEnter={(e) => {
e.preventDefault();
dragCounterRef.current++;
if (dragCounterRef.current === 1) setIsDragging(true);
}}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) onDrop(files);
}}
>
{children}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="h-10 w-10" />
<span className="text-lg font-medium">Drop files here</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,76 @@
import { useState } from "react";
import { File, FileText, FileCode, Music, Film } from "lucide-react";
import { ImageLightbox } from "@/components/shared/image-lightbox";
import type { MediaItem } from "@/types/chat";
interface MediaGalleryProps {
items: MediaItem[];
}
function fileIcon(kind: MediaItem["kind"]) {
switch (kind) {
case "document": return <FileText className="h-4 w-4 text-muted-foreground" />;
case "code": return <FileCode className="h-4 w-4 text-muted-foreground" />;
case "audio": return <Music className="h-4 w-4 text-muted-foreground" />;
case "video": return <Film className="h-4 w-4 text-muted-foreground" />;
default: return <File className="h-4 w-4 text-muted-foreground" />;
}
}
export function MediaGallery({ items }: MediaGalleryProps) {
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
if (items.length === 0) return null;
const images = items.filter((i) => i.kind === "image");
const files = items.filter((i) => i.kind !== "image");
return (
<div className="space-y-2">
{images.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{images.map((item, i) => (
<button
key={i}
type="button"
onClick={() => setLightboxIdx(i)}
className="relative overflow-hidden rounded-lg border cursor-pointer"
>
<img
src={item.path}
alt={item.fileName ?? ""}
className="h-40 w-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((item, i) => (
<a
key={i}
href={item.path.startsWith("javascript:") ? "#" : item.path}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5 text-sm hover:bg-muted"
>
{fileIcon(item.kind)}
<span className="max-w-[200px] truncate">{item.fileName ?? "file"}</span>
</a>
))}
</div>
)}
{lightboxIdx !== null && images[lightboxIdx] && (
<ImageLightbox
src={images[lightboxIdx]!.path}
alt={images[lightboxIdx]!.fileName ?? ""}
onClose={() => setLightboxIdx(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,15 @@
import type { ChatMessage } from "@/types/chat";
interface SystemNotificationProps {
message: ChatMessage;
}
export function SystemNotification({ message }: SystemNotificationProps) {
return (
<div className="flex items-center gap-3 py-2 text-xs text-muted-foreground">
<div className="h-px flex-1 bg-border" />
<span className="shrink-0 whitespace-nowrap">{message.content}</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
@@ -0,0 +1,38 @@
import { Users } from "lucide-react";
import type { ActiveTeamTask } from "@/types/chat";
interface TeamActivityPanelProps {
tasks: ActiveTeamTask[];
}
export function TeamActivityPanel({ tasks }: TeamActivityPanelProps) {
if (tasks.length === 0) return null;
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Users className="h-3.5 w-3.5" />
<span>
Team: {tasks.length} task{tasks.length > 1 ? "s" : ""} active
</span>
</div>
<div className="space-y-1">
{tasks.map((task) => (
<div key={task.taskId} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">#{task.taskNumber}</span>
<span className="truncate font-medium">{task.subject}</span>
<span className="text-muted-foreground">→</span>
<span className="shrink-0 text-muted-foreground">
{task.ownerDisplayName || task.ownerAgentKey}
</span>
{task.progressPercent != null && (
<span className="ml-auto shrink-0 text-muted-foreground">
{task.progressPercent}%
</span>
)}
</div>
))}
</div>
</div>
);
}
@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useWs } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods, Events } from "@/api/protocol";
import type { Message } from "@/types/session";
import type { ChatMessage, AgentEventPayload, ToolStreamEntry } from "@/types/chat";
import type { ChatMessage, AgentEventPayload, ToolStreamEntry, RunActivity, ActiveTeamTask } from "@/types/chat";
/**
* Manages chat message history and real-time streaming for a session.
@@ -20,6 +20,9 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const [toolStream, setToolStream] = useState<ToolStreamEntry[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [loading, setLoading] = useState(false);
const [activity, setActivity] = useState<RunActivity | null>(null);
const [blockReplies, setBlockReplies] = useState<ChatMessage[]>([]);
const [teamTasks, setTeamTasks] = useState<ActiveTeamTask[]>([]);
// Use refs for values accessed inside the event handler to avoid stale closures.
const runIdRef = useRef<string | null>(null);
@@ -29,6 +32,8 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const toolStreamRef = useRef<ToolStreamEntry[]>([]);
const agentIdRef = useRef(agentId);
agentIdRef.current = agentId;
const activityRef = useRef<RunActivity | null>(null);
const blockRepliesRef = useRef<ChatMessage[]>([]);
// Synchronously clear state during render when session changes.
// This prevents a flash of old messages before the useEffect fires.
@@ -41,11 +46,16 @@ export function useChatMessages(sessionKey: string, agentId: string) {
setToolStream([]);
setIsRunning(false);
setLoading(true);
setActivity(null);
setBlockReplies([]);
setTeamTasks([]);
runIdRef.current = null;
expectingRunRef.current = false;
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
activityRef.current = null;
blockRepliesRef.current = [];
}
// Load history (no loading spinner — the empty state placeholder is shown instead)
@@ -188,6 +198,45 @@ export function useChatMessages(sessionKey: string, agentId: string) {
setToolStream(toolStreamRef.current);
break;
}
case "block.reply": {
const content = event.payload?.content ?? "";
if (content) {
const blockMsg: ChatMessage = {
role: "assistant",
content,
timestamp: Date.now(),
isBlockReply: true,
};
blockRepliesRef.current = [...blockRepliesRef.current, blockMsg];
setBlockReplies(blockRepliesRef.current);
}
break;
}
case "activity": {
const phase = event.payload?.phase as RunActivity["phase"];
if (phase) {
const newActivity: RunActivity = {
phase,
tool: event.payload?.tool as string | undefined,
tools: event.payload?.tools as string[] | undefined,
iteration: event.payload?.iteration as number | undefined,
};
activityRef.current = newActivity;
setActivity(newActivity);
}
break;
}
case "run.retrying": {
const attempt = Number(event.payload?.attempt) || 0;
const maxAttempts = Number(event.payload?.maxAttempts) || 0;
activityRef.current = {
phase: "retrying",
retryAttempt: attempt,
retryMax: maxAttempts,
};
setActivity(activityRef.current);
break;
}
case "run.completed": {
setIsRunning(false);
runIdRef.current = null;
@@ -204,6 +253,10 @@ export function useChatMessages(sessionKey: string, agentId: string) {
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
activityRef.current = null;
setActivity(null);
blockRepliesRef.current = [];
setBlockReplies([]);
if (streamed && !hadTools) {
setMessages((prev) => [
@@ -223,6 +276,10 @@ export function useChatMessages(sessionKey: string, agentId: string) {
setToolStream([]);
streamRef.current = "";
thinkingRef.current = "";
activityRef.current = null;
setActivity(null);
blockRepliesRef.current = [];
setBlockReplies([]);
setMessages((prev) => [
...prev,
{
@@ -240,6 +297,115 @@ export function useChatMessages(sessionKey: string, agentId: string) {
useWsEvent(Events.AGENT, handleAgentEvent);
// Team task event handler (curried by event name)
const handleTeamTaskEvent = useCallback(
(eventName: string) => (payload: unknown) => {
const event = payload as {
team_id?: string;
task_id?: string;
task_number?: number;
subject?: string;
status?: string;
owner_agent_key?: string;
owner_display_name?: string;
progress_percent?: number;
progress_step?: string;
reason?: string;
};
if (!event?.team_id) return;
setTeamTasks((prev) => {
const existing = prev.find((t) => t.taskId === event.task_id);
if (eventName === "team.task.dispatched" || eventName === "team.task.assigned") {
if (existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, status: event.status ?? "in_progress", ownerAgentKey: event.owner_agent_key, ownerDisplayName: event.owner_display_name }
: t,
);
}
return [
...prev,
{
taskId: event.task_id ?? "",
taskNumber: event.task_number ?? 0,
subject: event.subject ?? "",
status: event.status ?? "in_progress",
ownerAgentKey: event.owner_agent_key,
ownerDisplayName: event.owner_display_name,
},
];
}
if (eventName === "team.task.progress" && existing) {
return prev.map((t) =>
t.taskId === event.task_id
? { ...t, progressPercent: event.progress_percent, progressStep: event.progress_step }
: t,
);
}
if (
eventName === "team.task.completed" ||
eventName === "team.task.failed" ||
eventName === "team.task.cancelled"
) {
return prev.filter((t) => t.taskId !== event.task_id);
}
return prev;
});
// Add inline notification message for key events
if (
eventName === "team.task.dispatched" ||
eventName === "team.task.completed" ||
eventName === "team.task.failed"
) {
let icon = "📋";
let text = "";
if (eventName === "team.task.completed") {
icon = "✅";
text = `Task #${event.task_number} "${event.subject}" completed`;
} else if (eventName === "team.task.failed") {
icon = "❌";
text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`;
} else {
icon = "📋";
text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`;
}
setMessages((prev) => [
...prev,
{
role: "assistant" as const,
content: `${icon} ${text}`,
timestamp: Date.now(),
isNotification: true,
notificationType: eventName,
},
]);
}
},
[],
);
// Memoize bound handlers so useWsEvent doesn't re-register on every render.
const onTaskDispatched = useMemo(() => handleTeamTaskEvent("team.task.dispatched"), [handleTeamTaskEvent]);
const onTaskCompleted = useMemo(() => handleTeamTaskEvent("team.task.completed"), [handleTeamTaskEvent]);
const onTaskFailed = useMemo(() => handleTeamTaskEvent("team.task.failed"), [handleTeamTaskEvent]);
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
useWsEvent(Events.TEAM_TASK_FAILED, onTaskFailed);
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
// Add a local message optimistically (shown immediately, replaced on next loadHistory)
const addLocalMessage = useCallback((msg: ChatMessage) => {
setMessages((prev) => [...prev, msg]);
@@ -252,6 +418,9 @@ export function useChatMessages(sessionKey: string, agentId: string) {
toolStream,
isRunning,
loading,
activity,
blockReplies,
teamTasks,
expectRun,
loadHistory,
addLocalMessage,
+43 -1
View File
@@ -2,16 +2,50 @@
import type { Message } from "./session";
/** Activity phase tracking during agent run */
export interface RunActivity {
phase: "thinking" | "tool_exec" | "streaming" | "compacting" | "retrying";
tool?: string;
tools?: string[];
iteration?: number;
retryAttempt?: number;
retryMax?: number;
}
/** Team task tracking from team.task.* events */
export interface ActiveTeamTask {
taskId: string;
taskNumber: number;
subject: string;
status: string;
ownerAgentKey?: string;
ownerDisplayName?: string;
progressPercent?: number;
progressStep?: string;
}
/** Media item for gallery display */
export interface MediaItem {
path: string;
mimeType: string;
fileName?: string;
kind: "image" | "video" | "audio" | "document" | "code";
}
/** Extended message with UI-specific fields */
export interface ChatMessage extends Message {
timestamp?: number;
isStreaming?: boolean;
toolDetails?: ToolStreamEntry[];
isBlockReply?: boolean;
isNotification?: boolean;
notificationType?: string;
mediaItems?: MediaItem[];
}
/** Agent event payload from WS event "agent" */
export interface AgentEventPayload {
type: string; // "run.started" | "run.completed" | "run.failed" | "chunk" | "tool.call" | "tool.result"
type: string; // "run.started" | "run.completed" | "run.failed" | "chunk" | "tool.call" | "tool.result" | "activity" | "block.reply" | "run.retrying"
agentId: string;
runId: string;
runKind?: string; // "delegation" | "announce" — omitted for user-initiated runs
@@ -23,6 +57,14 @@ export interface AgentEventPayload {
error?: string;
arguments?: Record<string, unknown>;
result?: string;
// activity event fields
phase?: string;
tool?: string;
tools?: string[];
iteration?: number;
// run.retrying event fields
attempt?: number;
maxAttempts?: number;
};
}
+3
View File
@@ -14,6 +14,9 @@ export interface TeamTaskEventPayload {
channel: string;
chat_id: string;
timestamp: string;
task_number?: number;
progress_percent?: number;
progress_step?: string;
}
// --- Team message events ---