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>
This commit is contained in:
Wirasm
2025-09-18 11:05:03 +03:00
committed by GitHub
parent b383c8cbec
commit f4ad785439
44 changed files with 1344 additions and 492 deletions

View File

@@ -109,7 +109,8 @@ GET /api/agent-chat/sessions/{id}/messages - Chat messages
### Database Types (from backend)
```typescript
type DatabaseTaskStatus = 'todo' | 'doing' | 'review' | 'done';
type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
type Assignee = string; // Flexible string to support any agent name
// Common values: 'User', 'Archon', 'Coding Agent'
```
### Request/Response Types

View File

@@ -0,0 +1,227 @@
# TanStack Query Patterns Guide
This guide documents the standardized patterns for using TanStack Query v5 in the Archon frontend.
## Core Principles
1. **Feature Ownership**: Each feature owns its query keys in `{feature}/hooks/use{Feature}Queries.ts`
2. **Consistent Patterns**: Always use shared patterns from `shared/queryPatterns.ts`
3. **No Hardcoded Values**: Never hardcode stale times or disabled keys
4. **Mirror Backend API**: Query keys should exactly match backend API structure
## Query Key Factory Pattern
Every feature MUST implement a query key factory following this pattern:
```typescript
// features/{feature}/hooks/use{Feature}Queries.ts
export const featureKeys = {
all: ["feature"] as const, // Base key for the domain
lists: () => [...featureKeys.all, "list"] as const, // For list endpoints
detail: (id: string) => [...featureKeys.all, "detail", id] as const, // For single item
// Add more as needed following backend routes
};
```
### Examples from Codebase
```typescript
// Projects - Simple hierarchy
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
features: (id: string) => [...projectKeys.all, id, "features"] as const,
};
// Tasks - Dual nature (global and project-scoped)
export const taskKeys = {
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const, // /api/tasks
detail: (id: string) => [...taskKeys.all, "detail", id] as const,
byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // /api/projects/{id}/tasks
counts: () => [...taskKeys.all, "counts"] as const,
};
```
## Shared Patterns Usage
### Import Required Patterns
```typescript
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns";
```
### Disabled Queries
Always use `DISABLED_QUERY_KEY` when a query should not execute:
```typescript
// ✅ CORRECT
queryKey: projectId ? projectKeys.detail(projectId) : DISABLED_QUERY_KEY,
// ❌ WRONG - Don't create custom disabled keys
queryKey: projectId ? projectKeys.detail(projectId) : ["projects-undefined"],
```
### Stale Times
Always use `STALE_TIMES` constants for cache configuration:
```typescript
// ✅ CORRECT
staleTime: STALE_TIMES.normal, // 30 seconds
staleTime: STALE_TIMES.frequent, // 5 seconds
staleTime: STALE_TIMES.instant, // 0 - always fresh
// ❌ WRONG - Don't hardcode times
staleTime: 30000,
staleTime: 0,
```
#### STALE_TIMES Reference
- `instant: 0` - Always fresh (real-time data like active progress)
- `realtime: 3_000` - 3 seconds (near real-time updates)
- `frequent: 5_000` - 5 seconds (frequently changing data)
- `normal: 30_000` - 30 seconds (standard cache time)
- `rare: 300_000` - 5 minutes (rarely changing config)
- `static: Infinity` - Never stale (settings, auth)
## Complete Hook Pattern
```typescript
export function useFeatureDetail(id: string | undefined) {
return useQuery({
queryKey: id ? featureKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => id
? featureService.getFeatureById(id)
: Promise.reject("No ID provided"),
enabled: !!id,
staleTime: STALE_TIMES.normal,
});
}
```
## Mutations with Optimistic Updates
```typescript
export function useCreateFeature() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateFeatureRequest) => featureService.create(data),
onMutate: async (newData) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: featureKeys.lists() });
// Snapshot for rollback
const previous = queryClient.getQueryData(featureKeys.lists());
// Optimistic update (use timestamp IDs for now - Phase 3 will use UUIDs)
const tempId = `temp-${Date.now()}`;
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
[...old, { ...newData, id: tempId }]
);
return { previous, tempId };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(featureKeys.lists(), context.previous);
}
},
onSuccess: (data, variables, context) => {
// Replace optimistic with real data
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
old.map(item => item.id === context?.tempId ? data : item)
);
},
});
}
```
## Testing Query Hooks
Always mock both services and shared patterns:
```typescript
// Mock services
vi.mock("../../services", () => ({
featureService: {
getList: vi.fn(),
getById: vi.fn(),
},
}));
// Mock shared patterns with ALL values
vi.mock("../../../shared/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Infinity,
},
}));
```
## Vertical Slice Architecture
Each feature is self-contained:
```
src/features/projects/
├── components/ # UI components
├── hooks/
│ └── useProjectQueries.ts # Query hooks & keys
├── services/
│ └── projectService.ts # API calls
└── types/
└── index.ts # TypeScript types
```
Sub-features (like tasks under projects) follow the same structure:
```
src/features/projects/tasks/
├── components/
├── hooks/
│ └── useTaskQueries.ts # Own query keys!
├── services/
└── types/
```
## Migration Checklist
When refactoring to these patterns:
- [ ] Create query key factory in `hooks/use{Feature}Queries.ts`
- [ ] Import `DISABLED_QUERY_KEY` and `STALE_TIMES` from shared
- [ ] Replace all hardcoded disabled keys with `DISABLED_QUERY_KEY`
- [ ] Replace all hardcoded stale times with `STALE_TIMES` constants
- [ ] Update all `queryKey` references to use factory
- [ ] Update all `invalidateQueries` to use factory
- [ ] Update all `setQueryData` to use factory
- [ ] Add comprehensive tests for query keys
- [ ] Remove any backward compatibility code
## Common Pitfalls to Avoid
1. **Don't create centralized query keys** - Each feature owns its keys
2. **Don't hardcode values** - Use shared constants
3. **Don't mix concerns** - Tasks shouldn't import projectKeys
4. **Don't skip mocking in tests** - Mock both services and patterns
5. **Don't use inconsistent patterns** - Follow the established conventions
## Future Improvements (Phase 3+)
- Replace timestamp IDs (`temp-${Date.now()}`) with UUIDs
- Add Server-Sent Events for real-time updates
- Consider Zustand for complex client state

View File

@@ -9,7 +9,7 @@ import { useToast } from "../../ui/hooks/useToast";
import { Button, Input, Label } from "../../ui/primitives";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../ui/primitives/dialog";
import { cn } from "../../ui/primitives/styles";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives/tabs";
import { Tabs, TabsContent } from "../../ui/primitives/tabs";
import { useCrawlUrl, useUploadDocument } from "../hooks";
import type { CrawlRequest, UploadMetadata } from "../types";
import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector";

View File

@@ -8,12 +8,12 @@ import { format } from "date-fns";
import { motion } from "framer-motion";
import { Briefcase, Clock, Code, ExternalLink, File, FileText, Globe, Terminal } from "lucide-react";
import { useState } from "react";
import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../../progress/types";
import { StatPill } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
import { useDeleteKnowledgeItem, useRefreshKnowledgeItem } from "../hooks";
import { KnowledgeCardProgress } from "../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../progress/types";
import type { KnowledgeItem } from "../types";
import { extractDomain } from "../utils/knowledge-utils";
import { KnowledgeCardActions } from "./KnowledgeCardActions";
@@ -232,7 +232,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
<KnowledgeCardTitle
sourceId={item.source_id}
title={item.title}
description={(item as any).summary}
description={item.metadata?.description}
accentColor={getAccentColorName()}
/>
</div>
@@ -268,7 +268,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
role="none"
className="mt-2"
>
<KnowledgeCardTags sourceId={item.source_id} tags={item.tags || item.metadata?.tags || []} />
<KnowledgeCardTags sourceId={item.source_id} tags={item.metadata?.tags || []} />
</div>
</div>

View File

@@ -5,8 +5,8 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, Loader2 } from "lucide-react";
import type { ActiveOperation } from "../../progress/types";
import { Button } from "../../ui/primitives";
import type { ActiveOperation } from "../progress/types";
import type { KnowledgeItem } from "../types";
import { KnowledgeCard } from "./KnowledgeCard";
import { KnowledgeTable } from "./KnowledgeTable";

View File

@@ -60,21 +60,25 @@ describe("useKnowledgeQueries", () => {
expect(knowledgeKeys.all).toEqual(["knowledge"]);
expect(knowledgeKeys.lists()).toEqual(["knowledge", "list"]);
expect(knowledgeKeys.detail("source-123")).toEqual(["knowledge", "detail", "source-123"]);
expect(knowledgeKeys.chunks("source-123", "example.com")).toEqual([
expect(knowledgeKeys.chunks("source-123", { domain: "example.com" })).toEqual([
"knowledge",
"detail",
"source-123",
"chunks",
"example.com",
{ domain: "example.com", limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.codeExamples("source-123")).toEqual([
"knowledge",
"source-123",
"code-examples",
{ limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.codeExamples("source-123")).toEqual(["knowledge", "detail", "source-123", "code-examples"]);
expect(knowledgeKeys.search("test query")).toEqual(["knowledge", "search", "test query"]);
expect(knowledgeKeys.sources()).toEqual(["knowledge", "sources"]);
});
it("should handle filter in list key", () => {
it("should handle filter in summaries key", () => {
const filter = { knowledge_type: "technical" as const, page: 2 };
expect(knowledgeKeys.list(filter)).toEqual(["knowledge", "list", filter]);
expect(knowledgeKeys.summaries(filter)).toEqual(["knowledge", "summaries", filter]);
});
});
@@ -122,12 +126,22 @@ describe("useKnowledgeQueries", () => {
message: "Item deleted",
});
const wrapper = createWrapper();
const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });
// Create QueryClient instance that will be used by the test
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Pre-populate cache
const queryClient = new QueryClient();
queryClient.setQueryData(knowledgeKeys.list(), initialData);
// Pre-populate cache with the same client instance
queryClient.setQueryData(knowledgeKeys.lists(), initialData);
// Create wrapper with the pre-populated QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });
await result.current.mutateAsync("source-1");

View File

@@ -5,11 +5,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useActiveOperations } from "../../progress/hooks";
import { progressKeys } from "../../progress/hooks/useProgressQueries";
import type { ActiveOperation, ActiveOperationsResponse } from "../../progress/types";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { useToast } from "../../ui/hooks/useToast";
import { useActiveOperations } from "../progress/hooks";
import { progressKeys } from "../progress/hooks/useProgressQueries";
import type { ActiveOperation, ActiveOperationsResponse } from "../progress/types";
import { knowledgeService } from "../services";
import type {
CrawlRequest,
@@ -25,16 +26,26 @@ import { getProviderErrorMessage } from "../utils/providerErrorHandler";
export const knowledgeKeys = {
all: ["knowledge"] as const,
lists: () => [...knowledgeKeys.all, "list"] as const,
list: (filters?: KnowledgeItemsFilter) => [...knowledgeKeys.lists(), filters] as const,
details: () => [...knowledgeKeys.all, "detail"] as const,
detail: (sourceId: string) => [...knowledgeKeys.details(), sourceId] as const,
chunks: (sourceId: string, domainFilter?: string) =>
[...knowledgeKeys.detail(sourceId), "chunks", domainFilter] as const,
codeExamples: (sourceId: string) => [...knowledgeKeys.detail(sourceId), "code-examples"] as const,
search: (query: string) => [...knowledgeKeys.all, "search", query] as const,
detail: (id: string) => [...knowledgeKeys.all, "detail", id] as const,
// Include domain + pagination to avoid cache collisions
chunks: (
id: string,
opts?: { domain?: string; limit?: number; offset?: number },
) =>
[
...knowledgeKeys.all,
id,
"chunks",
{ domain: opts?.domain ?? "all", limit: opts?.limit, offset: opts?.offset },
] as const,
// Include pagination in the key
codeExamples: (id: string, opts?: { limit?: number; offset?: number }) =>
[...knowledgeKeys.all, id, "code-examples", { limit: opts?.limit, offset: opts?.offset }] as const,
// Prefix helper for targeting all summaries queries
summariesPrefix: () => [...knowledgeKeys.all, "summaries"] as const,
summaries: (filter?: KnowledgeItemsFilter) => [...knowledgeKeys.all, "summaries", filter] as const,
sources: () => [...knowledgeKeys.all, "sources"] as const,
summary: () => [...knowledgeKeys.all, "summary"] as const,
summaries: (filter?: KnowledgeItemsFilter) => [...knowledgeKeys.summary(), filter] as const,
search: (query: string) => [...knowledgeKeys.all, "search", query] as const,
};
/**
@@ -42,23 +53,34 @@ export const knowledgeKeys = {
*/
export function useKnowledgeItem(sourceId: string | null) {
return useQuery<KnowledgeItem>({
queryKey: sourceId ? knowledgeKeys.detail(sourceId) : ["knowledge-undefined"],
queryKey: sourceId ? knowledgeKeys.detail(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getKnowledgeItem(sourceId) : Promise.reject("No source ID")),
enabled: !!sourceId,
staleTime: 30000, // Cache for 30 seconds
staleTime: STALE_TIMES.normal,
});
}
/**
* Fetch document chunks for a knowledge item
*/
export function useKnowledgeItemChunks(sourceId: string | null, domainFilter?: string) {
export function useKnowledgeItemChunks(
sourceId: string | null,
opts?: { domain?: string; limit?: number; offset?: number }
) {
// TODO: Phase 4 - Add explicit typing: useQuery<DocumentChunk[]> or appropriate return type
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
return useQuery({
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, domainFilter) : ["chunks-undefined"],
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
queryFn: () =>
sourceId ? knowledgeService.getKnowledgeItemChunks(sourceId, { domainFilter }) : Promise.reject("No source ID"),
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
domainFilter: opts?.domain,
limit: opts?.limit,
offset: opts?.offset,
})
: Promise.reject("No source ID"),
enabled: !!sourceId,
staleTime: 60000, // Cache for 1 minute
staleTime: STALE_TIMES.normal,
});
}
@@ -67,10 +89,10 @@ export function useKnowledgeItemChunks(sourceId: string | null, domainFilter?: s
*/
export function useCodeExamples(sourceId: string | null) {
return useQuery({
queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : ["code-examples-undefined"],
queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getCodeExamples(sourceId) : Promise.reject("No source ID")),
enabled: !!sourceId,
staleTime: 60000, // Cache for 1 minute
staleTime: STALE_TIMES.normal,
});
}
@@ -96,17 +118,25 @@ export function useCrawlUrl() {
>({
mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request),
onMutate: async (request) => {
// TODO: Phase 3 - Fix optimistic updates writing to wrong cache
// knowledgeKeys.lists() is never queried - actual data comes from knowledgeKeys.summaries(filter)
// This makes all optimistic updates invisible. Should either:
// 1. Remove optimistic updates for knowledge items
// 2. Update all summary caches with optimistic data
// 3. Create a real query that uses lists()
// See: PRPs/local/frontend-state-management-refactor.md Phase 3
// Cancel any outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: knowledgeKeys.lists() });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() });
await queryClient.cancelQueries({ queryKey: progressKeys.list() });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
// Snapshot the previous values for rollback
const previousKnowledge = queryClient.getQueryData<KnowledgeItem[]>(knowledgeKeys.lists());
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summary(),
queryKey: knowledgeKeys.summariesPrefix(),
});
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.list());
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
// Generate temporary IDs
const tempProgressId = `temp-progress-${Date.now()}`;
@@ -115,7 +145,13 @@ export function useCrawlUrl() {
// Create optimistic knowledge item
const optimisticItem: KnowledgeItem = {
id: tempItemId,
title: new URL(request.url).hostname || "New crawl",
title: (() => {
try {
return new URL(request.url).hostname || "New crawl";
} catch {
return "New crawl";
}
})(),
url: request.url,
source_id: tempProgressId,
source_type: "url",
@@ -143,7 +179,12 @@ export function useCrawlUrl() {
// CRITICAL: Also add optimistic item to SUMMARIES cache (what the UI actually uses!)
// This ensures the card shows up immediately in the knowledge base view
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summary() }, (old) => {
// TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates
// Currently adds items to ALL summary caches regardless of their filters (e.g., knowledge_type, tags).
// This can cause items to appear in filtered views where they shouldn't be visible.
// Solution: Check each cache's filter criteria before adding the optimistic item.
// See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) {
return {
items: [optimisticItem],
@@ -175,7 +216,7 @@ export function useCrawlUrl() {
};
// Add optimistic operation to active operations
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.list(), (old) => {
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) {
return {
operations: [optimisticOperation],
@@ -213,7 +254,7 @@ export function useCrawlUrl() {
});
// Also update summaries cache with real progress ID
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summary() }, (old) => {
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) return old;
return {
...old,
@@ -230,7 +271,7 @@ export function useCrawlUrl() {
});
// Update progress operation with real progress ID
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.list(), (old) => {
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) return old;
return {
...old,
@@ -252,7 +293,7 @@ export function useCrawlUrl() {
// Invalidate to get fresh data
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
queryClient.invalidateQueries({ queryKey: progressKeys.list() });
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
showToast(`Crawl started: ${response.message}`, "success");
@@ -271,7 +312,7 @@ export function useCrawlUrl() {
}
}
if (context?.previousOperations) {
queryClient.setQueryData(progressKeys.list(), context.previousOperations);
queryClient.setQueryData(progressKeys.active(), context.previousOperations);
}
const errorMessage = getProviderErrorMessage(error) || "Failed to start crawl";
@@ -302,14 +343,14 @@ export function useUploadDocument() {
knowledgeService.uploadDocument(file, metadata),
onMutate: async ({ file, metadata }) => {
// Cancel any outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() });
await queryClient.cancelQueries({ queryKey: progressKeys.list() });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
// Snapshot the previous values for rollback
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summary(),
queryKey: knowledgeKeys.summariesPrefix(),
});
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.list());
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
// Generate temporary IDs
const tempProgressId = `temp-upload-${Date.now()}`;
@@ -332,14 +373,18 @@ export function useUploadDocument() {
source_type: "file",
status: "processing",
description: `Uploading ${file.name}`,
filename: file.name,
file_name: file.name,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Add optimistic item to SUMMARIES cache (what the UI uses!)
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summary() }, (old) => {
// TODO: [Phase 3 - Optimistic Updates] Fix filter-blind optimistic updates for uploads
// Same issue as crawlUrl - adds items to ALL summary caches regardless of filters.
// Should check filter criteria (knowledge_type, tags, etc.) before adding to each cache.
// See: https://github.com/coleam00/Archon/pull/676#issuecomment-XXXXX
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) {
return {
items: [optimisticItem],
@@ -371,7 +416,7 @@ export function useUploadDocument() {
};
// Add optimistic operation to active operations
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.list(), (old) => {
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) {
return {
operations: [optimisticOperation],
@@ -392,7 +437,7 @@ export function useUploadDocument() {
// Replace temporary IDs with real ones from the server
if (context && response?.progressId) {
// Update summaries cache with real progress ID
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summary() }, (old) => {
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) return old;
return {
...old,
@@ -409,7 +454,7 @@ export function useUploadDocument() {
});
// Update progress operation with real progress ID
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.list(), (old) => {
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) return old;
return {
...old,
@@ -432,8 +477,8 @@ export function useUploadDocument() {
// Invalidate queries to get fresh data - with a short delay for fast uploads
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() });
queryClient.invalidateQueries({ queryKey: progressKeys.list() });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
}, 1000);
// Don't show success here - upload is just starting in background
@@ -447,7 +492,7 @@ export function useUploadDocument() {
}
}
if (context?.previousOperations) {
queryClient.setQueryData(progressKeys.list(), context.previousOperations);
queryClient.setQueryData(progressKeys.active(), context.previousOperations);
}
// Display the actual error message from backend
@@ -470,6 +515,8 @@ export function useStopCrawl() {
},
onError: (error, progressId) => {
// If it's a 404, the operation might have already completed or been cancelled
// TODO: Phase 4 - Improve error type safety, create proper error interface instead of 'as any'
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
const is404Error =
(error as any)?.statusCode === 404 ||
(error instanceof Error && (error.message.includes("404") || error.message.includes("not found")));
@@ -496,10 +543,10 @@ export function useDeleteKnowledgeItem() {
mutationFn: (sourceId: string) => knowledgeService.deleteKnowledgeItem(sourceId),
onMutate: async (sourceId) => {
// Cancel summary queries (all filters)
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Snapshot all summary caches (for all filters)
const summariesPrefix = knowledgeKeys.summary();
const summariesPrefix = knowledgeKeys.summariesPrefix();
const previousEntries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: summariesPrefix,
});
@@ -529,9 +576,9 @@ export function useDeleteKnowledgeItem() {
showToast(data.message || "Item deleted successfully", "success");
// Invalidate summaries to reconcile with server
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() });
// Also invalidate detail view if it exists
queryClient.invalidateQueries({ queryKey: knowledgeKeys.details() });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Also invalidate detail views
queryClient.invalidateQueries({ queryKey: knowledgeKeys.all });
},
});
}
@@ -544,16 +591,16 @@ export function useUpdateKnowledgeItem() {
const { showToast } = useToast();
return useMutation({
mutationFn: ({ sourceId, updates }: { sourceId: string; updates: Partial<KnowledgeItem> }) =>
mutationFn: ({ sourceId, updates }: { sourceId: string; updates: Partial<KnowledgeItem> & { tags?: string[] } }) =>
knowledgeService.updateKnowledgeItem(sourceId, updates),
onMutate: async ({ sourceId, updates }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: knowledgeKeys.detail(sourceId) });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Snapshot the previous values
const previousItem = queryClient.getQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId));
const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summary() });
const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summariesPrefix() });
// Optimistically update the detail item
if (previousItem) {
@@ -567,30 +614,31 @@ export function useUpdateKnowledgeItem() {
updatedItem.title = updates.title;
}
// Handle tags updates
// Handle tags updates - update in metadata only
if ("tags" in updates && Array.isArray(updates.tags)) {
const newTags = updates.tags as string[];
(updatedItem as any).tags = newTags;
updatedItem.metadata = {
...currentMetadata,
tags: newTags,
};
}
// Handle knowledge_type updates
if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") {
const newType = updates.knowledge_type as "technical" | "business";
updatedItem.knowledge_type = newType;
// Also update in metadata for consistency
updatedItem.metadata = {
...updatedItem.metadata,
knowledge_type: newType,
};
}
// Synchronize metadata with top-level fields
updatedItem.metadata = {
...currentMetadata,
...((updatedItem as any).tags && { tags: (updatedItem as any).tags }),
...(updatedItem.knowledge_type && { knowledge_type: updatedItem.knowledge_type }),
};
queryClient.setQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId), updatedItem);
}
// Optimistically update summaries cache
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summary() }, (old) => {
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old?.items) return old;
return {
@@ -607,25 +655,26 @@ export function useUpdateKnowledgeItem() {
updatedItem.title = updates.title;
}
// Update tags if provided
// Update tags if provided - update in metadata only
if ("tags" in updates && Array.isArray(updates.tags)) {
const newTags = updates.tags as string[];
updatedItem.tags = newTags;
updatedItem.metadata = {
...currentMetadata,
tags: newTags,
};
}
// Update knowledge_type if provided
if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") {
const newType = updates.knowledge_type as "technical" | "business";
updatedItem.knowledge_type = newType;
// Also update in metadata for consistency
updatedItem.metadata = {
...updatedItem.metadata,
knowledge_type: newType,
};
}
// Synchronize metadata with top-level fields
updatedItem.metadata = {
...currentMetadata,
...(updatedItem.tags && { tags: updatedItem.tags }),
...(updatedItem.knowledge_type && { knowledge_type: updatedItem.knowledge_type }),
};
return updatedItem;
}
return item;
@@ -656,7 +705,7 @@ export function useUpdateKnowledgeItem() {
// Invalidate all related queries
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); // Add summaries cache
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() }); // Add summaries cache
},
});
}
@@ -722,14 +771,16 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
}, [activeOperationsData]);
// Fetch summaries with smart polling when there are active operations
const { refetchInterval } = useSmartPolling(hasActiveOperations ? 5000 : 30000);
const { refetchInterval } = useSmartPolling(
hasActiveOperations ? STALE_TIMES.frequent : STALE_TIMES.normal,
);
const summaryQuery = useQuery<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summaries(filter),
queryFn: () => knowledgeService.getKnowledgeSummaries(filter),
refetchInterval: hasActiveOperations ? refetchInterval : false, // Poll when ANY operations are active
refetchOnWindowFocus: true,
staleTime: 30000, // Consider data stale after 30 seconds
staleTime: STALE_TIMES.normal, // Consider data stale after 30 seconds
});
// When operations complete, remove them from tracking
@@ -751,13 +802,13 @@ export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
// Invalidate after a delay to allow backend database to become consistent
const timer = setTimeout(() => {
// Invalidate all summaries regardless of filter
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Also invalidate lists for consistency
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
// For uploads, also refetch immediately to ensure UI shows the item
if (hasCompletedUpload) {
queryClient.refetchQueries({ queryKey: knowledgeKeys.summary() });
queryClient.refetchQueries({ queryKey: knowledgeKeys.summariesPrefix() });
}
}, delay);
@@ -782,8 +833,8 @@ export function useKnowledgeChunks(
) {
return useQuery({
queryKey: sourceId
? [...knowledgeKeys.detail(sourceId), "chunks", options?.limit, options?.offset]
: ["chunks-undefined"],
? knowledgeKeys.chunks(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
@@ -792,7 +843,7 @@ export function useKnowledgeChunks(
})
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: 60000, // Cache for 1 minute
staleTime: STALE_TIMES.normal,
});
}
@@ -805,8 +856,8 @@ export function useKnowledgeCodeExamples(
) {
return useQuery({
queryKey: sourceId
? [...knowledgeKeys.codeExamples(sourceId), options?.limit, options?.offset]
: ["code-examples-undefined"],
? knowledgeKeys.codeExamples(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
sourceId
? knowledgeService.getCodeExamples(sourceId, {
@@ -815,6 +866,6 @@ export function useKnowledgeCodeExamples(
})
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: 60000, // Cache for 1 minute
staleTime: STALE_TIMES.normal,
});
}

View File

@@ -5,7 +5,6 @@
* - Knowledge item management (CRUD, search)
* - Crawling and URL processing
* - Document upload and processing
* - Progress tracking for long-running operations
* - Document browsing and viewing
*/
@@ -13,8 +12,6 @@
export * from "./components";
// Hooks
export * from "./hooks";
// Sub-features
export * from "./progress";
// Services
export * from "./services";
// Types

View File

@@ -65,7 +65,10 @@ export const knowledgeService = {
/**
* Update a knowledge item
*/
async updateKnowledgeItem(sourceId: string, updates: Partial<KnowledgeItem>): Promise<KnowledgeItem> {
async updateKnowledgeItem(
sourceId: string,
updates: Partial<KnowledgeItem> & { tags?: string[] },
): Promise<KnowledgeItem> {
const response = await callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
method: "PUT",
body: JSON.stringify(updates),

View File

@@ -1,2 +1 @@
export * from "../progress/types";
export * from "./knowledge";

View File

@@ -4,14 +4,14 @@
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { CrawlingProgress } from "../../progress/components/CrawlingProgress";
import type { ActiveOperation } from "../../progress/types";
import { useToast } from "../../ui/hooks/useToast";
import { AddKnowledgeDialog } from "../components/AddKnowledgeDialog";
import { KnowledgeHeader } from "../components/KnowledgeHeader";
import { KnowledgeList } from "../components/KnowledgeList";
import { useKnowledgeSummaries } from "../hooks/useKnowledgeQueries";
import { KnowledgeInspector } from "../inspector/components/KnowledgeInspector";
import { CrawlingProgress } from "../progress/components/CrawlingProgress";
import type { ActiveOperation } from "../progress/types";
import type { KnowledgeItem, KnowledgeItemsFilter } from "../types";
export const KnowledgeView = () => {

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { mcpApi } from "../services";
@@ -9,6 +10,7 @@ export const mcpKeys = {
config: () => [...mcpKeys.all, "config"] as const,
sessions: () => [...mcpKeys.all, "sessions"] as const,
clients: () => [...mcpKeys.all, "clients"] as const,
health: () => [...mcpKeys.all, "health"] as const,
};
export function useMcpStatus() {
@@ -19,7 +21,7 @@ export function useMcpStatus() {
queryFn: () => mcpApi.getStatus(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 3000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}
@@ -28,7 +30,7 @@ export function useMcpConfig() {
return useQuery({
queryKey: mcpKeys.config(),
queryFn: () => mcpApi.getConfig(),
staleTime: Infinity, // Config rarely changes
staleTime: STALE_TIMES.static, // Config rarely changes
throwOnError: true,
});
}
@@ -41,7 +43,7 @@ export function useMcpClients() {
queryFn: () => mcpApi.getClients(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 8000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}
@@ -54,7 +56,7 @@ export function useMcpSessionInfo() {
queryFn: () => mcpApi.getSessionInfo(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 8000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}

View File

@@ -7,9 +7,9 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle, Globe, Loader2, StopCircle, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../ui/primitives";
import { cn } from "../../../ui/primitives/styles";
import { useStopCrawl } from "../../hooks";
import { useStopCrawl } from "../../knowledge/hooks";
import { Button } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { useCrawlProgressPolling } from "../hooks";
import type { ActiveOperation } from "../types/progress";

View File

@@ -6,7 +6,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle2, Code, FileText, Link, Loader2 } from "lucide-react";
import { cn } from "../../../ui/primitives/styles";
import { cn } from "../../ui/primitives/styles";
import type { ActiveOperation } from "../types/progress";
interface KnowledgeCardProgressProps {

View File

@@ -0,0 +1,260 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveOperationsResponse, ProgressResponse } from "../../types";
import {
progressKeys,
useActiveOperations,
useCrawlProgressPolling,
useOperationProgress,
} from "../useProgressQueries";
// Mock the services
vi.mock("../../services", () => ({
progressService: {
getProgress: vi.fn(),
listActiveOperations: vi.fn(),
},
}));
// Mock shared query patterns
vi.mock("../../../shared/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Infinity,
},
}));
// Test wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe("useProgressQueries", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("progressKeys", () => {
it("should generate correct query keys", () => {
expect(progressKeys.all).toEqual(["progress"]);
expect(progressKeys.lists()).toEqual(["progress", "list"]);
expect(progressKeys.detail("progress-123")).toEqual(["progress", "detail", "progress-123"]);
expect(progressKeys.active()).toEqual(["progress", "active"]);
});
});
describe("useOperationProgress", () => {
it("should poll for progress when progressId is provided", async () => {
const mockProgress: ProgressResponse = {
progressId: "progress-123",
status: "processing",
message: "Processing...",
progress: 50,
details: {},
};
const { progressService } = await import("../../services");
vi.mocked(progressService.getProgress).mockResolvedValue(mockProgress);
const { result } = renderHook(() => useOperationProgress("progress-123"), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.data).toEqual(mockProgress);
expect(progressService.getProgress).toHaveBeenCalledWith("progress-123");
});
});
it("should call onComplete callback when operation completes", async () => {
const onComplete = vi.fn();
const completedProgress: ProgressResponse = {
progressId: "progress-123",
status: "completed",
message: "Completed",
progress: 100,
details: { result: "success" },
};
const { progressService } = await import("../../services");
vi.mocked(progressService.getProgress).mockResolvedValue(completedProgress);
const { result } = renderHook(() => useOperationProgress("progress-123", { onComplete }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.data?.status).toBe("completed");
expect(onComplete).toHaveBeenCalledWith(completedProgress);
});
});
it("should call onError callback when operation fails", async () => {
const onError = vi.fn();
const errorProgress: ProgressResponse = {
progressId: "progress-123",
status: "error",
message: "Failed to process",
progress: 0,
error: "Something went wrong",
};
const { progressService } = await import("../../services");
vi.mocked(progressService.getProgress).mockResolvedValue(errorProgress);
const { result } = renderHook(() => useOperationProgress("progress-123", { onError }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.data?.status).toBe("error");
// onError is called with just the error string, not the full response
expect(onError).toHaveBeenCalledWith("Something went wrong");
});
});
it("should not execute query when progressId is null", () => {
const { result } = renderHook(() => useOperationProgress(null), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
});
});
describe("useActiveOperations", () => {
it("should fetch active operations when enabled", async () => {
const mockOperations: ActiveOperationsResponse = {
operations: [
{
progressId: "op-1",
sourceId: "source-1",
status: "processing",
message: "Processing document",
progress: 30,
},
{
progressId: "op-2",
sourceId: "source-2",
status: "processing",
message: "Crawling website",
progress: 60,
},
],
};
const { progressService } = await import("../../services");
vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations);
const { result } = renderHook(() => useActiveOperations(true), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(mockOperations);
expect(progressService.listActiveOperations).toHaveBeenCalled();
});
});
it("should not fetch when disabled", () => {
const { result } = renderHook(() => useActiveOperations(false), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined();
});
});
describe("useCrawlProgressPolling", () => {
it("should poll for active crawl operations", async () => {
const mockOperations: ActiveOperationsResponse = {
operations: [
{
progressId: "crawl-1",
sourceId: "source-1",
status: "processing",
message: "Crawling page 1 of 5",
progress: 20,
},
],
};
const { progressService } = await import("../../services");
vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations);
const { result } = renderHook(() => useCrawlProgressPolling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.activeOperations).toEqual(mockOperations.operations);
});
});
it("should return empty array when no operations", async () => {
const emptyResponse: ActiveOperationsResponse = {
operations: [],
count: 0,
};
const { progressService } = await import("../../services");
vi.mocked(progressService.listActiveOperations).mockResolvedValue(emptyResponse);
const { result } = renderHook(() => useCrawlProgressPolling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.activeOperations).toEqual([]);
expect(result.current.totalCount).toBe(0);
});
});
it("should identify active operations correctly", async () => {
const mockOperations: ActiveOperationsResponse = {
operations: [
{
progressId: "op-1",
sourceId: "source-1",
status: "processing",
message: "Active operation",
progress: 50,
},
],
count: 1,
};
const { progressService } = await import("../../services");
vi.mocked(progressService.listActiveOperations).mockResolvedValue(mockOperations);
const { result } = renderHook(() => useCrawlProgressPolling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.activeOperations).toHaveLength(1);
expect(result.current.totalCount).toBe(1);
});
});
});
});

View File

@@ -3,16 +3,20 @@
* Handles polling for operation progress with TanStack Query
*/
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
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,
detail: (progressId: string) => [...progressKeys.all, progressId] as const,
list: () => [...progressKeys.all, "list"] 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
@@ -34,6 +38,7 @@ export function useOperationProgress(
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(() => {
@@ -43,7 +48,7 @@ export function useOperationProgress(
}, [progressId]);
const query = useQuery<ProgressResponse | null>({
queryKey: progressId ? progressKeys.detail(progressId) : ["progress-undefined"],
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
queryFn: async () => {
if (!progressId) throw new Error("No progress ID");
@@ -52,9 +57,14 @@ export function useOperationProgress(
consecutiveNotFound.current = 0; // Reset counter on success
return data;
} catch (error: unknown) {
// Handle 404 errors specially
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
// 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
@@ -79,14 +89,16 @@ export function useOperationProgress(
}
// Keep polling on undefined (initial), null (transient 404), or active operations
return options?.pollingInterval ?? 1000;
// Use smart interval that pauses when tab is hidden
return smartInterval;
},
retry: false, // Don't retry on error
staleTime: 0, // Always refetch
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;
@@ -97,11 +109,13 @@ export function useOperationProgress(
options?.onComplete?.(query.data);
// Clean up the query after completion
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}
}, 2000);
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 2000),
);
}
// Handle cancellation
@@ -110,11 +124,13 @@ export function useOperationProgress(
options?.onError?.(query.data.error || "Operation was cancelled");
// Clean up the query after cancellation
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}
}, 2000);
timers.push(
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}
}, 2000),
);
}
// Handle errors
@@ -123,16 +139,24 @@ export function useOperationProgress(
options?.onError?.(query.data.error || "Operation failed");
// Clean up the query after error
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}
}, 5000);
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;
@@ -140,11 +164,18 @@ export function useOperationProgress(
options?.onError?.(errorMessage);
// Clean up the query after error
setTimeout(() => {
if (progressId) {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}
}, 5000);
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 {
@@ -163,31 +194,27 @@ export function useOperationProgress(
* @param enabled - Whether to enable polling (default: false)
*/
export function useActiveOperations(enabled = false) {
const { refetchInterval } = useSmartPolling(5000);
return useQuery<ActiveOperationsResponse>({
queryKey: progressKeys.list(),
queryKey: progressKeys.active(),
queryFn: () => progressService.listActiveOperations(),
enabled,
refetchInterval: enabled ? 5000 : false, // Only poll when explicitly enabled
staleTime: 3000,
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 } = useQuery({
queryKey: progressKeys.list(),
queryFn: () => progressService.listActiveOperations(),
refetchInterval: 5000, // Poll every 5 seconds
staleTime: 0,
});
const activeOperations = data?.operations || [];
const { data, isLoading } = useActiveOperations(true); // Always enabled for crawling progress
return {
activeOperations,
activeOperations: data?.operations || [],
isLoading,
totalCount: data?.count || 0,
};
@@ -209,26 +236,34 @@ export function useMultipleOperations(
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();
}, [progressIds.join(",")]); // Use join to create stable dependency
}, [progressIdsKey]); // Stable dependency across reorderings
const queries = (useQueries as any)({
const queries = useQueries({
queries: progressIds.map((progressId) => ({
queryKey: progressKeys.detail(progressId) as readonly unknown[],
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
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
// 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);
@@ -244,8 +279,8 @@ export function useMultipleOperations(
throw error;
}
},
refetchInterval: (query: { state: { data?: ProgressResponse } }) => {
const data = query.state.data as ProgressResponse | null | undefined;
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)) {
@@ -253,16 +288,19 @@ export function useMultipleOperations(
}
// Keep polling on undefined (initial), null (transient 404), or active operations
return 1000;
// Use smart interval that pauses when tab is hidden
return smartInterval;
},
retry: false,
staleTime: 0,
staleTime: STALE_TIMES.instant, // Always fresh for real-time progress
})),
});
}) as UseQueryResult<ProgressResponse | null, Error>[];
// Handle callbacks for each operation
useEffect(() => {
queries.forEach((query: any, index: number) => {
const timers: ReturnType<typeof setTimeout>[] = [];
queries.forEach((query, index) => {
const progressId = progressIds[index];
if (!query.data || !progressId) return;
@@ -277,9 +315,11 @@ export function useMultipleOperations(
options?.onComplete?.(progressId, data);
// Clean up after completion
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}, 2000);
timers.push(
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
}, 2000),
);
}
// Handle errors
@@ -288,16 +328,25 @@ export function useMultipleOperations(
options?.onError?.(progressId, data.error || "Operation failed");
// Clean up after error
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}, 5000);
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(() => {
queries.forEach((query: any, index: number) => {
const timers: ReturnType<typeof setTimeout>[] = [];
queries.forEach((query, index) => {
const progressId = progressIds[index];
if (!query.error || !progressId || errorIds.current.has(progressId)) return;
@@ -306,13 +355,20 @@ export function useMultipleOperations(
options?.onError?.(progressId, errorMessage);
// Clean up after error
setTimeout(() => {
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId) });
}, 5000);
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: any, index: number) => {
return queries.map((query, index) => {
const data = query.data as ProgressResponse | null;
return {
progressId: progressIds[index],

View File

@@ -3,7 +3,7 @@
* Uses ETag support for efficient polling
*/
import { callAPIWithETag } from "../../../shared/apiWithEtag";
import { callAPIWithETag } from "../../shared/apiWithEtag";
import type { ActiveOperationsResponse, ProgressResponse } from "../types";
export const progressService = {

View File

@@ -1,10 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
import { projectService } from "../../services";
import type { ProjectDocument } from "../types";
// Query keys
const documentKeys = {
all: (projectId: string) => ["projects", projectId, "documents"] as const,
// Query keys factory for documents
export const documentKeys = {
all: ["documents"] as const,
byProject: (projectId: string) => ["projects", projectId, "documents"] as const,
detail: (projectId: string, docId: string) => ["projects", projectId, "documents", "detail", docId] as const,
versions: (projectId: string) => ["projects", projectId, "versions"] as const,
version: (projectId: string, fieldName: string, version: number) =>
["projects", projectId, "versions", fieldName, version] as const,
};
/**
@@ -13,12 +19,13 @@ const documentKeys = {
*/
export function useProjectDocuments(projectId: string | undefined) {
return useQuery({
queryKey: projectId ? documentKeys.all(projectId) : ["documents-undefined"],
queryKey: projectId ? documentKeys.byProject(projectId) : DISABLED_QUERY_KEY,
queryFn: async () => {
if (!projectId) return [];
const project = await projectService.getProject(projectId);
return (project.docs || []) as ProjectDocument[];
},
enabled: !!projectId,
staleTime: STALE_TIMES.normal,
});
}

View File

@@ -15,6 +15,5 @@ export {
useDeleteProject,
useProjectFeatures,
useProjects,
useTaskCounts,
useUpdateProject,
} from "./useProjectQueries";

View File

@@ -57,9 +57,7 @@ describe("useProjectQueries", () => {
expect(projectKeys.all).toEqual(["projects"]);
expect(projectKeys.lists()).toEqual(["projects", "list"]);
expect(projectKeys.detail("123")).toEqual(["projects", "detail", "123"]);
expect(projectKeys.tasks("123")).toEqual(["projects", "detail", "123", "tasks"]);
expect(projectKeys.features("123")).toEqual(["projects", "detail", "123", "features"]);
expect(projectKeys.documents("123")).toEqual(["projects", "detail", "123", "documents"]);
expect(projectKeys.features("123")).toEqual(["projects", "123", "features"]);
});
});

View File

@@ -1,20 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { useToast } from "../../ui/hooks/useToast";
import { projectService, taskService } from "../services";
import { projectService } from "../services";
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../types";
// Query keys factory for better organization
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters?: unknown) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
tasks: (projectId: string) => [...projectKeys.detail(projectId), "tasks"] as const,
taskCounts: () => ["taskCounts"] as const,
features: (projectId: string) => [...projectKeys.detail(projectId), "features"] as const,
documents: (projectId: string) => [...projectKeys.detail(projectId), "documents"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
features: (id: string) => [...projectKeys.all, id, "features"] as const,
// Documents keys moved to documentKeys in documents feature
// Tasks keys moved to taskKeys in tasks feature
};
// Fetch all projects with smart polling
@@ -26,27 +24,19 @@ export function useProjects() {
queryFn: () => projectService.listProjects(),
refetchInterval, // Smart interval based on page visibility/focus
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
staleTime: 15000, // Consider data stale after 15 seconds
});
}
// Fetch task counts for all projects
export function useTaskCounts() {
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
queryKey: projectKeys.taskCounts(),
queryFn: () => taskService.getTaskCountsForAllProjects(),
refetchInterval: false, // Don't poll, only refetch manually
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
staleTime: STALE_TIMES.normal,
});
}
// Fetch project features
export function useProjectFeatures(projectId: string | undefined) {
// TODO: Phase 4 - Add explicit typing: useQuery<Awaited<ReturnType<typeof projectService.getProjectFeatures>>>
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
return useQuery({
queryKey: projectId ? projectKeys.features(projectId) : ["features-undefined"],
queryKey: projectId ? projectKeys.features(projectId) : DISABLED_QUERY_KEY,
queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")),
enabled: !!projectId,
staleTime: 30000, // Cache for 30 seconds
staleTime: STALE_TIMES.normal,
});
}

View File

@@ -1,7 +1,17 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/primitives";
import {
ComboBox,
type ComboBoxOption,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../ui/primitives";
import { cn } from "../../../ui/primitives/styles";
import { COMMON_ASSIGNEES } from "../types";
interface EditableTableCellProps {
value: string;
@@ -16,8 +26,11 @@ interface EditableTableCellProps {
// Status options for the status select
const STATUS_OPTIONS = ["todo", "doing", "review", "done"] as const;
// Assignee options
const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const;
// Convert common assignees to ComboBox options
const ASSIGNEE_OPTIONS: ComboBoxOption[] = COMMON_ASSIGNEES.map((name) => ({
value: name,
label: name,
}));
export const EditableTableCell = ({
value,
@@ -81,7 +94,7 @@ export const EditableTableCell = ({
};
// Get the appropriate options based on type
const selectOptions = type === "status" ? STATUS_OPTIONS : type === "assignee" ? ASSIGNEE_OPTIONS : options || [];
const selectOptions = type === "status" ? STATUS_OPTIONS : options || [];
if (!isEditing) {
return (
@@ -106,13 +119,40 @@ export const EditableTableCell = ({
)}
title={value || placeholder}
>
<span className={cn(!value && "text-gray-400 italic")}>{value || placeholder}</span>
<span className={cn(!value && "text-gray-400 italic")}>
{/* Truncate long assignee names */}
{type === "assignee" && value && value.length > 20 ? `${value.slice(0, 17)}...` : value || placeholder}
</span>
</div>
);
}
// Render select for select types
if (type === "select" || type === "status" || type === "assignee") {
// Render ComboBox for assignee type
if (type === "assignee") {
return (
<ComboBox
options={ASSIGNEE_OPTIONS}
value={editValue}
onValueChange={(newValue) => {
setEditValue(newValue);
// Auto-save on change
setTimeout(() => {
onSave(newValue);
setIsEditing(false);
}, 0);
}}
placeholder="Select assignee..."
searchPlaceholder="Assign to..."
emptyMessage="Press Enter to add"
className={cn("w-full h-7 text-sm", className)}
allowCustomValue={true}
disabled={isSaving}
/>
);
}
// Render select for select/status types
if (type === "select" || type === "status") {
return (
<Select
value={editValue}

View File

@@ -1,8 +1,8 @@
import { Bot, User } from "lucide-react";
import type React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "../../../ui/primitives";
import { ComboBox, type ComboBoxOption } from "../../../ui/primitives/combobox";
import { cn } from "../../../ui/primitives/styles";
import type { Assignee } from "../types";
import { type Assignee, COMMON_ASSIGNEES } from "../types";
interface TaskAssigneeProps {
assignee: Assignee;
@@ -10,61 +10,90 @@ interface TaskAssigneeProps {
isLoading?: boolean;
}
const ASSIGNEE_OPTIONS: Assignee[] = ["User", "Archon", "AI IDE Agent"];
// Convert common assignees to ComboBox options
const ASSIGNEE_OPTIONS: ComboBoxOption[] = COMMON_ASSIGNEES.map((name) => ({
value: name,
label: name,
}));
// Get icon for each assignee type
const getAssigneeIcon = (assigneeName: Assignee, size: "sm" | "md" = "sm") => {
// Truncate long assignee names for display
const truncateAssignee = (assignee: string, maxLength = 20) => {
if (assignee.length <= maxLength) return assignee;
return `${assignee.slice(0, maxLength - 3)}...`;
};
// Get icon for assignee (with fallback for custom agents)
const getAssigneeIcon = (assigneeName: string, size: "sm" | "md" = "sm") => {
const sizeClass = size === "sm" ? "w-3 h-3" : "w-4 h-4";
switch (assigneeName) {
case "User":
return <User className={cn(sizeClass, "text-blue-400")} />;
case "AI IDE Agent":
return <Bot className={cn(sizeClass, "text-purple-400")} />;
case "Archon":
return <img src="/logo-neon.png" alt="Archon" className={sizeClass} />;
default:
return <User className={cn(sizeClass, "text-blue-400")} />;
// Known assignees get specific icons
if (assigneeName === "User") {
return <User className={cn(sizeClass, "text-blue-400")} />;
}
if (assigneeName === "Archon") {
return <img src="/logo-neon.png" alt="Archon" className={sizeClass} />;
}
if (
assigneeName === "Coding Agent" ||
assigneeName.toLowerCase().includes("agent") ||
assigneeName.toLowerCase().includes("ai")
) {
return <Bot className={cn(sizeClass, "text-purple-400")} />;
}
// Unknown agents get a bot icon with first letter overlay
return (
<div className="relative flex items-center justify-center">
<Bot className={cn(sizeClass, "text-gray-400 opacity-60")} />
<span className="absolute text-[8px] font-bold text-white/90">{assigneeName[0]?.toUpperCase() || "?"}</span>
</div>
);
};
// Get glow effect for each assignee type
const getAssigneeStyles = (assigneeName: Assignee) => {
switch (assigneeName) {
case "User":
return {
glow: "shadow-[0_0_10px_rgba(59,130,246,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(59,130,246,0.5)]",
color: "text-blue-600 dark:text-blue-400",
};
case "AI IDE Agent":
return {
glow: "shadow-[0_0_10px_rgba(168,85,247,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(168,85,247,0.5)]",
color: "text-purple-600 dark:text-purple-400",
};
case "Archon":
return {
glow: "shadow-[0_0_10px_rgba(34,211,238,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(34,211,238,0.5)]",
color: "text-cyan-600 dark:text-cyan-400",
};
default:
return {
glow: "shadow-[0_0_10px_rgba(59,130,246,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(59,130,246,0.5)]",
color: "text-blue-600 dark:text-blue-400",
};
// Get glow effect styles based on assignee type
const getAssigneeStyles = (assigneeName: string) => {
// Known assignees get specific colors
if (assigneeName === "User") {
return {
glow: "shadow-[0_0_10px_rgba(59,130,246,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(59,130,246,0.5)]",
color: "text-blue-600 dark:text-blue-400",
};
}
if (assigneeName === "Archon") {
return {
glow: "shadow-[0_0_10px_rgba(34,211,238,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(34,211,238,0.5)]",
color: "text-cyan-600 dark:text-cyan-400",
};
}
if (
assigneeName === "Coding Agent" ||
assigneeName.toLowerCase().includes("agent") ||
assigneeName.toLowerCase().includes("ai")
) {
return {
glow: "shadow-[0_0_10px_rgba(168,85,247,0.4)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(168,85,247,0.5)]",
color: "text-purple-600 dark:text-purple-400",
};
}
// Custom agents get a neutral glow
return {
glow: "shadow-[0_0_10px_rgba(156,163,175,0.3)]",
hoverGlow: "hover:shadow-[0_0_12px_rgba(156,163,175,0.4)]",
color: "text-gray-600 dark:text-gray-400",
};
};
export const TaskAssignee: React.FC<TaskAssigneeProps> = ({ assignee, onAssigneeChange, isLoading = false }) => {
export const TaskAssignee: React.FC<TaskAssigneeProps> = ({ assignee, onAssigneeChange, isLoading }) => {
const styles = getAssigneeStyles(assignee);
// If no change handler, just show a static display
if (!onAssigneeChange) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" title={assignee}>
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full",
@@ -76,66 +105,33 @@ export const TaskAssignee: React.FC<TaskAssigneeProps> = ({ assignee, onAssignee
>
{getAssigneeIcon(assignee, "md")}
</div>
<span className="text-gray-600 dark:text-gray-400 text-xs">{assignee}</span>
<span className={cn("text-xs truncate max-w-[150px]", "text-gray-600 dark:text-gray-400")}>
{truncateAssignee(assignee, 25)}
</span>
</div>
);
}
// For editable mode, use a streamlined ComboBox
return (
<Select value={assignee} onValueChange={(value) => onAssigneeChange(value as Assignee)}>
<SelectTrigger
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// Stop propagation for all keys to prevent TaskCard from handling them
e.stopPropagation();
}}
>
<ComboBox
options={ASSIGNEE_OPTIONS}
value={assignee}
onValueChange={onAssigneeChange}
placeholder="Assignee"
searchPlaceholder="Assign to..."
emptyMessage="Press Enter to add"
className="min-w-[90px] max-w-[140px]"
allowCustomValue={true}
disabled={isLoading}
className={cn(
"h-auto py-0.5 px-1.5 gap-1.5",
"border-0 shadow-none bg-transparent",
"hover:bg-gray-100/50 dark:hover:bg-gray-900/50",
"transition-all duration-200 rounded-md",
"min-w-fit w-auto",
)}
showChevron={false}
aria-label={`Assignee: ${assignee}${isLoading ? " (updating...)" : ""}`}
aria-disabled={isLoading}
>
<div className="flex items-center gap-1.5">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full",
"bg-white/80 dark:bg-black/70",
"border border-gray-300/50 dark:border-gray-700/50",
"backdrop-blur-md transition-shadow duration-200",
styles.glow,
styles.hoverGlow,
)}
>
{getAssigneeIcon(assignee, "md")}
</div>
<span className={cn("text-xs", styles.color)}>{assignee}</span>
</div>
</SelectTrigger>
<SelectContent className="min-w-[140px]">
{ASSIGNEE_OPTIONS.map((option) => {
const optionStyles = getAssigneeStyles(option);
return (
<SelectItem key={option} value={option}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full",
"bg-white/80 dark:bg-black/70",
"border border-gray-300/50 dark:border-gray-700/50",
optionStyles.glow,
)}
>
{getAssigneeIcon(option, "md")}
</div>
<span className={cn("text-sm", optionStyles.color)}>{option}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
/>
</div>
);
};

View File

@@ -1,6 +1,8 @@
import { memo, useCallback, useEffect, useState } from "react";
import {
Button,
ComboBox,
type ComboBoxOption,
Dialog,
DialogContent,
DialogFooter,
@@ -18,7 +20,7 @@ import {
TextArea,
} from "../../../ui/primitives";
import { useTaskEditor } from "../hooks";
import type { Assignee, Task, TaskPriority } from "../types";
import { type Assignee, COMMON_ASSIGNEES, type Task, type TaskPriority } from "../types";
import { FeatureSelect } from "./FeatureSelect";
interface TaskEditModalProps {
@@ -30,7 +32,13 @@ interface TaskEditModalProps {
onOpenChange?: (open: boolean) => void;
}
const ASSIGNEE_OPTIONS = ["User", "Archon", "AI IDE Agent"] as const;
// Convert common assignees to ComboBox options
const ASSIGNEE_OPTIONS: ComboBoxOption[] = COMMON_ASSIGNEES.map((name) => ({
value: name,
label: name,
description:
name === "User" ? "Assign to human user" : name === "Archon" ? "Assign to Archon system" : "Assign to Coding Agent",
}));
export const TaskEditModal = memo(
({ isModalOpen, editingTask, projectId, onClose, onSaved, onOpenChange }: TaskEditModalProps) => {
@@ -153,23 +161,16 @@ export const TaskEditModal = memo(
<FormGrid columns={2}>
<FormField>
<Label>Assignee</Label>
<Select
<ComboBox
options={ASSIGNEE_OPTIONS}
value={localTask?.assignee || "User"}
onValueChange={(value) =>
setLocalTask((prev) => (prev ? { ...prev, assignee: value as Assignee } : null))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNEE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
onValueChange={(value) => setLocalTask((prev) => (prev ? { ...prev, assignee: value } : null))}
placeholder="Select or type assignee..."
searchPlaceholder="Search or enter custom..."
emptyMessage="Type a custom assignee name"
className="w-full"
allowCustomValue={true}
/>
</FormField>
<FormField>

View File

@@ -15,5 +15,6 @@ export {
useCreateTask,
useDeleteTask,
useProjectTasks,
useTaskCounts,
useUpdateTask,
} from "./useTaskQueries";

View File

@@ -3,12 +3,13 @@ import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "../../types";
import { taskKeys, useCreateTask, useProjectTasks } from "../useTaskQueries";
import { taskKeys, useCreateTask, useProjectTasks, useTaskCounts } from "../useTaskQueries";
// Mock the services
vi.mock("../../services", () => ({
taskService: {
getTasksByProject: vi.fn(),
getTaskCountsForAllProjects: vi.fn(),
createTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
@@ -54,7 +55,11 @@ describe("useTaskQueries", () => {
describe("taskKeys", () => {
it("should generate correct query keys", () => {
expect(taskKeys.all("project-123")).toEqual(["projects", "project-123", "tasks"]);
expect(taskKeys.all).toEqual(["tasks"]);
expect(taskKeys.lists()).toEqual(["tasks", "list"]);
expect(taskKeys.detail("task-123")).toEqual(["tasks", "detail", "task-123"]);
expect(taskKeys.byProject("project-123")).toEqual(["projects", "project-123", "tasks"]);
expect(taskKeys.counts()).toEqual(["tasks", "counts"]);
});
});

View File

@@ -1,21 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
import { useSmartPolling } from "../../../ui/hooks";
import { useToast } from "../../../ui/hooks/useToast";
import { projectKeys } from "../../hooks/useProjectQueries";
import { taskService } from "../services";
import type { CreateTaskRequest, Task, UpdateTaskRequest } from "../types";
// Query keys factory for tasks
// Query keys factory for tasks - supports dual backend nature
export const taskKeys = {
all: (projectId: string) => ["projects", projectId, "tasks"] as const,
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const, // For /api/tasks
detail: (id: string) => [...taskKeys.all, "detail", id] as const, // For /api/tasks/{id}
byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // For /api/projects/{id}/tasks
counts: () => [...taskKeys.all, "counts"] as const, // For /api/projects/task-counts
};
// Fetch tasks for a specific project
export function useProjectTasks(projectId: string | undefined, enabled = true) {
const { refetchInterval } = useSmartPolling(5000); // 5 second base interval for faster MCP updates
const { refetchInterval } = useSmartPolling(2000); // 2s active per guideline for real-time task updates
return useQuery<Task[]>({
queryKey: projectId ? taskKeys.all(projectId) : ["tasks-undefined"],
queryKey: projectId ? taskKeys.byProject(projectId) : DISABLED_QUERY_KEY,
queryFn: async () => {
if (!projectId) throw new Error("No project ID");
return taskService.getTasksByProject(projectId);
@@ -23,7 +27,17 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) {
enabled: !!projectId && enabled,
refetchInterval, // Smart interval based on page visibility/focus
refetchOnWindowFocus: true, // Refetch immediately when tab gains focus (ETag makes this cheap)
staleTime: 10000, // Consider data stale after 10 seconds
staleTime: STALE_TIMES.frequent,
});
}
// Fetch task counts for all projects
export function useTaskCounts() {
return useQuery<Awaited<ReturnType<typeof taskService.getTaskCountsForAllProjects>>>({
queryKey: taskKeys.counts(),
queryFn: () => taskService.getTaskCountsForAllProjects(),
refetchInterval: false, // Don't poll, only refetch manually
staleTime: STALE_TIMES.rare,
});
}
@@ -36,10 +50,10 @@ export function useCreateTask() {
mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData),
onMutate: async (newTaskData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.all(newTaskData.project_id) });
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(newTaskData.project_id) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(taskKeys.all(newTaskData.project_id));
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(newTaskData.project_id));
// Create optimistic task with temporary ID
const tempId = `temp-${Date.now()}`;
@@ -48,14 +62,14 @@ export function useCreateTask() {
...newTaskData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
// Ensure all required fields have defaults
// Ensure all required fields have defaults (let backend handle assignee default)
task_order: newTaskData.task_order ?? 100,
status: newTaskData.status ?? "todo",
assignee: newTaskData.assignee ?? "User",
assignee: newTaskData.assignee ?? "User", // Keep for now as UI needs a value for optimistic update
} as Task;
// Optimistically add the new task
queryClient.setQueryData(taskKeys.all(newTaskData.project_id), (old: Task[] | undefined) => {
queryClient.setQueryData(taskKeys.byProject(newTaskData.project_id), (old: Task[] | undefined) => {
if (!old) return [optimisticTask];
return [...old, optimisticTask];
});
@@ -67,13 +81,13 @@ export function useCreateTask() {
console.error("Failed to create task:", error, { variables });
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.all(variables.project_id), context.previousTasks);
queryClient.setQueryData(taskKeys.byProject(variables.project_id), context.previousTasks);
}
showToast(`Failed to create task: ${errorMessage}`, "error");
},
onSuccess: (data, variables, context) => {
// Replace optimistic task with real one from server
queryClient.setQueryData(taskKeys.all(variables.project_id), (old: Task[] | undefined) => {
queryClient.setQueryData(taskKeys.byProject(variables.project_id), (old: Task[] | undefined) => {
if (!old) return [data];
// Replace only the specific temp task with real one
return old
@@ -84,12 +98,12 @@ export function useCreateTask() {
index === self.findIndex((t) => t.id === task.id),
);
});
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
showToast("Task created successfully", "success");
},
onSettled: (_data, _error, variables) => {
// Always refetch to ensure consistency after operation completes
queryClient.invalidateQueries({ queryKey: taskKeys.all(variables.project_id) });
queryClient.invalidateQueries({ queryKey: taskKeys.byProject(variables.project_id) });
},
});
}
@@ -104,13 +118,13 @@ export function useUpdateTask(projectId: string) {
taskService.updateTask(taskId, updates),
onMutate: async ({ taskId, updates }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) });
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.all(projectId));
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(projectId));
// Optimistically update
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) => {
queryClient.setQueryData<Task[]>(taskKeys.byProject(projectId), (old) => {
if (!old) return old;
return old.map((task) => (task.id === taskId ? { ...task, ...updates } : task));
});
@@ -122,21 +136,24 @@ export function useUpdateTask(projectId: string) {
console.error("Failed to update task:", error, { variables });
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks);
queryClient.setQueryData(taskKeys.byProject(projectId), context.previousTasks);
}
showToast(`Failed to update task: ${errorMessage}`, "error");
// Refetch on error to ensure consistency
queryClient.invalidateQueries({ queryKey: taskKeys.all(projectId) });
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) });
// Only invalidate counts if status was changed
if (variables.updates?.status) {
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
}
},
onSuccess: (data, { updates }) => {
// Merge server response to keep timestamps and computed fields in sync
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) =>
queryClient.setQueryData<Task[]>(taskKeys.byProject(projectId), (old) =>
old ? old.map((t) => (t.id === data.id ? data : t)) : old,
);
// Only invalidate counts if status changed (which affects counts)
if (updates.status) {
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
// Show toast for significant status changes
showToast(`Task moved to ${updates.status}`, "success");
}
@@ -153,13 +170,13 @@ export function useDeleteTask(projectId: string) {
mutationFn: (taskId: string) => taskService.deleteTask(taskId),
onMutate: async (taskId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.all(projectId) });
await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.all(projectId));
const previousTasks = queryClient.getQueryData<Task[]>(taskKeys.byProject(projectId));
// Optimistically remove the task
queryClient.setQueryData<Task[]>(taskKeys.all(projectId), (old) => {
queryClient.setQueryData<Task[]>(taskKeys.byProject(projectId), (old) => {
if (!old) return old;
return old.filter((task) => task.id !== taskId);
});
@@ -171,7 +188,7 @@ export function useDeleteTask(projectId: string) {
console.error("Failed to delete task:", error, { taskId });
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.all(projectId), context.previousTasks);
queryClient.setQueryData(taskKeys.byProject(projectId), context.previousTasks);
}
showToast(`Failed to delete task: ${errorMessage}`, "error");
},
@@ -180,7 +197,9 @@ export function useDeleteTask(projectId: string) {
},
onSettled: () => {
// Always refetch counts after deletion
queryClient.invalidateQueries({ queryKey: projectKeys.taskCounts() });
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
// Also refetch the project's task list to reconcile server-side ordering
queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) });
},
});
}

View File

@@ -4,8 +4,11 @@ import { z } from "zod";
export const DatabaseTaskStatusSchema = z.enum(["todo", "doing", "review", "done"]);
export const TaskPrioritySchema = z.enum(["low", "medium", "high", "critical"]);
// Assignee schema - simplified to predefined options
export const AssigneeSchema = z.enum(["User", "Archon", "AI IDE Agent"]);
// Assignee schema - flexible string for any agent name
export const AssigneeSchema = z
.string()
.min(1, "Assignee cannot be empty")
.max(100, "Assignee name must be less than 100 characters");
// Task schemas
export const CreateTaskSchema = z.object({

View File

@@ -280,7 +280,7 @@ describe("taskService", () => {
title: "Full Task",
description: "This is a detailed description that should persist",
status: "todo",
assignee: "AI IDE Agent",
assignee: "Coding Agent",
task_order: 100,
priority: "critical",
feature: "authentication",

View File

@@ -9,6 +9,7 @@ export type { UseTaskActionsReturn, UseTaskEditorReturn } from "./hooks";
// Core task types (vertical slice architecture)
export type {
Assignee,
CommonAssignee,
CreateTaskRequest,
DatabaseTaskStatus,
Task,
@@ -18,3 +19,6 @@ export type {
TaskSource,
UpdateTaskRequest,
} from "./task";
// Export constants
export { COMMON_ASSIGNEES } from "./task";

View File

@@ -11,8 +11,12 @@ export type { TaskPriority };
// Database status enum - using database values directly
export type DatabaseTaskStatus = "todo" | "doing" | "review" | "done";
// Assignee type - simplified to predefined options
export type Assignee = "User" | "Archon" | "AI IDE Agent";
// Assignee type - flexible string to support any agent name
export type Assignee = string;
// Common assignee options for UI suggestions
export const COMMON_ASSIGNEES = ["User", "Archon", "Coding Agent"] as const;
export type CommonAssignee = (typeof COMMON_ASSIGNEES)[number];
// Task counts for project overview
export interface TaskCounts {
@@ -46,7 +50,7 @@ export interface Task {
title: string;
description: string;
status: DatabaseTaskStatus;
assignee: Assignee;
assignee: Assignee; // Can be any string - agent names, "User", etc.
task_order: number;
feature?: string;
sources?: TaskSource[];
@@ -72,7 +76,7 @@ export interface CreateTaskRequest {
title: string;
description: string;
status?: DatabaseTaskStatus;
assignee?: Assignee;
assignee?: Assignee; // Optional assignee string
task_order?: number;
feature?: string;
featureColor?: string;
@@ -85,7 +89,7 @@ export interface UpdateTaskRequest {
title?: string;
description?: string;
status?: DatabaseTaskStatus;
assignee?: Assignee;
assignee?: Assignee; // Optional assignee string
task_order?: number;
feature?: string;
featureColor?: string;

View File

@@ -11,7 +11,7 @@ export const getAssigneeIcon = (assigneeName: Assignee) => {
switch (assigneeName) {
case "User":
return <User className="w-4 h-4 text-blue-400" />;
case "AI IDE Agent":
case "Coding Agent":
return <Bot className="w-4 h-4 text-purple-400" />;
case "Archon":
return <img src="/logo-neon.png" alt="Archon" className="w-4 h-4" />;
@@ -25,7 +25,7 @@ export const getAssigneeGlow = (assigneeName: Assignee) => {
switch (assigneeName) {
case "User":
return "shadow-[0_0_10px_rgba(59,130,246,0.4)]";
case "AI IDE Agent":
case "Coding Agent":
return "shadow-[0_0_10px_rgba(168,85,247,0.4)]";
case "Archon":
return "shadow-[0_0_10px_rgba(34,211,238,0.4)]";

View File

@@ -9,13 +9,8 @@ import { NewProjectModal } from "../components/NewProjectModal";
import { ProjectHeader } from "../components/ProjectHeader";
import { ProjectList } from "../components/ProjectList";
import { DocsTab } from "../documents/DocsTab";
import {
projectKeys,
useDeleteProject,
useProjects,
useTaskCounts,
useUpdateProject,
} from "../hooks/useProjectQueries";
import { projectKeys, useDeleteProject, useProjects, useUpdateProject } from "../hooks/useProjectQueries";
import { useTaskCounts } from "../tasks/hooks";
import { TasksTab } from "../tasks/TasksTab";
import type { Project } from "../types";

View File

@@ -0,0 +1,24 @@
/**
* Shared Query Patterns
*
* Consistent patterns for TanStack Query across all features
*
* USAGE GUIDELINES:
* - Always use DISABLED_QUERY_KEY for disabled queries
* - Always use STALE_TIMES constants for staleTime configuration
* - Never hardcode stale times directly in hooks
*/
// Consistent disabled query key - use when query should not execute
export const DISABLED_QUERY_KEY = ["disabled"] as const;
// Consistent stale times by update frequency
// Use these to ensure predictable caching behavior across the app
export const STALE_TIMES = {
instant: 0, // Always fresh - for real-time data like active progress
realtime: 3_000, // 3 seconds - for near real-time updates
frequent: 5_000, // 5 seconds - for frequently changing data
normal: 30_000, // 30 seconds - standard cache time for most data
rare: 300_000, // 5 minutes - for rarely changing configuration
static: Infinity, // Never stale - for static data like settings
} as const;

View File

@@ -49,8 +49,8 @@ export function useSmartPolling(baseInterval: number = 10000) {
}
if (!hasFocus) {
// Page is visible but not focused - poll less frequently (1 minute)
return 60000; // 60 seconds for background polling
// Page is visible but not focused - poll less frequently
return 5000; // 5 seconds for background polling (aligned with polling guidelines)
}
// Page is active - use normal interval

View File

@@ -1,12 +1,13 @@
/**
* ComboBox Primitive
*
* A searchable dropdown component built with Radix UI Popover and Command
* A searchable dropdown component built with Radix UI Popover
* Provides autocomplete functionality with keyboard navigation
* Follows WAI-ARIA combobox pattern for accessibility
*/
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { Check, Loader2 } from "lucide-react";
import * as React from "react";
import { Button } from "./button";
import { cn } from "./styles";
@@ -25,10 +26,26 @@ interface ComboBoxProps {
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
disabled?: boolean;
isLoading?: boolean;
allowCustomValue?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
}
/**
* ComboBox component with search and custom value support
*
* @example
* <ComboBox
* options={[{ value: "1", label: "Option 1" }]}
* value={selected}
* onValueChange={setSelected}
* placeholder="Select..."
* allowCustomValue={true}
* />
*/
export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
(
{
@@ -39,86 +56,168 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
searchPlaceholder = "Search...",
emptyMessage = "No results found.",
className,
disabled = false,
isLoading = false,
allowCustomValue = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
"aria-describedby": ariaDescribedBy,
},
ref,
) => {
// State management
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
// Refs for DOM elements
const inputRef = React.useRef<HTMLInputElement>(null);
const optionsRef = React.useRef<HTMLDivElement>(null);
const listboxId = React.useId();
// Filter options based on search
// Memoized filtered options
const filteredOptions = React.useMemo(() => {
if (!search) return options;
if (!search.trim()) return options;
const searchLower = search.toLowerCase();
const searchLower = search.toLowerCase().trim();
return options.filter(
(option) =>
option.label.toLowerCase().includes(searchLower) ||
option.value.toLowerCase().includes(searchLower) ||
option.description?.toLowerCase().includes(searchLower),
option.label.toLowerCase().includes(searchLower) || option.value.toLowerCase().includes(searchLower),
);
}, [options, search]);
// Find current option label
const selectedOption = options.find((opt) => opt.value === value);
// Derived state
const selectedOption = React.useMemo(() => options.find((opt) => opt.value === value), [options, value]);
const displayValue = selectedOption?.label || value || "";
const hasCustomOption =
allowCustomValue &&
search.trim() &&
!filteredOptions.some((opt) => opt.label.toLowerCase() === search.toLowerCase());
// Handle selection
const handleSelect = (optionValue: string) => {
onValueChange(optionValue);
setOpen(false);
setSearch("");
};
// Handle custom value input
const handleCustomValue = () => {
if (allowCustomValue && search && !filteredOptions.some((opt) => opt.label === search)) {
onValueChange(search);
// Event handlers
const handleSelect = React.useCallback(
(optionValue: string) => {
onValueChange(optionValue);
setOpen(false);
setSearch("");
}
};
setHighlightedIndex(0);
},
[onValueChange],
);
// Focus input when opening
const handleCustomValue = React.useCallback(() => {
if (hasCustomOption) {
handleSelect(search.trim());
}
}, [hasCustomOption, search, handleSelect]);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "Enter":
e.preventDefault();
if (filteredOptions.length > 0 && highlightedIndex < filteredOptions.length) {
handleSelect(filteredOptions[highlightedIndex].value);
} else if (hasCustomOption) {
handleCustomValue();
}
break;
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) => {
const maxIndex = hasCustomOption ? filteredOptions.length : filteredOptions.length - 1;
return Math.min(prev + 1, maxIndex);
});
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
break;
case "Escape":
e.preventDefault();
setOpen(false);
break;
case "Tab":
// Allow natural tab behavior to close dropdown
setOpen(false);
break;
}
},
[filteredOptions, highlightedIndex, hasCustomOption, handleSelect, handleCustomValue],
);
// Focus management
React.useEffect(() => {
if (open && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 0);
if (open) {
setSearch("");
setHighlightedIndex(0);
// Use RAF for more reliable focus
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [open]);
// Scroll highlighted option into view
React.useEffect(() => {
if (open && optionsRef.current) {
const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]');
highlightedElement?.scrollIntoView({ block: "nearest" });
}
}, [highlightedIndex, open]);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
variant="ghost"
disabled={disabled || isLoading}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// Stop propagation to prevent parent handlers
e.stopPropagation();
// Allow Space to open the dropdown
if (e.key === " ") {
e.preventDefault();
setOpen(true);
}
// Also open on Enter/ArrowDown for better keyboard UX
if (e.key === "Enter" || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"w-full justify-between font-normal",
"h-auto px-2 py-1 rounded-md text-xs font-medium",
"bg-gray-100/50 dark:bg-gray-800/50",
"hover:bg-gray-200/50 dark:hover:bg-gray-700/50",
"border border-gray-300/50 dark:border-gray-600/50",
"transition-all duration-200",
"focus:outline-none focus:ring-1 focus:ring-cyan-400",
!displayValue && "text-gray-500 dark:text-gray-400",
(disabled || isLoading) && "opacity-50 cursor-not-allowed",
className,
)}
>
<span className="truncate">
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
<span className="sr-only">Loading options...</span>
Loading...
</span>
) : (
displayValue || placeholder
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className={cn(
"w-full min-w-[var(--radix-popover-trigger-width)] max-h-[300px] p-1",
"w-full min-w-[var(--radix-popover-trigger-width)] max-w-[320px]",
"bg-gradient-to-b from-white/95 to-white/90",
"dark:from-gray-900/95 dark:to-black/95",
"backdrop-blur-xl",
@@ -126,97 +225,144 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
"rounded-lg shadow-xl",
"shadow-cyan-500/10 dark:shadow-cyan-400/10",
"z-50",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
)}
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* Search Input */}
<div className="p-2">
<div className="p-1">
{/* Search Input */}
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={ariaLabel ?? "Search options"}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
aria-controls={listboxId}
aria-expanded={open}
aria-autocomplete="list"
aria-activedescendant={
open
? (hasCustomOption && highlightedIndex === filteredOptions.length
? `${listboxId}-custom`
: highlightedIndex < filteredOptions.length
? `${listboxId}-opt-${highlightedIndex}`
: undefined)
: undefined
}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && allowCustomValue && search) {
e.preventDefault();
handleCustomValue();
}
onChange={(e) => {
setSearch(e.target.value);
setHighlightedIndex(0);
}}
onKeyDown={(e) => {
e.stopPropagation(); // Stop propagation first
handleKeyDown(e);
}}
onClick={(e) => e.stopPropagation()}
placeholder={searchPlaceholder}
className={cn(
"w-full px-3 py-1.5 text-sm",
"w-full px-2 py-1 text-xs",
"bg-white/50 dark:bg-black/50",
"border border-gray-200 dark:border-gray-700",
"rounded-md",
"rounded",
"text-gray-900 dark:text-white",
"placeholder-gray-500 dark:placeholder-gray-400",
"focus:outline-none focus:border-cyan-400",
"focus:shadow-[0_0_10px_rgba(34,211,238,0.2)]",
"focus:outline-none focus:ring-1 focus:ring-cyan-400",
"transition-all duration-200",
)}
/>
</div>
{/* Options List */}
<div className="overflow-y-auto max-h-[200px] p-1">
{isLoading ? (
<div className="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
Loading options...
</div>
) : filteredOptions.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{emptyMessage}
{allowCustomValue && search && (
<button
type="button"
onClick={handleCustomValue}
className={cn(
"mt-2 block w-full",
"px-3 py-1.5 text-left text-sm",
"bg-cyan-50/50 dark:bg-cyan-900/20",
"text-cyan-600 dark:text-cyan-400",
"rounded-md",
"hover:bg-cyan-100/50 dark:hover:bg-cyan-800/30",
"transition-colors duration-200",
)}
>
Create "{search}"
</button>
)}
</div>
) : (
filteredOptions.map((option) => (
<button
type="button"
key={option.value}
onClick={() => handleSelect(option.value)}
className={cn(
"relative flex w-full items-center px-3 py-2",
"text-sm rounded-md",
"hover:bg-gray-100/80 dark:hover:bg-white/10",
"text-gray-900 dark:text-white",
"transition-colors duration-200",
"focus:outline-none focus:bg-gray-100/80 dark:focus:bg-white/10",
value === option.value && "bg-cyan-50/50 dark:bg-cyan-900/20",
)}
{/* Options List */}
<div
ref={optionsRef}
id={listboxId}
role="listbox"
aria-label="Options"
className="mt-1 overflow-y-auto max-h-[150px]"
>
{isLoading ? (
<div className="py-3 text-center text-xs text-gray-500 dark:text-gray-400">
<Loader2 className="h-3 w-3 animate-spin mx-auto mb-1" aria-hidden="true" />
<span>Loading...</span>
</div>
) : filteredOptions.length === 0 && !hasCustomOption ? (
<div
className="py-3 text-center text-xs text-gray-500 dark:text-gray-400"
role="option"
aria-disabled="true"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100 text-cyan-600 dark:text-cyan-400" : "opacity-0",
)}
/>
<div className="flex-1 text-left">
<div className="font-medium">{option.label}</div>
{option.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{option.description}</div>
)}
</div>
</button>
))
)}
{emptyMessage}
</div>
) : (
<>
{filteredOptions.map((option, index) => {
const isSelected = value === option.value;
const isHighlighted = highlightedIndex === index;
return (
<button
type="button"
key={option.value}
id={`${listboxId}-opt-${index}`}
role="option"
aria-selected={isSelected}
data-highlighted={isHighlighted}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"relative flex w-full items-center px-2 py-1.5",
"text-xs text-left",
"transition-colors duration-150",
"text-gray-900 dark:text-white",
"hover:bg-gray-100/80 dark:hover:bg-white/10",
"focus:outline-none focus:bg-gray-100/80 dark:focus:bg-white/10",
isSelected && "bg-cyan-50/50 dark:bg-cyan-900/20",
isHighlighted && !isSelected && "bg-gray-100/60 dark:bg-white/5",
)}
>
<Check
className={cn(
"mr-1.5 h-3 w-3 shrink-0",
isSelected ? "opacity-100 text-cyan-600 dark:text-cyan-400" : "opacity-0",
)}
aria-hidden="true"
/>
<span className="truncate">{option.label}</span>
</button>
);
})}
{hasCustomOption && (
<button
type="button"
id={`${listboxId}-custom`}
role="option"
aria-selected={false}
data-highlighted={highlightedIndex === filteredOptions.length}
onClick={handleCustomValue}
onMouseEnter={() => setHighlightedIndex(filteredOptions.length)}
className={cn(
"relative flex w-full items-center px-2 py-1.5",
"text-xs text-left",
"bg-cyan-50/30 dark:bg-cyan-900/10",
"text-cyan-600 dark:text-cyan-400",
"border-t border-gray-200/50 dark:border-gray-700/50",
"hover:bg-cyan-100/50 dark:hover:bg-cyan-800/30",
"transition-colors duration-200",
highlightedIndex === filteredOptions.length && "bg-cyan-100/50 dark:bg-cyan-800/30",
)}
>
<span className="ml-4">Add "{search}"</span>
</button>
)}
</>
)}
</div>
</div>
</Popover.Content>
</Popover.Portal>

View File

@@ -130,6 +130,13 @@ def register_task_tools(mcp: FastMCP):
params["include_closed"] = include_closed
if project_id:
params["project_id"] = project_id
elif filter_by == "assignee" and filter_value:
# Use generic tasks endpoint for assignee filtering
url = urljoin(api_url, "/api/tasks")
params["assignee"] = filter_value
params["include_closed"] = include_closed
if project_id:
params["project_id"] = project_id
elif project_id:
# Direct project_id parameter provided
url = urljoin(api_url, "/api/tasks")
@@ -212,13 +219,17 @@ def register_task_tools(mcp: FastMCP):
title: Task title text
description: Detailed task description
status: "todo" | "doing" | "review" | "done"
assignee: "User" | "Archon" | "AI IDE Agent"
assignee: String name of the assignee. Can be any agent name,
"User" for human assignment, or custom agent identifiers
created by your system (e.g., "ResearchAgent-1", "CodeReviewer").
Common values: "User", "Archon", "Coding Agent"
Default: "User"
task_order: Priority 0-100 (higher = more priority)
feature: Feature label for grouping
Examples:
manage_task("create", project_id="p-1", title="Fix auth bug")
manage_task("update", task_id="t-1", status="doing")
manage_task("create", project_id="p-1", title="Fix auth bug", assignee="CodeAnalyzer-v2")
manage_task("update", task_id="t-1", status="doing", assignee="User")
manage_task("delete", task_id="t-1")
Returns: {success: bool, task?: object, message: string}

View File

@@ -142,10 +142,9 @@ class KnowledgeSummaryService:
"code_examples_count": code_counts.get(source_id, 0),
"knowledge_type": knowledge_type,
"source_type": source_type,
"tags": metadata.get("tags", []),
"created_at": source.get("created_at"),
"updated_at": source.get("updated_at"),
"metadata": metadata, # Include full metadata for debugging
"metadata": metadata, # Include full metadata (contains tags)
}
summaries.append(summary)