feat(chat): add realtime comment/attachment counters with toggleable task panel

Subscribe to team.task.commented and new team.task.attachment_added
events in chat view. Show inline notifications and increment counters
on active task cards. Add toggleable sidebar panel (auto-opens on
first task dispatch, auto-closes when all tasks complete). Mobile
renders as slide-over with backdrop overlay.
This commit is contained in:
viettranx committed 2026-03-25 12:06:58 +07:00
1 parent 5f1658cfad
commit 7ad15a375a
10 files changed
+218 -32

No files matched your search

+13
View File
@@ -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))
}
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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<string> = 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,
+37 -16
View File
@@ -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<RunActivity["phase"], string> = {
@@ -22,7 +24,7 @@ const phaseLabels: Record<RunActivity["phase"], string> = {
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 (
<div className="flex items-center justify-between border-b px-4 py-1.5">
@@ -59,20 +62,38 @@ export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }:
<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>
) : isBusy ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" />
<span>Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : (
<span className="text-xs text-muted-foreground/50">Ready</span>
)}
<div className="flex items-center gap-2">
{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>
) : isBusy ? (
<button
type="button"
onClick={onToggleTaskPanel}
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Users className="h-3.5 w-3.5" />
<span>Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</button>
) : (
<span className="text-xs text-muted-foreground/50">Ready</span>
)}
{/* Task panel toggle — visible when there are (or recently were) team tasks */}
{teamTasks.length > 0 && (
<button
type="button"
onClick={onToggleTaskPanel}
className="relative rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={taskPanelOpen ? "Close task panel" : "Open task panel"}
>
<PanelIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
+87
View File
@@ -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 (
<div className={cn(
"flex h-full w-72 shrink-0 flex-col border-l bg-background",
"max-sm:fixed max-sm:inset-y-0 max-sm:right-0 max-sm:z-50 max-sm:w-full max-sm:max-w-[85vw] max-sm:shadow-xl",
)}>
{/* Header */}
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
Tasks ({tasks.length})
</span>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Task list */}
<div className="flex-1 overflow-y-auto overscroll-contain p-2 space-y-1.5">
{tasks.length === 0 ? (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">No active tasks</p>
) : (
tasks.map((task) => <TaskCard key={task.taskId} task={task} />)
)}
</div>
</div>
);
}
function TaskCard({ task }: { task: ActiveTeamTask }) {
return (
<div className="rounded-md border bg-card p-2 text-xs">
<div className="flex items-start gap-1.5">
<span className="shrink-0 font-mono text-muted-foreground">#{task.taskNumber}</span>
<span className="font-medium leading-tight line-clamp-2">{task.subject}</span>
</div>
<div className="mt-1.5 flex items-center gap-2 text-muted-foreground">
<span className="truncate">{task.ownerDisplayName || task.ownerAgentKey || "unassigned"}</span>
<span className="ml-auto flex items-center gap-2">
{(task.commentCount ?? 0) > 0 && (
<span className="flex items-center gap-0.5">
<MessageSquare className="h-3 w-3" /> {task.commentCount}
</span>
)}
{(task.attachmentCount ?? 0) > 0 && (
<span className="flex items-center gap-0.5">
<Paperclip className="h-3 w-3" /> {task.attachmentCount}
</span>
)}
</span>
</div>
{/* Progress bar */}
{task.progressPercent != null && (
<div className="mt-1.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="truncate">{task.progressStep || "In progress"}</span>
<span className="shrink-0 ml-1">{task.progressPercent}%</span>
</div>
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(task.progressPercent, 100)}%` }}
/>
</div>
</div>
)}
</div>
);
}
@@ -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 (
<div className="rounded-lg border bg-muted px-3 py-2">
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<button
type="button"
className="mb-1.5 flex w-full items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={onTogglePanel}
>
<Users className="h-3.5 w-3.5" />
<span>
Team: {tasks.length} task{tasks.length > 1 ? "s" : ""} active
</span>
</div>
</button>
<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="text-muted-foreground">&rarr;</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>
)}
<span className="ml-auto flex shrink-0 items-center gap-2 text-muted-foreground">
{(task.commentCount ?? 0) > 0 && (
<span className="flex items-center gap-0.5">
<MessageSquare className="h-3 w-3" /> {task.commentCount}
</span>
)}
{(task.attachmentCount ?? 0) > 0 && (
<span className="flex items-center gap-0.5">
<Paperclip className="h-3 w-3" /> {task.attachmentCount}
</span>
)}
{task.progressPercent != null && (
<span>{task.progressPercent}%</span>
)}
</span>
</div>
))}
</div>
+22 -1
View File
@@ -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() {
)}
<div className="shrink-0">
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} />
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} onToggleTaskPanel={() => setTaskPanelOpen((v) => !v)} taskPanelOpen={taskPanelOpen} />
</div>
{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() {
)}
</DropZone>
</div>
{/* Mobile overlay backdrop — must render before TaskPanel so panel sits above */}
{isMobile && taskPanelOpen && (
<div className="fixed inset-0 z-40 bg-black/50" onClick={() => setTaskPanelOpen(false)} />
)}
{/* Task panel — toggleable sidebar on the right */}
<TaskPanel tasks={teamTasks} open={taskPanelOpen} onClose={() => setTaskPanelOpen(false)} />
</div>
);
}
+3 -2
View File
@@ -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<HTMLDivElement>(
@@ -122,7 +123,7 @@ export const ChatThread = memo(function ChatThread({
}
})}
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} />}
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} onTogglePanel={onToggleTaskPanel} />}
<ActiveRunZone
isRunning={isRunning}
@@ -412,6 +412,18 @@ export function useChatMessages(sessionKey: string, agentId: string) {
return prev.filter((t) => 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) => {
+2
View File
@@ -22,6 +22,8 @@ export interface ActiveTeamTask {
ownerDisplayName?: string;
progressPercent?: number;
progressStep?: string;
commentCount?: number;
attachmentCount?: number;
}
/** Media item for gallery display */