refactor: reorganize features/shared directory for better maintainability (#730)
Some checks failed
Continuous Integration / Frontend Tests (React + Vitest) (push) Has been cancelled
Continuous Integration / Backend Tests (Python + pytest) (push) Has been cancelled
Continuous Integration / Docker Build Tests (agents) (push) Has been cancelled
Continuous Integration / Docker Build Tests (frontend) (push) Has been cancelled
Continuous Integration / Docker Build Tests (mcp) (push) Has been cancelled
Continuous Integration / Docker Build Tests (server) (push) Has been cancelled
Continuous Integration / Test Results Summary (push) Has been cancelled

* refactor: reorganize features/shared directory structure

- Created organized subdirectories for better code organization:
  - api/ - API clients and HTTP utilities (renamed apiWithEtag.ts to apiClient.ts)
  - config/ - Configuration files (queryClient, queryPatterns)
  - types/ - Shared type definitions (errors)
  - utils/ - Pure utility functions (optimistic, clipboard)
  - hooks/ - Shared React hooks (already existed)

- Updated all import paths across the codebase (~40+ files)
- Updated all AI documentation in PRPs/ai_docs/ to reflect new structure
- All tests passing, build successful, no functional changes

This improves maintainability and follows vertical slice architecture patterns.

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

* fix: address PR review comments and code improvements

- Update imports to use @/features alias path for optimistic utils
- Fix optimistic upload item replacement by matching on source_id instead of id
- Clean up test suite naming and remove meta-terms from comments
- Only set Content-Type header on requests with body
- Add explicit TypeScript typing to useProjectFeatures hook
- Complete Phase 4 improvements with proper query typing

* fix: address additional PR review feedback

- Clear feature queries when deleting project to prevent cache memory leaks
- Update KnowledgeCard comments to follow documentation guidelines
- Add explanatory comment for accessibility pattern in KnowledgeCard

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wirasm 2025-09-22 14:59:33 +03:00 committed by GitHub
parent d3a5c3311a
commit 63a92cf7d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 230 additions and 215 deletions

View File

@ -198,7 +198,7 @@ Database values used directly - no mapping layers:
- Operation statuses: `"pending"`, `"processing"`, `"completed"`, `"failed"`
### Time Constants
**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
**Location**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
- `STALE_TIMES.instant` - 0ms
- `STALE_TIMES.realtime` - 3 seconds
- `STALE_TIMES.frequent` - 5 seconds

View File

@ -88,8 +88,8 @@ Pattern: `{METHOD} /api/{resource}/{id?}/{sub-resource?}`
### Data Fetching
**Core**: TanStack Query v5
**Configuration**: `archon-ui-main/src/features/shared/queryClient.ts`
**Patterns**: `archon-ui-main/src/features/shared/queryPatterns.ts`
**Configuration**: `archon-ui-main/src/features/shared/config/queryClient.ts`
**Patterns**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
### State Management
- **Server State**: TanStack Query
@ -139,7 +139,7 @@ TanStack Query is the single source of truth. No separate state management neede
No translation layers. Database values (e.g., `"todo"`, `"doing"`) used directly in UI.
### Browser-Native Caching
ETags handled by browser, not JavaScript. See `archon-ui-main/src/features/shared/apiWithEtag.ts`.
ETags handled by browser, not JavaScript. See `archon-ui-main/src/features/shared/api/apiClient.ts`.
## Deployment

View File

@ -8,7 +8,7 @@ Archon uses **TanStack Query v5** for all data fetching, caching, and synchroniz
### 1. Query Client Configuration
**Location**: `archon-ui-main/src/features/shared/queryClient.ts`
**Location**: `archon-ui-main/src/features/shared/config/queryClient.ts`
Centralized QueryClient with:
@ -30,7 +30,7 @@ Visibility-aware polling that:
### 3. Query Patterns
**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
**Location**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
Shared constants:
@ -64,7 +64,7 @@ Standard pattern across all features:
### ETag Support
**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
**Location**: `archon-ui-main/src/features/shared/api/apiClient.ts`
ETag implementation:
@ -83,7 +83,7 @@ Backend endpoints follow RESTful patterns:
## Optimistic Updates
**Utilities**: `archon-ui-main/src/features/shared/optimistic.ts`
**Utilities**: `archon-ui-main/src/features/shared/utils/optimistic.ts`
All mutations use nanoid-based optimistic updates:
@ -105,7 +105,7 @@ Polling intervals are defined in each feature's query hooks. See actual implemen
- **Progress**: `archon-ui-main/src/features/progress/hooks/useProgressQueries.ts`
- **MCP**: `archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts`
Standard intervals from `archon-ui-main/src/features/shared/queryPatterns.ts`:
Standard intervals from `archon-ui-main/src/features/shared/config/queryPatterns.ts`:
- `STALE_TIMES.instant`: 0ms (always fresh)
- `STALE_TIMES.frequent`: 5 seconds (frequently changing data)
- `STALE_TIMES.normal`: 30 seconds (standard cache)

View File

@ -17,7 +17,7 @@ The backend generates ETags for API responses:
- Returns `304 Not Modified` when ETags match
### Frontend Handling
**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
**Location**: `archon-ui-main/src/features/shared/api/apiClient.ts`
The frontend relies on browser-native HTTP caching:
- Browser automatically sends `If-None-Match` headers with cached ETags
@ -28,7 +28,7 @@ The frontend relies on browser-native HTTP caching:
#### Browser vs Non-Browser Behavior
- **Standard Browsers**: Per the Fetch spec, a 304 response freshens the HTTP cache and returns the cached body to JavaScript
- **Non-Browser Runtimes** (React Native, custom fetch): May surface 304 with empty body to JavaScript
- **Client Fallback**: The `apiWithEtag.ts` implementation handles both scenarios, ensuring consistent behavior across environments
- **Client Fallback**: The `apiClient.ts` implementation handles both scenarios, ensuring consistent behavior across environments
## Implementation Details
@ -81,8 +81,8 @@ Unlike previous implementations, the current approach:
### Configuration
Cache behavior is controlled through TanStack Query's `staleTime`:
- See `archon-ui-main/src/features/shared/queryPatterns.ts` for standard times
- See `archon-ui-main/src/features/shared/queryClient.ts` for global configuration
- See `archon-ui-main/src/features/shared/config/queryPatterns.ts` for standard times
- See `archon-ui-main/src/features/shared/config/queryClient.ts` for global configuration
## Performance Benefits
@ -100,7 +100,7 @@ Cache behavior is controlled through TanStack Query's `staleTime`:
### Core Implementation
- **Backend Utilities**: `python/src/server/utils/etag_utils.py`
- **Frontend Client**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
- **Frontend Client**: `archon-ui-main/src/features/shared/api/apiClient.ts`
- **Tests**: `python/tests/server/utils/test_etag_utils.py`
### Usage Examples

View File

@ -5,7 +5,7 @@ This guide documents the standardized patterns for using TanStack Query v5 in th
## 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`
2. **Consistent Patterns**: Always use shared patterns from `shared/config/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
@ -49,7 +49,7 @@ export const taskKeys = {
### Import Required Patterns
```typescript
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
```
### Disabled Queries
@ -106,7 +106,7 @@ export function useFeatureDetail(id: string | undefined) {
## Mutations with Optimistic Updates
```typescript
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/optimistic";
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/utils/optimistic";
export function useCreateFeature() {
const queryClient = useQueryClient();
@ -161,7 +161,7 @@ vi.mock("../../services", () => ({
}));
// Mock shared patterns with ALL values
vi.mock("../../../shared/queryPatterns", () => ({
vi.mock("../../../shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,

View File

@ -3,7 +3,7 @@
## Core Architecture
### Shared Utilities Module
**Location**: `src/features/shared/optimistic.ts`
**Location**: `src/features/shared/utils/optimistic.ts`
Provides type-safe utilities for managing optimistic state across all features:
- `createOptimisticId()` - Generates stable UUIDs using nanoid
@ -73,13 +73,13 @@ Reusable component showing:
- Uses `createOptimisticId()` directly for progress tracking
### Toasts
- **Location**: `src/features/ui/hooks/useToast.ts:43`
- **Location**: `src/features/shared/hooks/useToast.ts:43`
- Uses `createOptimisticId()` for unique toast IDs
## Testing
### Unit Tests
**Location**: `src/features/shared/optimistic.test.ts`
**Location**: `src/features/shared/utils/tests/optimistic.test.ts`
Covers all utility functions with 8 test cases:
- ID uniqueness and format validation

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './features/shared/queryClient';
import { queryClient } from './features/shared/config/queryClient';
import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';

View File

@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { callAPIWithETag } from "../../../features/shared/apiWithEtag";
import { createRetryLogic, STALE_TIMES } from "../../../features/shared/queryPatterns";
import { callAPIWithETag } from "../../../features/shared/api/apiClient";
import { createRetryLogic, STALE_TIMES } from "../../../features/shared/config/queryPatterns";
import type { HealthResponse } from "../types";
/**

View File

@ -1,6 +1,6 @@
/**
* Enhanced Knowledge Card Component
* Individual knowledge item card with excellent UX and inline progress
* Knowledge Card component
* Displays a knowledge item with inline progress and status UI
* Following the pattern from ProjectCard
*/
@ -10,7 +10,7 @@ import { Clock, Code, ExternalLink, File, FileText, Globe } from "lucide-react";
import { useState } from "react";
import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../../progress/types";
import { isOptimistic } from "../../shared/optimistic";
import { isOptimistic } from "@/features/shared/utils/optimistic";
import { StatPill } from "../../ui/primitives";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { cn } from "../../ui/primitives/styles";
@ -144,6 +144,7 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
};
return (
// biome-ignore lint/a11y/useSemanticElements: Card contains nested interactive elements (buttons, links) - using div to avoid invalid HTML nesting
<motion.div
className="relative group cursor-pointer"
role="button"

View File

@ -5,13 +5,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/optimistic";
import { useSmartPolling } from "@/features/shared/hooks";
import { useToast } from "@/features/shared/hooks/useToast";
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/utils/optimistic";
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 "@/features/shared/hooks";
import { useToast } from "@/features/shared/hooks/useToast";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/config/queryPatterns";
import { knowledgeService } from "../services";
import type {
CrawlRequest,
@ -170,7 +170,6 @@ export function useCrawlUrl() {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Omit<KnowledgeItem, "id">);
const tempItemId = optimisticItem.id;
// Update all summaries caches with optimistic data, respecting each cache's filter
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
@ -229,7 +228,7 @@ export function useCrawlUrl() {
});
// Return context for rollback and replacement
return { previousSummaries, previousOperations, tempProgressId, tempItemId };
return { previousSummaries, previousOperations, tempProgressId };
},
onSuccess: (response, _variables, context) => {
// Replace temporary IDs with real ones from the server
@ -313,7 +312,6 @@ export function useUploadDocument() {
previousSummaries?: Array<[readonly unknown[], KnowledgeItemsResponse | undefined]>;
previousOperations?: ActiveOperationsResponse;
tempProgressId: string;
tempItemId: string;
}
>({
mutationFn: ({ file, metadata }: { file: File; metadata: UploadMetadata }) =>
@ -352,7 +350,6 @@ export function useUploadDocument() {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Omit<KnowledgeItem, "id">);
const tempItemId = optimisticItem.id;
// Respect each cache's filter (knowledge_type, tags, etc.)
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
@ -410,7 +407,7 @@ export function useUploadDocument() {
};
});
return { previousSummaries, previousOperations, tempProgressId, tempItemId };
return { previousSummaries, previousOperations, tempProgressId };
},
onSuccess: (response, _variables, context) => {
// Replace temporary IDs with real ones from the server
@ -421,7 +418,7 @@ export function useUploadDocument() {
return {
...old,
items: old.items.map((item) => {
if (item.id === context.tempItemId) {
if (item.source_id === context.tempProgressId) {
return {
...item,
source_id: response.progressId,

View File

@ -4,13 +4,13 @@
*/
import { useCallback, useEffect, useState } from "react";
import { copyToClipboard } from "../../../shared/utils/clipboard";
import { InspectorDialog, InspectorDialogContent, InspectorDialogTitle } from "../../../ui/primitives";
import type { CodeExample, DocumentChunk, InspectorSelectedItem, KnowledgeItem } from "../../types";
import { useInspectorPagination } from "../hooks/useInspectorPagination";
import { ContentViewer } from "./ContentViewer";
import { InspectorHeader } from "./InspectorHeader";
import { InspectorSidebar } from "./InspectorSidebar";
import { copyToClipboard } from "../../../shared/utils/clipboard";
interface KnowledgeInspectorProps {
item: KnowledgeItem;

View File

@ -5,7 +5,7 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { STALE_TIMES } from "@/features/shared/queryPatterns";
import { STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { knowledgeKeys } from "../../hooks/useKnowledgeQueries";
import { knowledgeService } from "../../services";
import type { ChunksResponse, CodeExample, CodeExamplesResponse, DocumentChunk } from "../../types";

View File

@ -3,8 +3,8 @@
* Handles all knowledge-related API operations using TanStack Query patterns
*/
import { callAPIWithETag } from "../../shared/apiWithEtag";
import { APIServiceError } from "../../shared/errors";
import { callAPIWithETag } from "../../shared/api/apiClient";
import { APIServiceError } from "../../shared/types/errors";
import type {
ChunksResponse,
CodeExamplesResponse,

View File

@ -1,70 +1,70 @@
import { describe, it, expect } from 'vitest';
import { parseProviderError, getProviderErrorMessage, type ProviderError } from '../providerErrorHandler';
import { describe, expect, it } from "vitest";
import { getProviderErrorMessage, type ProviderError, parseProviderError } from "../providerErrorHandler";
describe('providerErrorHandler', () => {
describe('parseProviderError', () => {
it('should handle basic Error objects', () => {
const error = new Error('Basic error message');
describe("providerErrorHandler", () => {
describe("parseProviderError", () => {
it("should handle basic Error objects", () => {
const error = new Error("Basic error message");
const result = parseProviderError(error);
expect(result.message).toBe('Basic error message');
expect(result.message).toBe("Basic error message");
expect(result.isProviderError).toBeUndefined();
});
it('should handle errors with statusCode property', () => {
const error = { statusCode: 401, message: 'Unauthorized' };
it("should handle errors with statusCode property", () => {
const error = { statusCode: 401, message: "Unauthorized" };
const result = parseProviderError(error);
expect(result.statusCode).toBe(401);
expect(result.message).toBe('Unauthorized');
expect(result.message).toBe("Unauthorized");
});
it('should handle errors with status property', () => {
const error = { status: 429, message: 'Rate limited' };
it("should handle errors with status property", () => {
const error = { status: 429, message: "Rate limited" };
const result = parseProviderError(error);
expect(result.statusCode).toBe(429);
expect(result.message).toBe('Rate limited');
expect(result.message).toBe("Rate limited");
});
it('should prioritize statusCode over status when both are present', () => {
const error = { statusCode: 401, status: 429, message: 'Auth error' };
it("should prioritize statusCode over status when both are present", () => {
const error = { statusCode: 401, status: 429, message: "Auth error" };
const result = parseProviderError(error);
expect(result.statusCode).toBe(401);
});
it('should parse structured provider errors from backend', () => {
it("should parse structured provider errors from backend", () => {
const error = {
message: JSON.stringify({
detail: {
error_type: 'authentication_failed',
provider: 'OpenAI',
message: 'Invalid API key'
}
})
error_type: "authentication_failed",
provider: "OpenAI",
message: "Invalid API key",
},
}),
};
const result = parseProviderError(error);
expect(result.isProviderError).toBe(true);
expect(result.provider).toBe('OpenAI');
expect(result.errorType).toBe('authentication_failed');
expect(result.message).toBe('Invalid API key');
expect(result.provider).toBe("OpenAI");
expect(result.errorType).toBe("authentication_failed");
expect(result.message).toBe("Invalid API key");
});
it('should handle malformed JSON in message gracefully', () => {
it("should handle malformed JSON in message gracefully", () => {
const error = {
message: 'invalid json { detail'
message: "invalid json { detail",
};
const result = parseProviderError(error);
expect(result.isProviderError).toBeUndefined();
expect(result.message).toBe('invalid json { detail');
expect(result.message).toBe("invalid json { detail");
});
it('should handle null and undefined inputs safely', () => {
it("should handle null and undefined inputs safely", () => {
expect(() => parseProviderError(null)).not.toThrow();
expect(() => parseProviderError(undefined)).not.toThrow();
@ -75,7 +75,7 @@ describe('providerErrorHandler', () => {
expect(undefinedResult).toBeDefined();
});
it('should handle empty objects', () => {
it("should handle empty objects", () => {
const result = parseProviderError({});
expect(result).toBeDefined();
@ -83,171 +83,171 @@ describe('providerErrorHandler', () => {
expect(result.isProviderError).toBeUndefined();
});
it('should handle primitive values', () => {
expect(() => parseProviderError('string error')).not.toThrow();
it("should handle primitive values", () => {
expect(() => parseProviderError("string error")).not.toThrow();
expect(() => parseProviderError(42)).not.toThrow();
expect(() => parseProviderError(true)).not.toThrow();
});
it('should handle structured errors without provider field', () => {
it("should handle structured errors without provider field", () => {
const error = {
message: JSON.stringify({
detail: {
error_type: 'quota_exhausted',
message: 'Usage limit exceeded'
}
})
error_type: "quota_exhausted",
message: "Usage limit exceeded",
},
}),
};
const result = parseProviderError(error);
expect(result.isProviderError).toBe(true);
expect(result.provider).toBe('LLM'); // Default fallback
expect(result.errorType).toBe('quota_exhausted');
expect(result.message).toBe('Usage limit exceeded');
expect(result.provider).toBe("LLM"); // Default fallback
expect(result.errorType).toBe("quota_exhausted");
expect(result.message).toBe("Usage limit exceeded");
});
it('should handle partial structured errors', () => {
it("should handle partial structured errors", () => {
const error = {
message: JSON.stringify({
detail: {
error_type: 'rate_limit'
error_type: "rate_limit",
// Missing message field
}
})
},
}),
};
const result = parseProviderError(error);
expect(result.isProviderError).toBe(true);
expect(result.errorType).toBe('rate_limit');
expect(result.errorType).toBe("rate_limit");
expect(result.message).toBe(error.message); // Falls back to original message
});
});
describe('getProviderErrorMessage', () => {
it('should return user-friendly message for authentication_failed', () => {
describe("getProviderErrorMessage", () => {
it("should return user-friendly message for authentication_failed", () => {
const error: ProviderError = {
name: 'Error',
message: 'Auth failed',
name: "Error",
message: "Auth failed",
isProviderError: true,
provider: 'OpenAI',
errorType: 'authentication_failed'
provider: "OpenAI",
errorType: "authentication_failed",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('Please verify your OpenAI API key in Settings.');
expect(result).toBe("Please verify your OpenAI API key in Settings.");
});
it('should return user-friendly message for quota_exhausted', () => {
it("should return user-friendly message for quota_exhausted", () => {
const error: ProviderError = {
name: 'Error',
message: 'Quota exceeded',
name: "Error",
message: "Quota exceeded",
isProviderError: true,
provider: 'Google AI',
errorType: 'quota_exhausted'
provider: "Google AI",
errorType: "quota_exhausted",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('Google AI quota exhausted. Please check your billing settings.');
expect(result).toBe("Google AI quota exhausted. Please check your billing settings.");
});
it('should return user-friendly message for rate_limit', () => {
it("should return user-friendly message for rate_limit", () => {
const error: ProviderError = {
name: 'Error',
message: 'Rate limited',
name: "Error",
message: "Rate limited",
isProviderError: true,
provider: 'Anthropic',
errorType: 'rate_limit'
provider: "Anthropic",
errorType: "rate_limit",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('Anthropic rate limit exceeded. Please wait and try again.');
expect(result).toBe("Anthropic rate limit exceeded. Please wait and try again.");
});
it('should return generic provider message for unknown error types', () => {
it("should return generic provider message for unknown error types", () => {
const error: ProviderError = {
name: 'Error',
message: 'Unknown error',
name: "Error",
message: "Unknown error",
isProviderError: true,
provider: 'OpenAI',
errorType: 'unknown_error'
provider: "OpenAI",
errorType: "unknown_error",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('OpenAI API error. Please check your configuration.');
expect(result).toBe("OpenAI API error. Please check your configuration.");
});
it('should use default provider when provider is missing', () => {
it("should use default provider when provider is missing", () => {
const error: ProviderError = {
name: 'Error',
message: 'Auth failed',
name: "Error",
message: "Auth failed",
isProviderError: true,
errorType: 'authentication_failed'
errorType: "authentication_failed",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('Please verify your LLM API key in Settings.');
expect(result).toBe("Please verify your LLM API key in Settings.");
});
it('should handle 401 status code for non-provider errors', () => {
const error = { statusCode: 401, message: 'Unauthorized' };
it("should handle 401 status code for non-provider errors", () => {
const error = { statusCode: 401, message: "Unauthorized" };
const result = getProviderErrorMessage(error);
expect(result).toBe('Please verify your API key in Settings.');
expect(result).toBe("Please verify your API key in Settings.");
});
it('should return original message for non-provider errors', () => {
const error = new Error('Network connection failed');
it("should return original message for non-provider errors", () => {
const error = new Error("Network connection failed");
const result = getProviderErrorMessage(error);
expect(result).toBe('Network connection failed');
expect(result).toBe("Network connection failed");
});
it('should return default message when no message is available', () => {
it("should return default message when no message is available", () => {
const error = {};
const result = getProviderErrorMessage(error);
expect(result).toBe('An error occurred.');
expect(result).toBe("An error occurred.");
});
it('should handle complex error objects with structured backend response', () => {
it("should handle complex error objects with structured backend response", () => {
const backendError = {
statusCode: 400,
message: JSON.stringify({
detail: {
error_type: 'authentication_failed',
provider: 'OpenAI',
message: 'API key invalid or expired'
}
})
error_type: "authentication_failed",
provider: "OpenAI",
message: "API key invalid or expired",
},
}),
};
const result = getProviderErrorMessage(backendError);
expect(result).toBe('Please verify your OpenAI API key in Settings.');
expect(result).toBe("Please verify your OpenAI API key in Settings.");
});
it('should handle edge case: message contains "detail" but is not JSON', () => {
const error = {
message: 'Error detail: something went wrong'
message: "Error detail: something went wrong",
};
const result = getProviderErrorMessage(error);
expect(result).toBe('Error detail: something went wrong');
expect(result).toBe("Error detail: something went wrong");
});
it('should handle null and undefined gracefully', () => {
expect(getProviderErrorMessage(null)).toBe('An error occurred.');
expect(getProviderErrorMessage(undefined)).toBe('An error occurred.');
it("should handle null and undefined gracefully", () => {
expect(getProviderErrorMessage(null)).toBe("An error occurred.");
expect(getProviderErrorMessage(undefined)).toBe("An error occurred.");
});
});
describe('TypeScript strict mode compliance', () => {
it('should handle type-safe property access', () => {
describe("TypeScript strict mode compliance", () => {
it("should handle type-safe property access", () => {
// Test that our type guards work properly
const errorWithStatus = { statusCode: 500 };
const errorWithMessage = { message: 'test' };
const errorWithBoth = { statusCode: 401, message: 'unauthorized' };
const errorWithMessage = { message: "test" };
const errorWithBoth = { statusCode: 401, message: "unauthorized" };
// These should not throw TypeScript errors and should work correctly
expect(() => parseProviderError(errorWithStatus)).not.toThrow();
@ -259,13 +259,13 @@ describe('providerErrorHandler', () => {
const result3 = parseProviderError(errorWithBoth);
expect(result1.statusCode).toBe(500);
expect(result2.message).toBe('test');
expect(result2.message).toBe("test");
expect(result3.statusCode).toBe(401);
expect(result3.message).toBe('unauthorized');
expect(result3.message).toBe("unauthorized");
});
it('should handle objects without expected properties safely', () => {
const objectWithoutStatus = { someOtherProperty: 'value' };
it("should handle objects without expected properties safely", () => {
const objectWithoutStatus = { someOtherProperty: "value" };
const objectWithoutMessage = { anotherProperty: 42 };
expect(() => parseProviderError(objectWithoutStatus)).not.toThrow();
@ -278,4 +278,4 @@ describe('providerErrorHandler', () => {
expect(result2.message).toBeUndefined();
});
});
});
});

View File

@ -4,9 +4,9 @@
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useToast } from "@/features/shared/hooks/useToast";
import { CrawlingProgress } from "../../progress/components/CrawlingProgress";
import type { ActiveOperation } from "../../progress/types";
import { useToast } from "@/features/shared/hooks/useToast";
import { AddKnowledgeDialog } from "../components/AddKnowledgeDialog";
import { KnowledgeHeader } from "../components/KnowledgeHeader";
import { KnowledgeList } from "../components/KnowledgeList";

View File

@ -2,9 +2,9 @@ import { Copy, ExternalLink } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useToast } from "@/features/shared/hooks";
import { copyToClipboard } from "../../shared/utils/clipboard";
import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
import { copyToClipboard } from "../../shared/utils/clipboard";
interface McpConfigSectionProps {
config?: McpServerConfig;
@ -324,7 +324,8 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
<p className="text-sm text-yellow-700 dark:text-yellow-300">
<span className="font-semibold">Platform Note:</span> The configuration below shows{" "}
{navigator.platform.toLowerCase().includes("win") ? "Windows" : "Linux/macOS"} format. Adjust paths
according to your system. This setup is complex right now because Codex has some bugs with MCP currently.
according to your system. This setup is complex right now because Codex has some bugs with MCP
currently.
</p>
</div>
)}

View File

@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks";
import { STALE_TIMES } from "../../shared/config/queryPatterns";
import { mcpApi } from "../services";
// Query keys factory

View File

@ -1,4 +1,4 @@
import { callAPIWithETag } from "../../shared/apiWithEtag";
import { callAPIWithETag } from "../../shared/api/apiClient";
import type { McpClient, McpServerConfig, McpServerStatus, McpSessionInfo } from "../types";
export const mcpApi = {

View File

@ -19,7 +19,7 @@ vi.mock("../../services", () => ({
}));
// Mock shared query patterns
vi.mock("../../../shared/queryPatterns", () => ({
vi.mock("../../../shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,

View File

@ -5,9 +5,9 @@
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 { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/config/queryPatterns";
import { useSmartPolling } from "../../shared/hooks";
import { APIServiceError } from "../../shared/types/errors";
import { progressService } from "../services";
import type { ActiveOperationsResponse, ProgressResponse, ProgressStatus } from "../types";

View File

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

View File

@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Activity, CheckCircle2, ListTodo } from "lucide-react";
import type React from "react";
import { isOptimistic } from "../../shared/optimistic";
import { isOptimistic } from "@/features/shared/utils/optimistic";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { cn } from "../../ui/primitives/styles";
import type { Project } from "../types";

View File

@ -13,9 +13,9 @@ import {
} from "lucide-react";
import type React from "react";
import { memo, useCallback, useState } from "react";
import { copyToClipboard } from "../../../shared/utils/clipboard";
import { Button } from "../../../ui/primitives";
import type { DocumentCardProps, DocumentType } from "../types";
import { copyToClipboard } from "../../../shared/utils/clipboard";
const getDocumentIcon = (type?: DocumentType) => {
switch (type) {

View File

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/config/queryPatterns";
import { projectService } from "../../services";
import type { ProjectDocument } from "../types";

View File

@ -1,13 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSmartPolling } from "@/features/shared/hooks";
import { useToast } from "@/features/shared/hooks/useToast";
import {
createOptimisticEntity,
type OptimisticEntity,
removeDuplicateEntities,
replaceOptimisticEntity,
} from "@/features/shared/optimistic";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks";
import { useToast } from "@/features/shared/hooks/useToast";
} from "@/features/shared/utils/optimistic";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/config/queryPatterns";
import { projectService } from "../services";
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../types";
@ -36,9 +36,7 @@ export function useProjects() {
// 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({
return useQuery<Awaited<ReturnType<typeof projectService.getProjectFeatures>>>({
queryKey: projectId ? projectKeys.features(projectId) : DISABLED_QUERY_KEY,
queryFn: () => (projectId ? projectService.getProjectFeatures(projectId) : Promise.reject("No project ID")),
enabled: !!projectId,
@ -208,6 +206,8 @@ export function useDeleteProject() {
// Don't refetch on success - trust optimistic update
// Only remove the specific project's detail data (including nested keys)
queryClient.removeQueries({ queryKey: projectKeys.detail(projectId), exact: false });
// Also remove the project's feature queries
queryClient.removeQueries({ queryKey: projectKeys.features(projectId), exact: false });
showToast("Project deleted successfully", "success");
},
});

View File

@ -3,8 +3,8 @@
* Focused service for project CRUD operations only
*/
import { callAPIWithETag } from "../../shared/apiWithEtag";
import { formatZodErrors, ValidationError } from "../../shared/errors";
import { callAPIWithETag } from "../../shared/api/apiClient";
import { formatZodErrors, ValidationError } from "../../shared/types/errors";
import { validateCreateProject, validateUpdateProject } from "../schemas";
import { formatRelativeTime } from "../shared/api";
import type { CreateProjectRequest, Project, ProjectFeatures, UpdateProjectRequest } from "../types";

View File

@ -2,7 +2,7 @@ import { Tag } from "lucide-react";
import type React from "react";
import { useCallback } from "react";
import { useDrag, useDrop } from "react-dnd";
import { isOptimistic } from "../../../shared/optimistic";
import { isOptimistic } from "@/features/shared/utils/optimistic";
import { OptimisticIndicator } from "../../../ui/primitives/OptimisticIndicator";
import { useTaskActions } from "../hooks";
import type { Assignee, Task, TaskPriority } from "../types";

View File

@ -1,11 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createOptimisticEntity,
replaceOptimisticEntity,
removeDuplicateEntities,
type OptimisticEntity,
} from "@/features/shared/optimistic";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/queryPatterns";
removeDuplicateEntities,
replaceOptimisticEntity,
} from "@/features/shared/utils/optimistic";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../../shared/config/queryPatterns";
import { useSmartPolling } from "../../../shared/hooks";
import { useToast } from "../../../shared/hooks/useToast";
import { taskService } from "../services";

View File

@ -3,8 +3,8 @@
* Focused service for task CRUD operations only
*/
import { callAPIWithETag } from "../../../shared/apiWithEtag";
import { formatZodErrors, ValidationError } from "../../../shared/errors";
import { callAPIWithETag } from "../../../shared/api/apiClient";
import { formatZodErrors, ValidationError } from "../../../shared/types/errors";
import { validateCreateTask, validateUpdateTask, validateUpdateTaskStatus } from "../schemas";
import type { CreateTaskRequest, DatabaseTaskStatus, Task, TaskCounts, UpdateTaskRequest } from "../types";

View File

@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { callAPIWithETag } from "../../../../shared/apiWithEtag";
import { callAPIWithETag } from "../../../../shared/api/apiClient";
import type { CreateTaskRequest, DatabaseTaskStatus, Task, UpdateTaskRequest } from "../../types";
import { taskService } from "../taskService";
// Mock the API call
vi.mock("../../../../shared/apiWithEtag", () => ({
vi.mock("../../../../shared/api/apiClient", () => ({
callAPIWithETag: vi.fn(),
}));

View File

@ -29,7 +29,8 @@ export function MigrationStatusCard() {
<Database className="w-5 h-5 text-purple-400" />
<h3 className="text-white font-semibold">Database Migrations</h3>
</div>
<button type="button"
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@ -100,7 +101,8 @@ export function MigrationStatusCard() {
? "Initial database setup is required."
: `${data.pending_count} migration${data.pending_count > 1 ? "s" : ""} need to be applied.`}
</p>
<button type="button"
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/50 rounded text-yellow-400 text-sm font-medium transition-colors"
>

View File

@ -5,8 +5,8 @@
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle, Copy, Database, ExternalLink, X } from "lucide-react";
import React from "react";
import { copyToClipboard } from "@/features/shared/utils/clipboard";
import { useToast } from "@/features/shared/hooks/useToast";
import { copyToClipboard } from "@/features/shared/utils/clipboard";
import type { PendingMigration } from "../types";
interface PendingMigrationsModalProps {
@ -93,7 +93,8 @@ export function PendingMigrationsModal({
<li>Click "Refresh Status" below to verify migrations were applied</li>
</ol>
{migrations.length > 1 && (
<button type="button"
<button
type="button"
onClick={handleCopyAll}
className="mt-3 px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 border border-blue-500/50 rounded text-blue-400 text-sm font-medium transition-colors"
>
@ -125,7 +126,8 @@ export function PendingMigrationsModal({
</p>
</div>
<div className="flex items-center gap-2">
<button type="button"
<button
type="button"
onClick={() => handleCopy(migration.sql_content, index)}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300 flex items-center gap-2 transition-colors"
>
@ -141,7 +143,8 @@ export function PendingMigrationsModal({
</>
)}
</button>
<button type="button"
<button
type="button"
onClick={() => setExpandedIndex(expandedIndex === index ? null : index)}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300 transition-colors"
>
@ -175,13 +178,15 @@ export function PendingMigrationsModal({
{/* Footer */}
<div className="p-6 border-t border-gray-700 flex justify-between">
<button type="button"
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 font-medium transition-colors"
>
Close
</button>
<button type="button"
<button
type="button"
onClick={onMigrationsApplied}
className="px-4 py-2 bg-purple-500/20 hover:bg-purple-500/30 border border-purple-500/50 rounded-lg text-purple-400 font-medium transition-colors"
>

View File

@ -3,7 +3,7 @@
*/
import { useQuery } from "@tanstack/react-query";
import { STALE_TIMES } from "@/features/shared/queryPatterns";
import { STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks/useSmartPolling";
import { migrationService } from "../services/migrationService";
import type { MigrationHistoryResponse, MigrationStatusResponse, PendingMigration } from "../types";

View File

@ -2,7 +2,7 @@
* Service for database migration tracking and management
*/
import { callAPIWithETag } from "@/features/shared/apiWithEtag";
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type { MigrationHistoryResponse, MigrationStatusResponse, PendingMigration } from "../types";
export const migrationService = {

View File

@ -54,7 +54,8 @@ export function UpdateBanner() {
<span className="text-sm font-medium">View Upgrade Instructions</span>
<ExternalLink className="w-4 h-4" />
</a>
<button type="button"
<button
type="button"
onClick={() => setIsDismissed(true)}
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors"
aria-label="Dismiss update banner"

View File

@ -28,7 +28,8 @@ export function VersionStatusCard() {
<Info className="w-5 h-5 text-blue-400" />
<h3 className="text-white font-semibold">Version Information</h3>
</div>
<button type="button"
<button
type="button"
onClick={handleRefreshClick}
disabled={isLoading || clearCache.isPending}
className="p-2 hover:bg-gray-700/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"

View File

@ -3,7 +3,7 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { STALE_TIMES } from "@/features/shared/queryPatterns";
import { STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks/useSmartPolling";
import { versionService } from "../services/versionService";
import type { VersionCheckResponse } from "../types";

View File

@ -2,7 +2,7 @@
* Service for version checking and update management
*/
import { callAPIWithETag } from "@/features/shared/apiWithEtag";
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type { CurrentVersionResponse, VersionCheckResponse } from "../types";
export const versionService = {

View File

@ -11,8 +11,8 @@
* For cache control, configure TanStack Query's staleTime/gcTime instead of manual HTTP caching.
*/
import { API_BASE_URL } from "../../config/api";
import { APIServiceError } from "./errors";
import { API_BASE_URL } from "../../../config/api";
import { APIServiceError } from "../types/errors";
/**
* Build full URL with test environment handling
@ -48,17 +48,24 @@ export async function callAPIWithETag<T = unknown>(endpoint: string, options: Re
// Construct the full URL
const fullUrl = buildFullUrl(cleanEndpoint);
// Build headers - merge default Content-Type with provided headers
// Build headers - only set Content-Type for requests with a body
// NOTE: We do NOT add If-None-Match headers; the browser handles ETag revalidation automatically
// Also note: Currently assumes headers are passed as plain objects (Record<string, string>)
// If we ever need to support Headers instances or [string, string][] tuples,
// we should normalize with: new Headers(options.headers), set defaults, then
// convert back with Object.fromEntries(headers.entries())
//
// Currently assumes headers are passed as plain objects (Record<string, string>)
// which works for all our current usage. The API doesn't require Accept headers
// since it always returns JSON, and we only set Content-Type when sending data.
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((options.headers as Record<string, string>) || {}),
};
// Only set Content-Type for requests that have a body (POST, PUT, PATCH, etc.)
// GET and DELETE requests should not have Content-Type header
const method = options.method?.toUpperCase() || 'GET';
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
// Make the request with timeout
// NOTE: Increased to 20s due to database performance issues with large DELETE operations
// Root cause: Sequential scan on crawled_pages table when deleting sources with 7K+ rows

View File

@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { callAPIWithETag } from "./apiWithEtag";
import { APIServiceError } from "./errors";
import { APIServiceError } from "../../types/errors";
import { callAPIWithETag } from "../apiClient";
// Preserve original globals to restore after tests
const originalAbortSignal = global.AbortSignal as any;
const originalFetch = global.fetch;
describe("apiWithEtag", () => {
describe("apiClient (callAPIWithETag)", () => {
beforeEach(() => {
vi.resetAllMocks();
// Reset fetch to undefined to ensure clean state
@ -258,16 +258,16 @@ describe("apiWithEtag", () => {
});
it("should work seamlessly with TanStack Query's caching strategy", async () => {
// This test documents how our simplified approach works with TanStack Query:
// This test documents how the API client integrates with TanStack Query:
// 1. TanStack Query calls our function when data is stale
// 2. We make a simple fetch request
// 2. We make a fetch request
// 3. Browser adds If-None-Match if it has cached data
// 4. Server returns 200 (new data) or 304 (not modified)
// 5. Browser returns data to us (either new or cached)
// 6. We return data to TanStack Query
// 7. TanStack Query updates its cache
const mockData = { workflow: "simplified" };
const mockData = { workflow: "standard" };
const mockResponse = {
ok: true,
status: 200,
@ -285,8 +285,8 @@ describe("apiWithEtag", () => {
});
it("should allow browser to optimize bandwidth automatically", async () => {
// This test verifies that even though we removed explicit ETag handling,
// bandwidth optimization still works through browser's HTTP cache
// This test verifies that ETag negotiation is handled by the browser
// and bandwidth optimization works through the browser's HTTP cache
const mockData = { size: "large", benefit: "bandwidth saved" };
const mockResponse = {
@ -310,7 +310,7 @@ describe("apiWithEtag", () => {
});
it("should handle server errors regardless of caching", async () => {
// Verify error handling still works in simplified version
// Verify error handling works with standard fetch approach
const errorResponse = {
ok: false,
status: 500,

View File

@ -1,3 +1,3 @@
export * from "./useSmartPolling";
export * from "./useThemeAware";
export * from "./useToast";
export * from "./useToast";

View File

@ -1,6 +1,6 @@
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { createOptimisticId } from "../optimistic";
import { createOptimisticId } from "../utils/optimistic";
// Toast types
interface Toast {

View File

@ -1,12 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import {
createOptimisticId,
createOptimisticEntity,
isOptimistic,
replaceOptimisticEntity,
removeDuplicateEntities,
cleanOptimisticMetadata,
} from "./optimistic";
createOptimisticEntity,
createOptimisticId,
isOptimistic,
removeDuplicateEntities,
replaceOptimisticEntity,
} from "../optimistic";
describe("Optimistic Update Utilities", () => {
describe("createOptimisticId", () => {

View File

@ -1,7 +1,7 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render as rtlRender } from "@testing-library/react";
import type React from "react";
import { createTestQueryClient } from "../shared/queryClient";
import { createTestQueryClient } from "../shared/config/queryClient";
import { ToastProvider } from "../ui/components/ToastProvider";
import { TooltipProvider } from "../ui/primitives/tooltip";