diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 1e5fb107..1cb31df5 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -133,6 +133,7 @@ export const Methods = { TEAMS_DELETE: "teams.delete", TEAMS_TASK_LIST: "teams.tasks.list", TEAMS_TASK_GET: "teams.tasks.get", + TEAMS_TASK_GET_LIGHT: "teams.tasks.get-light", TEAMS_TASK_APPROVE: "teams.tasks.approve", TEAMS_TASK_REJECT: "teams.tasks.reject", TEAMS_TASK_COMMENT: "teams.tasks.comment", @@ -186,6 +187,7 @@ export const Events = { TEAM_TASK_PROGRESS: "team.task.progress", TEAM_TASK_COMMENTED: "team.task.commented", TEAM_TASK_ASSIGNED: "team.task.assigned", + TEAM_TASK_DISPATCHED: "team.task.dispatched", TEAM_TASK_DELETED: "team.task.deleted", // Team messages @@ -224,7 +226,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_DELETED, + Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DISPATCHED, Events.TEAM_TASK_DELETED, 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/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index 56c3d640..bac87385 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -16,6 +16,18 @@ import type { type StatusFilter = "all" | "pending" | "in_progress" | "completed"; +/** Payload shape broadcast by team task WS events. */ +interface TaskEventPayload { + team_id?: string; + task_id?: string; + status?: string; + progress_percent?: number; + progress_step?: string; + owner_agent_key?: string; + channel?: string; + chat_id?: string; +} + interface BoardContainerProps { teamId: string; members: TeamMemberData[]; @@ -23,14 +35,32 @@ interface BoardContainerProps { isTeamV2: boolean; getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>; getTaskDetail: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[] }>; + getTaskLight: (teamId: string, taskId: string) => Promise; deleteTask?: (teamId: string, taskId: string) => Promise; deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise; onWorkspace?: () => void; } +/** Check if a task matches the current filters. */ +function taskMatchesFilter(task: TeamTaskData, sf: StatusFilter, scope: ScopeEntry | null): boolean { + // Status filter + switch (sf) { + case "pending": if (task.status !== "pending") return false; break; + case "in_progress": if (task.status !== "in_progress") return false; break; + case "completed": if (task.status !== "completed" && task.status !== "cancelled") return false; break; + // "all" → no status filter + } + // Scope filter + if (scope) { + if (scope.channel && (task.channel ?? "") !== scope.channel) return false; + if (scope.chat_id && (task.chat_id ?? "") !== scope.chat_id) return false; + } + return true; +} + export const BoardContainer = memo(function BoardContainer({ teamId, members, scopes, isTeamV2, - getTeamTasks, getTaskDetail, deleteTask, deleteTasksBulk, onWorkspace, + getTeamTasks, getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, onWorkspace, }: BoardContainerProps) { const { t } = useTranslation("teams"); const viewMode = useBoardStore((s) => s.viewMode); @@ -53,14 +83,15 @@ export const BoardContainer = memo(function BoardContainer({ filtersRef.current = { statusFilter, selectedScope }; const getTeamTasksRef = useRef(getTeamTasks); getTeamTasksRef.current = getTeamTasks; + const getTaskLightRef = useRef(getTaskLight); + getTaskLightRef.current = getTaskLight; + + // ── Full reload (initial load, filter change, manual refresh) ── - // Stable load function — never changes identity const load = useCallback(async (showSpinner = false) => { if (showSpinner) setRefreshing(true); try { const { statusFilter: sf, selectedScope: ss } = filtersRef.current; - // Backend supports: "" (active), "all", "completed", "in_review" - // "pending" and "in_progress" are client-side filtered from "all" const backendFilter = (sf === "pending" || sf === "in_progress") ? "all" : sf; const res = await getTeamTasksRef.current(teamId, backendFilter, ss?.channel, ss?.chat_id); let result = res.tasks ?? []; @@ -75,36 +106,116 @@ export const BoardContainer = memo(function BoardContainer({ } }, [teamId]); - // Initial load useEffect(() => { load(); }, [load]); - // Re-fetch when filters change — seamless (no spinner) + // Re-fetch when filters change useEffect(() => { if (initialized) load(); }, [statusFilter, selectedScope]); // eslint-disable-line react-hooks/exhaustive-deps - // Debounced WS event reload - const debounceRef = useRef>(undefined); - const reloadOnEvent = useCallback((payload: unknown) => { - const p = payload as { team_id?: string }; - if (p?.team_id && p.team_id !== teamId) return; - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => load(), 300); - }, [teamId, load]); + // ── Delta updates via WS events ── - useWsEvent(Events.TEAM_TASK_CREATED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_CLAIMED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_COMPLETED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_CANCELLED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_REVIEWED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_APPROVED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_REJECTED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_PROGRESS, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_COMMENTED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_ASSIGNED, reloadOnEvent); - useWsEvent(Events.TEAM_TASK_DELETED, reloadOnEvent); + // Per-task debounce timers for fetch-one calls (300ms) + const fetchTimersRef = useRef(new Map>()); + // Progress debounce timer (1s, global — batches all progress patches) + const progressTimerRef = useRef>(undefined); + const pendingProgressRef = useRef(new Map()); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + fetchTimersRef.current.forEach((t) => clearTimeout(t)); + clearTimeout(progressTimerRef.current); + }; + }, []); + + // Upsert a fetched task into local state (filter-aware) + const upsertTask = useCallback((task: TeamTaskData) => { + const { statusFilter: sf, selectedScope: ss } = filtersRef.current; + const matches = taskMatchesFilter(task, sf, ss); + setTasks((prev) => { + const idx = prev.findIndex((t) => t.id === task.id); + if (matches) { + if (idx >= 0) { + const next = [...prev]; + next[idx] = task; + return next; + } + return [task, ...prev]; // new task — prepend + } + // Doesn't match filter → remove if present + if (idx >= 0) return prev.filter((t) => t.id !== task.id); + return prev; + }); + }, []); + + // Debounced fetch-one: fetches a single task and upserts it + const debouncedFetchTask = useCallback((taskId: string) => { + const timers = fetchTimersRef.current; + const existing = timers.get(taskId); + if (existing) clearTimeout(existing); + pendingProgressRef.current.delete(taskId); // prevent stale progress overwriting fresh fetch + timers.set(taskId, setTimeout(async () => { + timers.delete(taskId); + try { + const task = await getTaskLightRef.current(teamId, taskId); + upsertTask(task); + } catch { + // Task may have been deleted between event and fetch — ignore + } + }, 300)); + }, [teamId, upsertTask]); + + // Handler: progress events → local patch, 1s debounce + const onProgress = useCallback((payload: unknown) => { + const p = payload as TaskEventPayload; + if (!p?.task_id || (p.team_id && p.team_id !== teamId)) return; + pendingProgressRef.current.set(p.task_id, { + percent: p.progress_percent ?? 0, + step: p.progress_step ?? "", + }); + clearTimeout(progressTimerRef.current); + progressTimerRef.current = setTimeout(() => { + const patches = new Map(pendingProgressRef.current); + pendingProgressRef.current.clear(); + setTasks((prev) => prev.map((t) => { + const patch = patches.get(t.id); + if (!patch) return t; + return { ...t, progress_percent: patch.percent, progress_step: patch.step }; + })); + }, 1000); + }, [teamId]); + + // Handler: deleted → local remove + const onDeleted = useCallback((payload: unknown) => { + const p = payload as TaskEventPayload; + if (!p?.task_id || (p.team_id && p.team_id !== teamId)) return; + setTasks((prev) => prev.filter((t) => t.id !== p.task_id)); + }, [teamId]); + + // Handler: created / status changes → debounced fetch-one + const onFetchOne = useCallback((payload: unknown) => { + const p = payload as TaskEventPayload; + if (!p?.task_id || (p.team_id && p.team_id !== teamId)) return; + debouncedFetchTask(p.task_id); + }, [teamId, debouncedFetchTask]); + + // Subscribe to events with appropriate handlers + useWsEvent(Events.TEAM_TASK_PROGRESS, onProgress); + useWsEvent(Events.TEAM_TASK_DELETED, onDeleted); + useWsEvent(Events.TEAM_TASK_CREATED, onFetchOne); + useWsEvent(Events.TEAM_TASK_CLAIMED, onFetchOne); + useWsEvent(Events.TEAM_TASK_COMPLETED, onFetchOne); + useWsEvent(Events.TEAM_TASK_CANCELLED, onFetchOne); + useWsEvent(Events.TEAM_TASK_REVIEWED, onFetchOne); + useWsEvent(Events.TEAM_TASK_APPROVED, onFetchOne); + useWsEvent(Events.TEAM_TASK_REJECTED, onFetchOne); + useWsEvent(Events.TEAM_TASK_ASSIGNED, onFetchOne); + useWsEvent(Events.TEAM_TASK_DISPATCHED, onFetchOne); + // TEAM_TASK_COMMENTED — no-op for list view + + // ── Callbacks for children ── - // Stable callbacks for children — never change identity const handleRefresh = useCallback(() => load(true), [load]); const handleCreateTask = useCallback(() => toast.info(t("board.createViaChat")), [t]); const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []); @@ -114,7 +225,6 @@ export const BoardContainer = memo(function BoardContainer({ if (found) setSelectedTask(found); }, [tasks]); - // Delete handler for kanban cards (confirm + call API) const deleteTaskRef = useRef(deleteTask); deleteTaskRef.current = deleteTask; const handleDeleteTask = useCallback(async (taskId: string) => { diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index e3ba8934..6dd0ea28 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -92,6 +92,17 @@ export function useTeams() { [ws], ); + const getTaskLight = useCallback( + async (teamId: string, taskId: string) => { + const res = await ws.call<{ task: TeamTaskData }>( + Methods.TEAMS_TASK_GET_LIGHT, + { teamId, taskId }, + ); + return res.task; + }, + [ws], + ); + const approveTask = useCallback( async (teamId: string, taskId: string, comment?: string) => { await ws.call(Methods.TEAMS_TASK_APPROVE, { teamId, taskId, comment }); @@ -202,7 +213,7 @@ export function useTeams() { return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes, - getTaskDetail, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents, + getTaskDetail, getTaskLight, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents, createTask, deleteTask, deleteTasksBulk, assignTask, addMember, removeMember, updateTeamSettings, getKnownUsers, }; diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index 94d10945..6e686262 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -20,7 +20,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { const { t } = useTranslation("teams"); const { getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam, - getTaskDetail, deleteTask, deleteTasksBulk, + getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, } = useTeams(); const [team, setTeam] = useState(null); @@ -99,6 +99,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { isTeamV2={isTeamV2} getTeamTasks={getTeamTasks} getTaskDetail={getTaskDetail} + getTaskLight={getTaskLight} deleteTask={deleteTask} deleteTasksBulk={deleteTasksBulk} onWorkspace={() => setWorkspaceOpen(true)} diff --git a/ui/web/src/types/team.ts b/ui/web/src/types/team.ts index 00aa31be..7e979a7a 100644 --- a/ui/web/src/types/team.ts +++ b/ui/web/src/types/team.ts @@ -75,6 +75,7 @@ export interface TeamTaskData { priority: number; result?: string; user_id?: string; + channel?: string; created_at?: string; updated_at?: string; // V2 fields