mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
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:
@@ -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
|
||||
|
||||
227
PRPs/ai_docs/QUERY_PATTERNS.md
Normal file
227
PRPs/ai_docs/QUERY_PATTERNS.md
Normal 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
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "../progress/types";
|
||||
export * from "./knowledge";
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
@@ -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 = {
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,5 @@ export {
|
||||
useDeleteProject,
|
||||
useProjectFeatures,
|
||||
useProjects,
|
||||
useTaskCounts,
|
||||
useUpdateProject,
|
||||
} from "./useProjectQueries";
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,5 +15,6 @@ export {
|
||||
useCreateTask,
|
||||
useDeleteTask,
|
||||
useProjectTasks,
|
||||
useTaskCounts,
|
||||
useUpdateTask,
|
||||
} from "./useTaskQueries";
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
24
archon-ui-main/src/features/shared/queryPatterns.ts
Normal file
24
archon-ui-main/src/features/shared/queryPatterns.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user