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 && (
+
+ )}
+
+ {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 ---