mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
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:
1 parent
b231878a85
commit
53fc15597e
5 files changed
+155
-30
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface TeamTaskData {
|
||||
priority: number;
|
||||
result?: string;
|
||||
user_id?: string;
|
||||
channel?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// V2 fields
|
||||
|
||||
Reference in new issue
Block a user