diff --git a/cmd/gateway.go b/cmd/gateway.go
index 7ecd98c6..406094cc 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -143,6 +143,16 @@ func runGateway() {
for _, item := range items {
batchMedia = append(batchMedia, item.Media...)
}
+ // Notify clients that leader is processing team results
+ // (bridges UI gap between last task.completed and announce run.started).
+ msgBus.Broadcast(bus.Event{
+ Name: protocol.EventTeamLeaderProcessing,
+ Payload: map[string]any{
+ "agentId": meta.ParentAgent,
+ "tasks": len(items),
+ },
+ })
+
msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: senderID,
diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go
index 08a6fe1c..14ac156f 100644
--- a/pkg/protocol/events.go
+++ b/pkg/protocol/events.go
@@ -52,6 +52,9 @@ const (
EventTeamTaskDeleted = "team.task.deleted"
EventTeamTaskStale = "team.task.stale"
+ // Emitted when leader starts processing completed team task results (before announce run).
+ EventTeamLeaderProcessing = "team.leader.processing"
+
// Team CRUD events (admin operations).
EventTeamCreated = "team.created"
EventTeamUpdated = "team.updated"
diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts
index e59e94f9..e194dc03 100644
--- a/ui/web/src/api/protocol.ts
+++ b/ui/web/src/api/protocol.ts
@@ -206,6 +206,9 @@ export const Events = {
TEAM_TASK_DISPATCHED: "team.task.dispatched",
TEAM_TASK_DELETED: "team.task.deleted",
+ // Team leader processing (bridges gap between last task.completed and announce run.started)
+ TEAM_LEADER_PROCESSING: "team.leader.processing",
+
// Team messages
TEAM_MESSAGE_SENT: "team.message.sent",
diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx
index 9012c77c..ace4534f 100644
--- a/ui/web/src/components/chat/active-run-zone.tsx
+++ b/ui/web/src/components/chat/active-run-zone.tsx
@@ -63,7 +63,7 @@ export function ActiveRunZone({
)}
- {isRunning && (
+ {(isRunning || activity?.phase === "leader_processing") && (
)}
diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx
index a3fbcce4..abff3717 100644
--- a/ui/web/src/components/chat/activity-indicator.tsx
+++ b/ui/web/src/components/chat/activity-indicator.tsx
@@ -1,4 +1,4 @@
-import { Brain, Wrench, Pencil, Archive, RefreshCw } from "lucide-react";
+import { Brain, Wrench, Pencil, Archive, RefreshCw, Users } from "lucide-react";
import type { RunActivity } from "@/types/chat";
interface ActivityIndicatorProps {
@@ -7,7 +7,7 @@ interface ActivityIndicatorProps {
}
export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProps) {
- if (!isRunning) return null;
+ if (!isRunning && activity?.phase !== "leader_processing") return null;
if (!activity) {
return (
@@ -51,6 +51,8 @@ function getPhaseConfig(activity: RunActivity) {
color: "text-orange-500",
label: `Retrying (${activity.retryAttempt ?? 0}/${activity.retryMax ?? 0})...`,
};
+ case "leader_processing":
+ return { icon: Users, color: "text-emerald-500", label: "Processing team results..." };
default:
return { icon: Brain, color: "text-muted-foreground", label: "Working..." };
}
diff --git a/ui/web/src/components/chat/chat-top-bar.tsx b/ui/web/src/components/chat/chat-top-bar.tsx
index 77ebbf03..cd587a70 100644
--- a/ui/web/src/components/chat/chat-top-bar.tsx
+++ b/ui/web/src/components/chat/chat-top-bar.tsx
@@ -19,6 +19,7 @@ const phaseLabels: Record = {
streaming: "Responding…",
compacting: "Compacting…",
retrying: "Retrying…",
+ leader_processing: "Processing team results…",
};
export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: ChatTopBarProps) {
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 2e7418c5..aa6fd6e5 100644
--- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts
+++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts
@@ -433,14 +433,24 @@ export function useChatMessages(sessionKey: string, agentId: string) {
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
+ // Leader processing: backend emits when announce queue drains (before announce run starts).
+ const handleLeaderProcessing = useCallback((payload: unknown) => {
+ const event = payload as { agentId?: string; tasks?: number };
+ if (event?.agentId === agentIdRef.current) {
+ activityRef.current = { phase: "leader_processing" };
+ setActivity(activityRef.current);
+ }
+ }, []);
+ useWsEvent(Events.TEAM_LEADER_PROCESSING, handleLeaderProcessing);
+
// Add a local message optimistically (shown immediately, replaced on next loadHistory)
const addLocalMessage = useCallback((msg: ChatMessage) => {
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;
+ // isBusy: true when main agent is running, team tasks are active,
+ // or leader is processing team results before announce run starts.
+ const isBusy = isRunning || teamTasks.length > 0 || activity?.phase === "leader_processing";
return {
messages,
diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts
index c2038fc0..c3ec44e9 100644
--- a/ui/web/src/types/chat.ts
+++ b/ui/web/src/types/chat.ts
@@ -4,7 +4,7 @@ import type { Message } from "./session";
/** Activity phase tracking during agent run */
export interface RunActivity {
- phase: "thinking" | "tool_exec" | "streaming" | "compacting" | "retrying";
+ phase: "thinking" | "tool_exec" | "streaming" | "compacting" | "retrying" | "leader_processing";
tool?: string;
tools?: string[];
iteration?: number;