diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 26349ac3..049144ea 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -481,6 +481,19 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) return ErrorResult("failed to attach file: " + err.Error()) } + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{ + TeamID: team.ID.String(), + TaskID: taskID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, + UserID: store.UserIDFromContext(ctx), + Channel: ToolChannelFromCtx(ctx), + ChatID: chatID, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "agent", + ActorID: t.manager.agentKeyFromID(ctx, agentID), + }) + return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)) } diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index c295b278..d4cb89ad 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -50,7 +50,8 @@ const ( EventTeamTaskDispatched = "team.task.dispatched" EventTeamTaskUpdated = "team.task.updated" EventTeamTaskDeleted = "team.task.deleted" - EventTeamTaskStale = "team.task.stale" + EventTeamTaskStale = "team.task.stale" + EventTeamTaskAttachmentAdded = "team.task.attachment_added" // Emitted when leader starts processing completed team task results (before announce run). EventTeamLeaderProcessing = "team.leader.processing" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index f4f78e3c..ae600172 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -216,6 +216,7 @@ export const Events = { TEAM_TASK_ASSIGNED: "team.task.assigned", TEAM_TASK_DISPATCHED: "team.task.dispatched", TEAM_TASK_DELETED: "team.task.deleted", + TEAM_TASK_ATTACHMENT_ADDED: "team.task.attachment_added", // Team leader processing (bridges gap between last task.completed and announce run.started) TEAM_LEADER_PROCESSING: "team.leader.processing", @@ -261,7 +262,7 @@ export const TEAM_RELATED_EVENTS: Set = new Set([ Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED, Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED, Events.TEAM_TASK_REJECTED, Events.TEAM_TASK_PROGRESS, - Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DISPATCHED, Events.TEAM_TASK_DELETED, + Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DISPATCHED, Events.TEAM_TASK_DELETED, Events.TEAM_TASK_ATTACHMENT_ADDED, Events.TEAM_MESSAGE_SENT, Events.TEAM_CREATED, Events.TEAM_UPDATED, Events.TEAM_DELETED, Events.TEAM_MEMBER_ADDED, Events.TEAM_MEMBER_REMOVED, diff --git a/ui/web/src/components/chat/chat-top-bar.tsx b/ui/web/src/components/chat/chat-top-bar.tsx index cd587a70..31534fdc 100644 --- a/ui/web/src/components/chat/chat-top-bar.tsx +++ b/ui/web/src/components/chat/chat-top-bar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Loader2, Bot, Users } from "lucide-react"; +import { Loader2, Bot, Users, PanelRightOpen, PanelRightClose } from "lucide-react"; import { useHttp } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import type { RunActivity, ActiveTeamTask } from "@/types/chat"; @@ -11,6 +11,8 @@ interface ChatTopBarProps { isBusy: boolean; activity: RunActivity | null; teamTasks: ActiveTeamTask[]; + onToggleTaskPanel?: () => void; + taskPanelOpen?: boolean; } const phaseLabels: Record = { @@ -22,7 +24,7 @@ const phaseLabels: Record = { leader_processing: "Processing team results…", }; -export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: ChatTopBarProps) { +export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks, onToggleTaskPanel, taskPanelOpen }: ChatTopBarProps) { const http = useHttp(); const connected = useAuthStore((s) => s.connected); const [agent, setAgent] = useState<{ name: string; emoji?: string } | null>(null); @@ -47,6 +49,7 @@ export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: const displayName = agent?.name ?? agentId; const emoji = agent?.emoji; + const PanelIcon = taskPanelOpen ? PanelRightClose : PanelRightOpen; return (
@@ -59,20 +62,38 @@ export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: {displayName}
- {isRunning ? ( -
- {activity ? phaseLabels[activity.phase] : "Running…"} - -
- ) : isBusy ? ( -
- - Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active - -
- ) : ( - Ready - )} +
+ {isRunning ? ( +
+ {activity ? phaseLabels[activity.phase] : "Running…"} + +
+ ) : isBusy ? ( + + ) : ( + Ready + )} + + {/* Task panel toggle — visible when there are (or recently were) team tasks */} + {teamTasks.length > 0 && ( + + )} +
); } diff --git a/ui/web/src/components/chat/task-panel.tsx b/ui/web/src/components/chat/task-panel.tsx new file mode 100644 index 00000000..6e9c0acf --- /dev/null +++ b/ui/web/src/components/chat/task-panel.tsx @@ -0,0 +1,87 @@ +import { X, MessageSquare, Paperclip } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ActiveTeamTask } from "@/types/chat"; + +interface TaskPanelProps { + tasks: ActiveTeamTask[]; + open: boolean; + onClose: () => void; +} + +export function TaskPanel({ tasks, open, onClose }: TaskPanelProps) { + if (!open) return null; + + return ( +
+ {/* Header */} +
+ + Tasks ({tasks.length}) + + +
+ + {/* Task list */} +
+ {tasks.length === 0 ? ( +

No active tasks

+ ) : ( + tasks.map((task) => ) + )} +
+
+ ); +} + +function TaskCard({ task }: { task: ActiveTeamTask }) { + return ( +
+
+ #{task.taskNumber} + {task.subject} +
+ +
+ {task.ownerDisplayName || task.ownerAgentKey || "unassigned"} + + + {(task.commentCount ?? 0) > 0 && ( + + {task.commentCount} + + )} + {(task.attachmentCount ?? 0) > 0 && ( + + {task.attachmentCount} + + )} + +
+ + {/* Progress bar */} + {task.progressPercent != null && ( +
+
+ {task.progressStep || "In progress"} + {task.progressPercent}% +
+
+
+
+
+ )} +
+ ); +} diff --git a/ui/web/src/components/chat/team-activity-panel.tsx b/ui/web/src/components/chat/team-activity-panel.tsx index ca127387..e3873fb7 100644 --- a/ui/web/src/components/chat/team-activity-panel.tsx +++ b/ui/web/src/components/chat/team-activity-panel.tsx @@ -1,35 +1,50 @@ -import { Users } from "lucide-react"; +import { Users, MessageSquare, Paperclip } from "lucide-react"; import type { ActiveTeamTask } from "@/types/chat"; interface TeamActivityPanelProps { tasks: ActiveTeamTask[]; + onTogglePanel?: () => void; } -export function TeamActivityPanel({ tasks }: TeamActivityPanelProps) { +export function TeamActivityPanel({ tasks, onTogglePanel }: TeamActivityPanelProps) { if (tasks.length === 0) return null; return (
-
+
+
{tasks.map((task) => (
#{task.taskNumber} {task.subject} - → + → {task.ownerDisplayName || task.ownerAgentKey} - {task.progressPercent != null && ( - - {task.progressPercent}% - - )} + + {(task.commentCount ?? 0) > 0 && ( + + {task.commentCount} + + )} + {(task.attachmentCount ?? 0) > 0 && ( + + {task.attachmentCount} + + )} + {task.progressPercent != null && ( + {task.progressPercent}% + )} +
))}
diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 59d7f166..6e5a5cdd 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -15,6 +15,7 @@ import { useChatMessages } from "./hooks/use-chat-messages"; import { useChatSend } from "./hooks/use-chat-send"; import { isOwnSession, parseSessionKey } from "@/lib/session-key"; import { useVirtualKeyboard } from "@/hooks/use-virtual-keyboard"; +import { TaskPanel } from "@/components/chat/task-panel"; export function ChatPage() { const { t } = useTranslation("chat"); @@ -147,6 +148,17 @@ export function ChatPage() { const isMobile = useIsMobile(); useVirtualKeyboard(); const [chatSidebarOpen, setChatSidebarOpen] = useState(false); + const [taskPanelOpen, setTaskPanelOpen] = useState(false); + + // Auto-open task panel when first task appears, auto-close when all done. + const prevTaskCountRef = useRef(0); + useEffect(() => { + const prev = prevTaskCountRef.current; + const curr = teamTasks.length; + if (prev === 0 && curr > 0) setTaskPanelOpen(true); + if (curr === 0 && prev > 0) setTaskPanelOpen(false); + prevTaskCountRef.current = curr; + }, [teamTasks.length]); const handleSessionSelectMobile = useCallback( (key: string) => { @@ -218,7 +230,7 @@ export function ChatPage() { )}
- + setTaskPanelOpen((v) => !v)} taskPanelOpen={taskPanelOpen} />
{sendError && ( @@ -240,6 +252,7 @@ export function ChatPage() { isBusy={isBusy} loading={messagesLoading} scrollTrigger={scrollTrigger} + onToggleTaskPanel={() => setTaskPanelOpen((v) => !v)} /> {isOwn ? ( @@ -259,6 +272,14 @@ export function ChatPage() { )}
+ + {/* Mobile overlay backdrop — must render before TaskPanel so panel sits above */} + {isMobile && taskPanelOpen && ( +
setTaskPanelOpen(false)} /> + )} + + {/* Task panel — toggleable sidebar on the right */} + setTaskPanelOpen(false)} />
); } diff --git a/ui/web/src/pages/chat/chat-thread.tsx b/ui/web/src/pages/chat/chat-thread.tsx index c5c46f0e..9725a73c 100644 --- a/ui/web/src/pages/chat/chat-thread.tsx +++ b/ui/web/src/pages/chat/chat-thread.tsx @@ -22,6 +22,7 @@ interface ChatThreadProps { isBusy: boolean; loading?: boolean; scrollTrigger?: number; + onToggleTaskPanel?: () => void; } /** Check if a message is tool-only (no user-visible text content) */ @@ -73,7 +74,7 @@ function buildDisplayItems(messages: ChatMessage[]): DisplayItem[] { export const ChatThread = memo(function ChatThread({ messages, streamText, thinkingText, toolStream, blockReplies, - activity, teamTasks, isRunning, isBusy, loading, scrollTrigger = 0, + activity, teamTasks, isRunning, isBusy, loading, scrollTrigger = 0, onToggleTaskPanel, }: ChatThreadProps) { const { t } = useTranslation("chat"); const { ref, onScroll } = useAutoScroll( @@ -122,7 +123,7 @@ export const ChatThread = memo(function ChatThread({ } })} - {teamTasks.length > 0 && } + {teamTasks.length > 0 && } t.taskId !== event.task_id); } + // Increment counters for comment/attachment events + if (eventName === "team.task.commented" && existing) { + return prev.map((t) => + t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t, + ); + } + if (eventName === "team.task.attachment_added" && existing) { + return prev.map((t) => + t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t, + ); + } + return prev; }); @@ -419,7 +431,9 @@ export function useChatMessages(sessionKey: string, agentId: string) { if ( eventName === "team.task.dispatched" || eventName === "team.task.completed" || - eventName === "team.task.failed" + eventName === "team.task.failed" || + eventName === "team.task.commented" || + eventName === "team.task.attachment_added" ) { let icon = "📋"; let text = ""; @@ -429,6 +443,12 @@ export function useChatMessages(sessionKey: string, agentId: string) { } else if (eventName === "team.task.failed") { icon = "❌"; text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`; + } else if (eventName === "team.task.commented") { + icon = "💬"; + text = `Comment on #${event.task_number} "${event.subject}"`; + } else if (eventName === "team.task.attachment_added") { + icon = "📎"; + text = `File attached to #${event.task_number} "${event.subject}"`; } else { icon = "📋"; text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`; @@ -456,6 +476,8 @@ export function useChatMessages(sessionKey: string, agentId: string) { const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]); const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]); const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]); + const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]); + const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]); useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched); useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted); @@ -463,6 +485,8 @@ export function useChatMessages(sessionKey: string, agentId: string) { useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled); useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress); useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned); + useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented); + useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached); // Leader processing: backend emits when announce queue drains (before announce run starts). const handleLeaderProcessing = useCallback((payload: unknown) => { diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts index c3ec44e9..c720e656 100644 --- a/ui/web/src/types/chat.ts +++ b/ui/web/src/types/chat.ts @@ -22,6 +22,8 @@ export interface ActiveTeamTask { ownerDisplayName?: string; progressPercent?: number; progressStep?: string; + commentCount?: number; + attachmentCount?: number; } /** Media item for gallery display */