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:
Viktor Barzin 2026-02-09 23:02:24 +00:00
parent 3616e678ac
commit 2d86213db5
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 130 additions and 363 deletions

View file

@ -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}
/>