Refactor task progress to unified useTaskProgress hook
Replace WebSocket-only useTaskWebSocket with useTaskProgress that provides a unified task state interface. TaskIndicator no longer manages its own polling or auth — it receives task state from the parent via props. Rename wsTasks prop to tasks throughout.
This commit is contained in:
parent
3616e678ac
commit
2d86213db5
6 changed files with 130 additions and 363 deletions
|
|
@ -1,8 +1,3 @@
|
|||
import { getUser } from '@/auth/authService';
|
||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
|
||||
import { TaskStatus, type TaskResult, type TaskState } from '@/types';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
|
@ -11,14 +6,15 @@ import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
|||
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
||||
|
||||
interface TaskIndicatorProps {
|
||||
taskID: string | null;
|
||||
onTaskCancelled?: () => void;
|
||||
tasks: Record<string, TaskState>;
|
||||
activeTaskId: string | null;
|
||||
isConnected: boolean;
|
||||
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||
onClearAllTasks: () => Promise<boolean>;
|
||||
onTaskCompleted?: () => void;
|
||||
wsTasks?: Record<string, TaskState>;
|
||||
wsConnected?: boolean;
|
||||
}
|
||||
|
||||
/** Convert a TaskState (from WS) into a TaskResult (for the drawer). */
|
||||
/** Convert a TaskState into a TaskResult (for the drawer). */
|
||||
function taskStateToResult(ts: TaskState): TaskResult {
|
||||
return {
|
||||
progress: ts.progress ?? 0,
|
||||
|
|
@ -50,215 +46,91 @@ function isTerminalStatus(status: string): boolean {
|
|||
}
|
||||
|
||||
export function TaskIndicator({
|
||||
taskID,
|
||||
onTaskCancelled,
|
||||
tasks,
|
||||
activeTaskId,
|
||||
isConnected: _isConnected,
|
||||
onCancelTask,
|
||||
onClearAllTasks,
|
||||
onTaskCompleted,
|
||||
wsTasks,
|
||||
wsConnected,
|
||||
}: TaskIndicatorProps) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||
const [processed, setProcessed] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState<number | null>(null);
|
||||
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
|
||||
const [taskResult, setTaskResult] = useState<TaskResult | null>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
|
||||
// Prevents WS effect from overwriting local cancel/clear state before
|
||||
// the parent's setTaskID(null) propagates. Reset when taskID changes.
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const onTaskCompletedRef = useRef(onTaskCompleted);
|
||||
useEffect(() => {
|
||||
onTaskCompletedRef.current = onTaskCompleted;
|
||||
}, [onTaskCompleted]);
|
||||
|
||||
// Track the currently-viewed task in the drawer; default to the externally-provided activeTaskId
|
||||
useEffect(() => {
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
||||
});
|
||||
if (activeTaskId) {
|
||||
setSelectedTaskId(activeTaskId);
|
||||
}
|
||||
}, []);
|
||||
}, [activeTaskId]);
|
||||
|
||||
// Track the currently-viewed task in the drawer; default to the externally-provided taskID
|
||||
// Fire onTaskCompleted when the active task transitions to SUCCESS
|
||||
const prevStatusRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (taskID) {
|
||||
setSelectedTaskId(taskID);
|
||||
cancelledRef.current = false; // new task, reset cancelled guard
|
||||
}
|
||||
}, [taskID]);
|
||||
|
||||
// Count active (non-terminal) tasks from WS
|
||||
const activeWsTaskCount = useMemo(() => {
|
||||
if (!wsTasks) return 0;
|
||||
return Object.values(wsTasks).filter(
|
||||
(t) => !isTerminalStatus(t.status),
|
||||
).length;
|
||||
}, [wsTasks]);
|
||||
|
||||
// ----- WebSocket-driven state updates -----
|
||||
// When wsConnected, derive taskStatus/taskResult/progress from wsTasks
|
||||
useEffect(() => {
|
||||
if (!wsConnected || !wsTasks || !taskID) return;
|
||||
// Don't let WS overwrite local cancel/clear state
|
||||
if (cancelledRef.current) return;
|
||||
const wsTask = wsTasks[taskID];
|
||||
if (!wsTask) return;
|
||||
|
||||
const status = wsTask.status as TaskStatus;
|
||||
setTaskStatus(status);
|
||||
|
||||
if (wsTask.phase) {
|
||||
setTaskResult(taskStateToResult(wsTask));
|
||||
}
|
||||
if (wsTask.progress !== undefined) {
|
||||
setProgressPercentage(wsTask.progress * 100);
|
||||
}
|
||||
if (wsTask.processed !== undefined) {
|
||||
setProcessed(wsTask.processed);
|
||||
}
|
||||
if (wsTask.total !== undefined) {
|
||||
setTotal(wsTask.total);
|
||||
}
|
||||
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
setProgressPercentage(100);
|
||||
onTaskCompletedRef.current?.();
|
||||
}
|
||||
}, [wsConnected, wsTasks, taskID]);
|
||||
|
||||
// ----- Polling (always active as baseline; WS provides faster updates on top) -----
|
||||
useEffect(() => {
|
||||
if (!user || !taskID) {
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
if (!activeTaskId) {
|
||||
prevStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
const task = tasks[activeTaskId];
|
||||
const currentStatus = task?.status ?? null;
|
||||
if (
|
||||
currentStatus === 'SUCCESS' &&
|
||||
prevStatusRef.current !== null &&
|
||||
prevStatusRef.current !== 'SUCCESS'
|
||||
) {
|
||||
onTaskCompletedRef.current?.();
|
||||
}
|
||||
prevStatusRef.current = currentStatus;
|
||||
}, [activeTaskId, tasks]);
|
||||
|
||||
// Reset state for new task
|
||||
setTaskStatus(TaskStatus.PENDING);
|
||||
setProgressPercentage(0);
|
||||
setProcessed(null);
|
||||
setTotal(null);
|
||||
setTaskResult(null);
|
||||
// Derive display data from the active task
|
||||
const activeTask = activeTaskId ? tasks[activeTaskId] : undefined;
|
||||
const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null;
|
||||
const taskResult = activeTask?.phase ? taskStateToResult(activeTask) : null;
|
||||
const progressPercentage = (activeTask?.progress ?? 0) * 100;
|
||||
const processed = activeTask?.processed ?? null;
|
||||
const total = activeTask?.total ?? null;
|
||||
|
||||
const pollTaskStatus = async () => {
|
||||
// Skip this poll cycle if cancelled locally
|
||||
if (cancelledRef.current) return true;
|
||||
try {
|
||||
const data = await fetchTaskStatus(user, taskID);
|
||||
const status = data.status as TaskStatus;
|
||||
setTaskStatus(status);
|
||||
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
setProgressPercentage(100);
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
if (parsedResult.phase) {
|
||||
setTaskResult(parsedResult);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
onTaskCompletedRef.current?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
if (parsedResult.phase) {
|
||||
setTaskResult(parsedResult);
|
||||
}
|
||||
if (parsedResult.progress !== undefined) {
|
||||
setProgressPercentage(parsedResult.progress * 100);
|
||||
}
|
||||
if (parsedResult.processed !== undefined) {
|
||||
setProcessed(parsedResult.processed);
|
||||
}
|
||||
if (parsedResult.total !== undefined) {
|
||||
setTotal(parsedResult.total);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
setTaskStatus(TaskStatus.FAILURE);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
pollTaskStatus();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const shouldStop = await pollTaskStatus();
|
||||
if (shouldStop) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, POLLING_INTERVALS.TASK_STATUS_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [taskID, user]);
|
||||
// Count active (non-terminal) tasks
|
||||
const activeTaskCount = useMemo(() => {
|
||||
return Object.values(tasks).filter(
|
||||
(t) => !isTerminalStatus(t.status),
|
||||
).length;
|
||||
}, [tasks]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!user || !taskID || isCancelling) return;
|
||||
if (!activeTaskId || isCancelling) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await cancelTask(user, taskID);
|
||||
if (result.success) {
|
||||
cancelledRef.current = true;
|
||||
setTaskStatus(TaskStatus.REVOKED);
|
||||
onTaskCancelled?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
await onCancelTask(activeTaskId);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!user || isClearing) return;
|
||||
if (isClearing) return;
|
||||
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const result = await clearAllTasks(user);
|
||||
if (result.success) {
|
||||
cancelledRef.current = true;
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
onTaskCancelled?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore clear errors
|
||||
await onClearAllTasks();
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!taskID || !taskStatus) {
|
||||
if (!activeTaskId || !taskStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInProgress = taskStatus !== TaskStatus.SUCCESS &&
|
||||
taskStatus !== TaskStatus.FAILURE &&
|
||||
taskStatus !== TaskStatus.REVOKED;
|
||||
const isInProgress = !isTerminalStatus(taskStatus);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isInProgress) {
|
||||
|
|
@ -329,16 +201,16 @@ export function TaskIndicator({
|
|||
{taskStatus}
|
||||
</span>
|
||||
)}
|
||||
{activeWsTaskCount > 1 && (
|
||||
{activeTaskCount > 1 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-blue-500 text-[10px] font-medium text-white px-1">
|
||||
{activeWsTaskCount}
|
||||
{activeTaskCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{getTooltipContent()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">ID: {taskID.slice(0, 8)}...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">ID: {activeTaskId.slice(0, 8)}...</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isInProgress && (
|
||||
|
|
@ -381,10 +253,10 @@ export function TaskIndicator({
|
|||
onOpenChange={setDrawerOpen}
|
||||
taskResult={taskResult}
|
||||
taskStatus={taskStatus}
|
||||
taskID={selectedTaskId ?? taskID}
|
||||
taskID={selectedTaskId ?? activeTaskId}
|
||||
onCancel={handleCancel}
|
||||
isCancelling={isCancelling}
|
||||
wsTasks={wsTasks}
|
||||
tasks={tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue