mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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:
1 parent
b1b7bfbe69
commit
a9ec6302ca
12 files changed
+597
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in new issue
Block a user