diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index a0f79412..25fa29b1 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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", diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx new file mode 100644 index 00000000..d750e2d2 --- /dev/null +++ b/ui/web/src/components/chat/active-run-zone.tsx @@ -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 ( +
+
+ +
+ +
+ {blockReplies.map((msg, i) => ( + + ))} + + {toolStream.map((entry) => ( + + ))} + + {thinkingText !== null && ( + + )} + + {streamText !== null && } + + {isRunning && ( + + )} +
+
+ ); +} diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx new file mode 100644 index 00000000..a3fbcce4 --- /dev/null +++ b/ui/web/src/components/chat/activity-indicator.tsx @@ -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 ( +
+ + + + + +
+ ); + } + + const config = getPhaseConfig(activity); + + return ( +
+ + {config.label} +
+ ); +} + +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..." }; + } +} diff --git a/ui/web/src/components/chat/block-reply-bubble.tsx b/ui/web/src/components/chat/block-reply-bubble.tsx new file mode 100644 index 00000000..b223144e --- /dev/null +++ b/ui/web/src/components/chat/block-reply-bubble.tsx @@ -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 ( +
+
+ +
+
+ ); +} diff --git a/ui/web/src/components/chat/chat-top-bar.tsx b/ui/web/src/components/chat/chat-top-bar.tsx new file mode 100644 index 00000000..3e798791 --- /dev/null +++ b/ui/web/src/components/chat/chat-top-bar.tsx @@ -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 = { + 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 ( +
+
+ {emoji ? ( + {emoji} + ) : ( + + )} + {displayName} +
+ + {isRunning ? ( +
+ {activity ? phaseLabels[activity.phase] : "Running…"} + +
+ ) : ( + Ready + )} +
+ ); +} diff --git a/ui/web/src/components/chat/drop-zone.tsx b/ui/web/src/components/chat/drop-zone.tsx new file mode 100644 index 00000000..6004755e --- /dev/null +++ b/ui/web/src/components/chat/drop-zone.tsx @@ -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 ( +
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 && ( +
+
+ + Drop files here +
+
+ )} +
+ ); +} diff --git a/ui/web/src/components/chat/media-gallery.tsx b/ui/web/src/components/chat/media-gallery.tsx new file mode 100644 index 00000000..029d90a4 --- /dev/null +++ b/ui/web/src/components/chat/media-gallery.tsx @@ -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 ; + case "code": return ; + case "audio": return ; + case "video": return ; + default: return ; + } +} + +export function MediaGallery({ items }: MediaGalleryProps) { + const [lightboxIdx, setLightboxIdx] = useState(null); + + if (items.length === 0) return null; + + const images = items.filter((i) => i.kind === "image"); + const files = items.filter((i) => i.kind !== "image"); + + return ( +
+ {images.length > 0 && ( +
+ {images.map((item, i) => ( + + ))} +
+ )} + + {files.length > 0 && ( +
+ {files.map((item, i) => ( + + {fileIcon(item.kind)} + {item.fileName ?? "file"} + + ))} +
+ )} + + {lightboxIdx !== null && images[lightboxIdx] && ( + setLightboxIdx(null)} + /> + )} +
+ ); +} diff --git a/ui/web/src/components/chat/system-notification.tsx b/ui/web/src/components/chat/system-notification.tsx new file mode 100644 index 00000000..8ae08474 --- /dev/null +++ b/ui/web/src/components/chat/system-notification.tsx @@ -0,0 +1,15 @@ +import type { ChatMessage } from "@/types/chat"; + +interface SystemNotificationProps { + message: ChatMessage; +} + +export function SystemNotification({ message }: SystemNotificationProps) { + return ( +
+
+ {message.content} +
+
+ ); +} diff --git a/ui/web/src/components/chat/team-activity-panel.tsx b/ui/web/src/components/chat/team-activity-panel.tsx new file mode 100644 index 00000000..b8d2df0d --- /dev/null +++ b/ui/web/src/components/chat/team-activity-panel.tsx @@ -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 ( +
+
+ + + Team: {tasks.length} task{tasks.length > 1 ? "s" : ""} active + +
+
+ {tasks.map((task) => ( +
+ #{task.taskNumber} + {task.subject} + → + + {task.ownerDisplayName || task.ownerAgentKey} + + {task.progressPercent != null && ( + + {task.progressPercent}% + + )} +
+ ))} +
+
+ ); +} 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 0a114ce6..56c19e79 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -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([]); const [isRunning, setIsRunning] = useState(false); const [loading, setLoading] = useState(false); + const [activity, setActivity] = useState(null); + const [blockReplies, setBlockReplies] = useState([]); + const [teamTasks, setTeamTasks] = useState([]); // Use refs for values accessed inside the event handler to avoid stale closures. const runIdRef = useRef(null); @@ -29,6 +32,8 @@ export function useChatMessages(sessionKey: string, agentId: string) { const toolStreamRef = useRef([]); const agentIdRef = useRef(agentId); agentIdRef.current = agentId; + const activityRef = useRef(null); + const blockRepliesRef = useRef([]); // 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, diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts index 5a2863fa..09a0cf7a 100644 --- a/ui/web/src/types/chat.ts +++ b/ui/web/src/types/chat.ts @@ -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; result?: string; + // activity event fields + phase?: string; + tool?: string; + tools?: string[]; + iteration?: number; + // run.retrying event fields + attempt?: number; + maxAttempts?: number; }; } diff --git a/ui/web/src/types/team-events.ts b/ui/web/src/types/team-events.ts index 5ee86022..0870ed0e 100644 --- a/ui/web/src/types/team-events.ts +++ b/ui/web/src/types/team-events.ts @@ -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 ---