Files
archon/archon-ui-main/src/features/progress/hooks/useProgressQueries.ts
Wirasm f4ad785439 refactor: Phase 2 Query Keys Standardization - Complete TanStack Query v5 patterns implementation (#692)
* refactor: complete Phase 2 Query Keys Standardization

Standardize query keys across all features following vertical slice architecture,
ensuring they mirror backend API structure exactly with no backward compatibility.

Key Changes:
- Refactor all query key factories to follow consistent patterns
- Move progress feature from knowledge/progress to top-level /features/progress
- Create shared query patterns for consistency (DISABLED_QUERY_KEY, STALE_TIMES)
- Remove all hardcoded stale times and disabled keys
- Update all imports after progress feature relocation

Query Key Factories Standardized:
- projectKeys: removed task-related keys (tasks, taskCounts)
- taskKeys: added dual nature support (global via lists(), project-scoped via byProject())
- knowledgeKeys: removed redundant methods (details, summary)
- progressKeys: new top-level feature with consistent factory
- documentKeys: full factory pattern with versions support
- mcpKeys: complete with health endpoint

Shared Patterns Implementation:
- STALE_TIMES: instant (0), realtime (3s), frequent (5s), normal (30s), rare (5m), static (∞)
- DISABLED_QUERY_KEY: consistent disabled query pattern across all features
- Removed unused createQueryOptions helper

Testing:
- Added comprehensive tests for progress hooks
- Updated all test mocks to include new STALE_TIMES values
- All 81 feature tests passing

Documentation:
- Created QUERY_PATTERNS.md guide for future implementations
- Clear patterns, examples, and migration checklist

Breaking Changes:
- Progress imports moved from knowledge/progress to progress
- Query key structure changes (cache will reset)
- No backward compatibility maintained

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: establish single source of truth for tags in metadata

- Remove ambiguous top-level tags field from KnowledgeItem interface
- Update all UI components to use metadata.tags exclusively
- Fix mutations to correctly update tags in metadata object
- Remove duplicate tags field from backend KnowledgeSummaryService
- Fix test setup issue with QueryClient instance in knowledge tests
- Add TODO comments for filter-blind optimistic updates (Phase 3)

This eliminates the ambiguity identified in Phase 2 where both item.tags
and metadata.tags existed, establishing metadata.tags as the single
source of truth across the entire stack.

* fix: comprehensive progress hooks improvements

- Integrate useSmartPolling for all polling queries
- Fix memory leaks from uncleaned timeouts
- Replace string-based error checking with status codes
- Remove TypeScript any usage with proper types
- Fix unstable dependencies with sorted JSON serialization
- Add staleTime to document queries for consistency

* feat: implement flexible assignee system for dynamic agents

- Changed assignee from restricted enum to flexible string type
- Renamed "AI IDE Agent" to "Coding Agent" for clarity
- Enhanced ComboBox with Radix UI best practices:
  - Full ARIA compliance (roles, labels, keyboard nav)
  - Performance optimizations (memoization, useCallback)
  - Improved UX (auto-scroll, keyboard shortcuts)
  - Fixed event bubbling preventing unintended modal opens
- Updated MCP server docs to reflect flexible assignee capability
- Removed unnecessary UI elements (arrows, helper text)
- Styled ComboBox to match priority selector aesthetic

This allows external MCP clients to create and assign custom sub-agents
dynamically, supporting advanced agent orchestration workflows.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: complete Phase 2 summariesPrefix usage for cache consistency

- Fix all knowledgeKeys.summaries() calls to use summariesPrefix() for operations targeting multiple summary caches
- Update cancelQueries, getQueriesData, setQueriesData, invalidateQueries, and refetchQueries calls
- Fix critical cache invalidation bug where filtered summaries weren't being cleared
- Update test expectations to match new factory patterns
- Address CodeRabbit review feedback on cache stability issues

This completes the Phase 2 Query Keys Standardization work documented in PRPs/local/frontend-state-management-refactor.md

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: update MCP task tools documentation for Coding Agent rename

Update task assignee documentation from "AI IDE Agent" to "Coding Agent"
to match frontend changes for consistency across the system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: implement assignee filtering in MCP find_tasks function

Add missing implementation for filter_by="assignee" that was documented
but not coded. The filter now properly passes the assignee parameter to
the backend API, matching the existing pattern used for status filtering.

Fixes documentation/implementation mismatch identified by CodeRabbit.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Phase 2 cleanup - address review comments and improve code quality

Changes made:
- Reduced smart polling interval from 60s to 5s for background tabs (better responsiveness)
- Fixed cache coherence bug in knowledge queries (missing limit parameter)
- Standardized "Coding Agent" naming (was inconsistently "AI IDE Agent")
- Improved task queries with 2s polling, type safety, and proper invalidation
- Enhanced combobox accessibility with proper ARIA attributes and IDs
- Delegated useCrawlProgressPolling to useActiveOperations (removed duplication)
- Added exact: true to progress query removals (prevents sibling removal)
- Fixed invalid Tailwind class ml-4.5 to ml-4

All changes align with Phase 2 query key standardization goals and improve
overall code quality, accessibility, and performance.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 11:05:03 +03:00

384 lines
13 KiB
TypeScript

/**
* Progress Query Hooks
* Handles polling for operation progress with TanStack Query
*/
import { type UseQueryResult, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { APIServiceError } from "../../shared/errors";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { progressService } from "../services";
import type { ActiveOperationsResponse, ProgressResponse, ProgressStatus } from "../types";
// Query keys factory
export const progressKeys = {
all: ["progress"] as const,
lists: () => [...progressKeys.all, "list"] as const,
detail: (id: string) => [...progressKeys.all, "detail", id] as const,
active: () => [...progressKeys.all, "active"] as const,
};
// Terminal states that should stop polling
const TERMINAL_STATES: ProgressStatus[] = ["completed", "error", "failed", "cancelled"];
/**
* Poll for operation progress
* Automatically stops polling when operation completes or fails
*/
export function useOperationProgress(
progressId: string | null,
options?: {
onComplete?: (data: ProgressResponse) => void;
onError?: (error: string) => void;
pollingInterval?: number;
},
) {
const queryClient = useQueryClient();
const hasCalledComplete = useRef(false);
const hasCalledError = useRef(false);
const consecutiveNotFound = useRef(0);
const { refetchInterval: smartInterval } = useSmartPolling(options?.pollingInterval ?? 1000);
// Reset refs when progressId changes
useEffect(() => {
hasCalledComplete.current = false;
hasCalledError.current = false;
consecutiveNotFound.current = 0;
}, [progressId]);
const query = useQuery<ProgressResponse | null>({
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
queryFn: async () => {
if (!progressId) throw new Error("No progress ID");
try {
const data = await progressService.getProgress(progressId);
consecutiveNotFound.current = 0; // Reset counter on success
return data;
} catch (error: unknown) {
// Handle 404 errors specially - check status code first, then message as fallback
const isNotFound =
(error instanceof APIServiceError && error.statusCode === 404) ||
(error as { status?: number })?.status === 404 ||
(error as { response?: { status?: number } })?.response?.status === 404 ||
(error instanceof Error && /not found/i.test(error.message));
if (isNotFound) {
consecutiveNotFound.current++;
// After 5 consecutive 404s, assume the operation is gone
if (consecutiveNotFound.current >= 5) {
throw new Error("Operation no longer exists");
}
// Return null to keep polling a bit longer
return null;
}
throw error;
}
},
enabled: !!progressId,
refetchInterval: (query) => {
const data = query.state.data as ProgressResponse | null | undefined;
// Only stop polling when we have actual data and it's in a terminal state
if (data && TERMINAL_STATES.includes(data.status)) {
return false;
}
// Keep polling on undefined (initial), null (transient 404), or active operations
// Use smart interval that pauses when tab is hidden
return smartInterval;
},
retry: false, // Don't retry on error
staleTime: STALE_TIMES.instant, // Always fresh for real-time progress
});
// Handle completion and error callbacks
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
if (!query.data) return;
const status = query.data.status;
// Handle completion
if (status === "completed" && !hasCalledComplete.current) {
hasCalledComplete.current = true;
options?.onComplete?.(query.data);
// Clean up the query after completion
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 2000),
);
}
// Handle cancellation
if (status === "cancelled" && !hasCalledError.current) {
hasCalledError.current = true;
options?.onError?.(query.data.error || "Operation was cancelled");
// Clean up the query after cancellation
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 2000),
);
}
// Handle errors
if ((status === "error" || status === "failed") && !hasCalledError.current) {
hasCalledError.current = true;
options?.onError?.(query.data.error || "Operation failed");
// Clean up the query after error
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 5000),
);
}
// Cleanup function to clear all timeouts
return () => {
timers.forEach(clearTimeout);
};
}, [query.data?.status, progressId, queryClient, options, query.data]);
// Forward query errors (e.g., "Operation no longer exists") to onError callback
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
if (!query.error || hasCalledError.current) return;
hasCalledError.current = true;
const errorMessage = query.error instanceof Error ? query.error.message : String(query.error);
options?.onError?.(errorMessage);
// Clean up the query after error
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 5000),
);
// Cleanup function to clear timeouts
return () => {
timers.forEach(clearTimeout);
};
}, [query.error, progressId, queryClient, options]);
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
isComplete: query.data?.status === "completed",
isFailed: query.data?.status === "error" || query.data?.status === "failed",
isActive: query.data ? !TERMINAL_STATES.includes(query.data.status) : false,
};
}
/**
* Get all active operations
* Useful for showing a global progress indicator
* @param enabled - Whether to enable polling (default: false)
*/
export function useActiveOperations(enabled = false) {
const { refetchInterval } = useSmartPolling(5000);
return useQuery<ActiveOperationsResponse>({
queryKey: progressKeys.active(),
queryFn: () => progressService.listActiveOperations(),
enabled,
refetchInterval: enabled ? refetchInterval : false, // Only poll when explicitly enabled, pause when hidden
staleTime: STALE_TIMES.realtime, // Near real-time for active operations
});
}
/**
* Hook for polling all crawl operations
* Used in the CrawlingProgress component
* Delegates to useActiveOperations for consistency
*/
export function useCrawlProgressPolling() {
const { data, isLoading } = useActiveOperations(true); // Always enabled for crawling progress
return {
activeOperations: data?.operations || [],
isLoading,
totalCount: data?.count || 0,
};
}
/**
* Hook to manage multiple progress operations
* Useful for the crawling tab that shows multiple operations
*/
export function useMultipleOperations(
progressIds: string[],
options?: {
onComplete?: (progressId: string, data: ProgressResponse) => void;
onError?: (progressId: string, error: string) => void;
},
) {
const queryClient = useQueryClient();
const completedIds = useRef(new Set<string>());
const errorIds = useRef(new Set<string>());
// Track consecutive 404s per operation
const notFoundCounts = useRef<Map<string, number>>(new Map());
const { refetchInterval: smartInterval } = useSmartPolling(1000);
// Reset tracking sets when progress IDs change
// Use sorted JSON stringification for stable dependency that handles reordering
const progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
useEffect(() => {
completedIds.current.clear();
errorIds.current.clear();
notFoundCounts.current.clear();
}, [progressIdsKey]); // Stable dependency across reorderings
const queries = useQueries({
queries: progressIds.map((progressId) => ({
queryKey: progressKeys.detail(progressId),
queryFn: async (): Promise<ProgressResponse | null> => {
try {
const data = await progressService.getProgress(progressId);
notFoundCounts.current.set(progressId, 0); // Reset counter on success
return data;
} catch (error: unknown) {
// Handle 404 errors specially for resilience - check status code first
const isNotFound =
(error instanceof APIServiceError && error.statusCode === 404) ||
(error as { status?: number })?.status === 404 ||
(error as { response?: { status?: number } })?.response?.status === 404 ||
(error instanceof Error && /not found/i.test(error.message));
if (isNotFound) {
const currentCount = (notFoundCounts.current.get(progressId) || 0) + 1;
notFoundCounts.current.set(progressId, currentCount);
// After 5 consecutive 404s, assume the operation is gone
if (currentCount >= 5) {
throw new Error("Operation no longer exists");
}
// Return null to keep polling a bit longer
return null;
}
throw error;
}
},
refetchInterval: (query: { state: { data: ProgressResponse | null | undefined } }) => {
const data = query.state.data;
// Only stop polling when we have actual data and it's in a terminal state
if (data && TERMINAL_STATES.includes(data.status)) {
return false;
}
// Keep polling on undefined (initial), null (transient 404), or active operations
// Use smart interval that pauses when tab is hidden
return smartInterval;
},
retry: false,
staleTime: STALE_TIMES.instant, // Always fresh for real-time progress
})),
}) as UseQueryResult<ProgressResponse | null, Error>[];
// Handle callbacks for each operation
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
queries.forEach((query, index) => {
const progressId = progressIds[index];
if (!query.data || !progressId) return;
const data = query.data as ProgressResponse | null;
if (!data) return;
const status = data.status;
// Handle completion
if (status === "completed" && !completedIds.current.has(progressId)) {
completedIds.current.add(progressId);
options?.onComplete?.(progressId, data);
// Clean up after completion
timers.push(
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}, 2000),
);
}
// Handle errors
if ((status === "error" || status === "failed") && !errorIds.current.has(progressId)) {
errorIds.current.add(progressId);
options?.onError?.(progressId, data.error || "Operation failed");
// Clean up after error
timers.push(
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}, 5000),
);
}
});
// Cleanup function to clear all timeouts
return () => {
timers.forEach(clearTimeout);
};
}, [queries, progressIds, queryClient, options]);
// Forward query errors (e.g., 404s after threshold) to onError callback
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
queries.forEach((query, index) => {
const progressId = progressIds[index];
if (!query.error || !progressId || errorIds.current.has(progressId)) return;
errorIds.current.add(progressId);
const errorMessage = query.error instanceof Error ? query.error.message : String(query.error);
options?.onError?.(progressId, errorMessage);
// Clean up after error
timers.push(
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}, 5000),
);
});
// Cleanup function to clear all timeouts
return () => {
timers.forEach(clearTimeout);
};
}, [queries, progressIds, queryClient, options]);
return queries.map((query, index) => {
const data = query.data as ProgressResponse | null;
return {
progressId: progressIds[index],
data,
isLoading: query.isLoading,
error: query.error,
isComplete: data?.status === "completed",
isFailed: data?.status === "error" || data?.status === "failed",
isActive: data ? !TERMINAL_STATES.includes(data.status) : false,
};
});
}