feat(teams): delta realtime updates for task list

- Replace full-list-reload on WS events with delta patching
- Progress events: local patch with 1s debounce (no network call)
- Delete events: local remove (no network call)
- Created/status changes: debounced fetch-one per task_id (300ms)
- Add channel field to TeamTaskData type for scope-aware filtering
- Clear stale progress patches when fetch-one fires (race prevention)
This commit is contained in:
viettranx committed 2026-03-17 18:03:22 +07:00
1 parent b231878a85
commit 53fc15597e
5 files changed
+155 -30

No files matched your search

+3 -1
View File
@@ -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<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_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,
+137 -27
View File
@@ -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<TeamTaskData>;
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
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<ReturnType<typeof setTimeout>>(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<string, ReturnType<typeof setTimeout>>());
// Progress debounce timer (1s, global — batches all progress patches)
const progressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const pendingProgressRef = useRef(new Map<string, { percent: number; step: string }>());
// 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) => {
+12 -1
View File
@@ -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,
};
+2 -1
View File
@@ -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<TeamData | null>(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)}
+1
View File
@@ -75,6 +75,7 @@ export interface TeamTaskData {
priority: number;
result?: string;
user_id?: string;
channel?: string;
created_at?: string;
updated_at?: string;
// V2 fields