Skip to content

feat: implement manual cluster management system#1196

Open
Arunodoy18 wants to merge 1 commit intoAOSSIE-Org:mainfrom
Arunodoy18:feat/1185-cluster-management-system
Open

feat: implement manual cluster management system#1196
Arunodoy18 wants to merge 1 commit intoAOSSIE-Org:mainfrom
Arunodoy18:feat/1185-cluster-management-system

Conversation

@Arunodoy18
Copy link

@Arunodoy18 Arunodoy18 commented Feb 25, 2026

Backend:

  • Add manual_clusters + manual_cluster_images tables (SQLite, FK cascade)
  • Service layer with ClusterNotFoundError / ImageNotFoundError exceptions
  • RESTful endpoints: POST/GET/PATCH/DELETE /clusters, bulk assign/remove images
  • 18 unit tests covering all CRUD paths and edge cases
  • Register tables and router in main.py

Frontend:

  • ManualCluster TypeScript types
  • Axios API functions for all 7 endpoints
  • Redux slice (manualClustersSlice) with optimistic remove
  • useManualClusters custom hook (loading/error state, rollback on failure)
  • ClusterCard component (inline rename, delete)
  • ImagePickerModal (paginated 40/page, debounced search, multi-select)
  • ClusterListPage (/clusters) and ClusterDetailPage (/clusters/:id)
  • My Clusters nav item in sidebar
  • New CLUSTERS + CLUSTER_DETAIL routes

Addressed Issues:

Fixes #(TODO:issue number)

Screenshots/Recordings:

Additional Notes:

Checklist

  • My PR addresses a single issue, fixes a single bug or makes a single improvement.
  • My code follows the project's code style and conventions
  • If applicable, I have made corresponding changes or additions to the documentation
  • If applicable, I have made corresponding changes or additions to tests
  • My changes generate no new warnings or errors
  • I have joined the Discord server and I will share a link to this PR with the project maintainers there
  • I have read the Contribution Guidelines
  • Once I submit my PR, CodeRabbit AI will automatically review it and I will address CodeRabbit's comments.

AI Usage Disclosure

Check one of the checkboxes below:

  • [ . ] This PR does not contain AI-generated code at all.
  • This PR contains AI-generated code. I have tested the code locally and I am responsible for it.

Summary by CodeRabbit

Release Notes

New Features

  • Manual cluster management: create, rename, and delete clusters to organize images into custom collections
  • Cluster detail page with image gallery for viewing and managing cluster contents
  • Image picker modal to assign multiple images to clusters simultaneously
  • "My Clusters" navigation menu for quick access to all cluster collections
  • Complete backend API and database infrastructure for cluster operations

Backend:
- Add manual_clusters + manual_cluster_images tables (SQLite, FK cascade)
- Service layer with ClusterNotFoundError / ImageNotFoundError exceptions
- RESTful endpoints: POST/GET/PATCH/DELETE /clusters, bulk assign/remove images
- 18 unit tests covering all CRUD paths and edge cases
- Register tables and router in main.py

Frontend:
- ManualCluster TypeScript types
- Axios API functions for all 7 endpoints
- Redux slice (manualClustersSlice) with optimistic remove
- useManualClusters custom hook (loading/error state, rollback on failure)
- ClusterCard component (inline rename, delete)
- ImagePickerModal (paginated 40/page, debounced search, multi-select)
- ClusterListPage (/clusters) and ClusterDetailPage (/clusters/:id)
- My Clusters nav item in sidebar
- New CLUSTERS + CLUSTER_DETAIL routes
@github-actions
Copy link
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Walkthrough

This pull request introduces a complete manual cluster management feature spanning backend and frontend. It adds database persistence, CRUD service operations, RESTful API endpoints, Redux state management, UI components, and page routes to enable users to create, organize, and manage image clusters.

Changes

Cohort / File(s) Summary
Backend Database Layer
backend/app/database/manual_clusters.py
Implements SQLite tables for manual clusters and image mappings with CRUD operations, foreign key constraints, cascade deletes, and image existence validation.
Backend Service Layer
backend/app/utils/manual_cluster_service.py
Business logic for cluster management with domain-specific exceptions (ClusterNotFoundError, ImageNotFoundError, DuplicateClusterNameError) and operations like create, list, rename, delete, assign/remove images.
Backend API Layer
backend/app/routes/manual_clusters.py, backend/app/schemas/manual_clusters.py
RESTful endpoints for cluster CRUD, image assignment/removal, and structured request/response schemas with validation and error handling.
Backend Integration & Tests
backend/main.py, backend/tests/conftest.py, backend/tests/test_manual_clusters.py
Routes initialization, database table creation on startup, and comprehensive unit tests covering success/error paths for all endpoints.
Frontend Types & API Integration
frontend/src/types/ManualCluster.ts, frontend/src/api/api-functions/manual_clusters.ts, frontend/src/api/apiEndpoints.ts
TypeScript interfaces for clusters/images/payloads and API client functions wrapping HTTP calls to backend endpoints.
Frontend State Management
frontend/src/features/manualClustersSlice.ts, frontend/src/app/store.ts
Redux slice managing clusters and detail cache with reducers for list/detail operations and image sync; integrated into root store.
Frontend Custom Hook
frontend/src/hooks/useManualClusters.ts
Custom React hook centralizing cluster operations (load, create, rename, delete, assign/remove images) with Redux dispatch and API coordination.
Frontend UI Components & Pages
frontend/src/components/Clusters/ClusterCard.tsx, frontend/src/components/Clusters/ImagePickerModal.tsx, frontend/src/pages/Clusters/ClusterListPage.tsx, frontend/src/pages/Clusters/ClusterDetailPage.tsx
Components for cluster cards with inline rename/delete, image picker modal with search/select, and pages for listing and viewing cluster details.
Frontend Routing & Navigation
frontend/src/constants/routes.ts, frontend/src/routes/AppRoutes.tsx, frontend/src/components/Navigation/Sidebar/AppSidebar.tsx
Route constants and registrations for cluster list/detail pages, sidebar navigation item with prefix-based active state, and export aggregation.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Frontend
    participant Server as Backend API
    participant DB as SQLite Database

    User->>Client: Click "Create Cluster"
    Client->>Server: POST /clusters/ {name}
    Server->>DB: INSERT cluster record
    DB-->>Server: Return cluster_id, timestamps
    Server-->>Client: 201 {cluster}
    Client->>Client: Redux: addCluster()
    Client-->>User: Show new cluster in list

    User->>Client: Click "My Clusters"
    Client->>Server: GET /clusters/
    Server->>DB: SELECT all clusters with image_count
    DB-->>Server: Return cluster rows
    Server-->>Client: 200 {clusters[]}
    Client->>Client: Redux: setClusters()
    Client-->>User: Display cluster grid
Loading
sequenceDiagram
    actor User
    participant Client as Frontend
    participant Server as Backend API
    participant DB as SQLite Database

    User->>Client: Click cluster, open ImagePickerModal
    Client->>Client: Display available images
    User->>Client: Select images, click "Assign"
    Client->>Server: POST /clusters/{id}/images {image_ids}
    Server->>DB: Validate image_ids exist
    DB-->>Server: Confirmation
    Server->>DB: INSERT image mappings (OR IGNORE)
    DB-->>Server: Return assigned/skipped counts
    Server-->>Client: 200 {assigned_count, skipped_count}
    Client->>Client: Redux: appendImages()
    Client-->>User: Update cluster detail with new images
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

Python, TypeScript/JavaScript

Suggested reviewers

  • rahulharpal1603

Poem

🐰 Hop along, dear clusters bright,
With manual hands we organize right,
Cards and modals, images to assign,
Redux and routes in harmony align,
From backend to frontend, a feature so fine!

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: implement manual cluster management system' accurately captures the main change: a comprehensive manual cluster management system spanning both backend and frontend with database, API routes, UI components, and state management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (11)
backend/tests/test_manual_clusters.py (1)

143-167: Consider adding a test for rename conflict (409) once the route handler is updated.

If DuplicateClusterNameError handling is added to the rename endpoint (as flagged in the routes review), a corresponding test should accompany it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_manual_clusters.py` around lines 143 - 167, Add a new test
method in the TestRenameCluster class (e.g., test_rename_conflict) that patches
app.routes.manual_clusters.rename_cluster to raise DuplicateClusterNameError and
then calls the PATCH /clusters/{id} endpoint with the conflicting name; assert
the response.status_code is 409 and the body indicates conflict. Reference the
DuplicateClusterNameError exception and the rename_cluster patch used in other
tests to implement this test consistently with test_rename_missing_cluster.
frontend/src/pages/Clusters/ClusterDetailPage.tsx (3)

42-45: Same eslint-disable pattern as ClusterListPage — loadClusterDetail can safely be added to the dep array.

loadClusterDetail is memoized with useCallback([dispatch]), so including it won't cause extra renders. Also, clusterId is already in the dep array, which is correct.

♻️ Suggested change
   useEffect(() => {
     if (clusterId) loadClusterDetail(clusterId);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [clusterId]);
+  }, [clusterId, loadClusterDetail]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx` around lines 42 - 45, The
effect currently silences eslint for missing deps; update the useEffect for
loading details to include loadClusterDetail in the dependency array (i.e.,
useEffect(() => { if (clusterId) loadClusterDetail(clusterId); }, [clusterId,
loadClusterDetail])) since loadClusterDetail is already memoized via
useCallback([dispatch]) and won't cause extra renders; keep clusterId in the
deps as well and remove the eslint-disable comment.

47-55: Consider using successData from usePictoQuery instead of double-nesting data?.data.

The usePictoQuery hook already provides a successData field (as seen in frontend/src/hooks/useQueryExtension.ts) that extracts the nested data property. Using it would be cleaner and more consistent with the hook's intended API.

♻️ Suggested change
-  const { data: allImagesData } = usePictoQuery({
+  const { successData } = usePictoQuery({
     queryKey: ['all-images-for-picker'],
     queryFn: () => fetchAllImages(),
   });
   const allImages: Image[] = useMemo(
-    () => (allImagesData?.data as Image[]) ?? [],
-    [allImagesData],
+    () => (successData as Image[]) ?? [],
+    [successData],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx` around lines 47 - 55, The
code is accessing nested response data via allImagesData?.data instead of using
the hook's normalized success payload; update the use of usePictoQuery to read
from its successData field (provided by useQueryExtension) — replace references
to allImagesData and the memo that derives allImages from allImagesData?.data
with successData (e.g., const { successData: allImagesSuccess } =
usePictoQuery(...) and derive allImages from that) while keeping the existing
queryKey and queryFn (fetchAllImages) and preserving the allImages: Image[] type
and useMemo wrapper.

290-294: Use Tailwind's aspect-square class instead of inline style.

♻️ Suggested change
     <div
-      className="group relative overflow-hidden rounded-md border"
-      style={{ aspectRatio: '1/1' }}
+      className="group relative aspect-square overflow-hidden rounded-md border"
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx` around lines 290 - 294,
Replace the inline style on the JSX container with Tailwind's utility: in the
ClusterDetailPage component update the returned <div> (the element with
className "group relative overflow-hidden rounded-md border") to remove the
style={{ aspectRatio: '1/1' }} prop and add the Tailwind class "aspect-square"
to its className so it becomes "group relative overflow-hidden rounded-md border
aspect-square".
frontend/src/components/Clusters/ClusterCard.tsx (1)

48-50: Hardcoded navigation path.

The path /clusters/${cluster.cluster_id} duplicates the route definition from ROUTES.CLUSTER_DETAIL. Consider deriving it from the constant to avoid drift if the route changes. The same pattern appears in ClusterDetailPage.tsx (lines 85, 130, 147).

♻️ Example
+import { ROUTES } from '@/constants/routes';
+
 const handleCardClick = () => {
-    if (!editing) navigate(`/clusters/${cluster.cluster_id}`);
+    if (!editing) navigate(`/${ROUTES.CLUSTERS}/${cluster.cluster_id}`);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Clusters/ClusterCard.tsx` around lines 48 - 50,
Replace the hardcoded path in handleCardClick with the ROUTES constant: instead
of navigate(`/clusters/${cluster.cluster_id}`) use the ROUTES.CLUSTER_DETAIL
template (or a helper that formats it) to build the URL using
cluster.cluster_id; update the call in ClusterCard's handleCardClick and mirror
the same change where the literal path is used in ClusterDetailPage (the
occurrences around the handlers at lines referenced) so all navigation uses
ROUTES.CLUSTER_DETAIL with the id parameter rather than a duplicated string.
backend/app/routes/manual_clusters.py (1)

201-201: Inconsistent use of Path(...) annotation.

rename_cluster_endpoint and assign_images_endpoint declare cluster_id: str without Path(...), while other endpoints (lines 166, 235, 310) use Path(...). This inconsistency doesn't affect functionality (FastAPI infers path params), but is worth harmonizing for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/routes/manual_clusters.py` at line 201, The cluster_id parameter
is inconsistently annotated across endpoints; update the signature of
rename_cluster_endpoint (and similarly assign_images_endpoint) to use FastAPI's
Path(...) annotation for cluster_id so it matches other handlers (e.g., the
endpoints at lines referenced that already use Path(...)); change the parameter
declaration to accept cluster_id: str = Path(..., description="cluster id") (or
equivalent) to harmonize annotations and include an explanatory description if
present in other handlers.
frontend/src/pages/Clusters/ClusterListPage.tsx (2)

32-35: Consider including loadClusters in the dependency array instead of suppressing the lint rule.

Since loadClusters is memoized with useCallback and depends only on dispatch (which is stable), adding it to the dep array is safe and eliminates the need for the eslint-disable comment.

♻️ Suggested change
   useEffect(() => {
     loadClusters();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
+  }, [loadClusters]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterListPage.tsx` around lines 32 - 35,
Replace the eslint suppression by including the memoized loadClusters function
in the useEffect dependency array: update the useEffect that currently calls
loadClusters() so its deps include loadClusters, and remove the "//
eslint-disable-next-line react-hooks/exhaustive-deps" comment; loadClusters is
defined with useCallback and only depends on dispatch so it is stable and safe
to add as a dependency.

119-126: Dialog can be dismissed while creation is in progress.

The onOpenChange handler doesn't check creating, so a user can close the dialog via Escape or clicking outside while the API call is in flight. The Cancel button is correctly disabled, but the dialog itself is not guarded. Consider adding a guard:

♻️ Suggested change
       <Dialog
         open={showCreate}
         onOpenChange={(v) => {
-          if (!v) {
+          if (!v && !creating) {
             setShowCreate(false);
             setNewName('');
           }
         }}
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterListPage.tsx` around lines 119 - 126, The
Dialog's onOpenChange currently closes the modal and resets state even when a
create request is in flight; update the onOpenChange handler (the function
passed to Dialog) to guard against closing when the creating flag is true by
returning early if v is false and creating is true, and only call
setShowCreate(false) and setNewName('') when not creating; reference Dialog's
onOpenChange, the creating variable, setShowCreate and setNewName to locate
where to add the guard.
frontend/src/features/manualClustersSlice.ts (1)

51-69: appendImages reducer is unused and should be removed or documented.

The reducer is exported from manualClustersSlice.ts but never imported or dispatched anywhere in the codebase. In useManualClusters.ts, the assignImages function reloads the full cluster detail via setClusterDetail after the API call (lines 172–176) rather than using the incremental appendImages reducer.

If this reducer is intended for future optimizations, add a brief comment explaining its purpose. Otherwise, remove it to keep the slice surface area minimal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/features/manualClustersSlice.ts` around lines 51 - 69, The
appendImages reducer in manualClustersSlice.ts is unused (no imports/dispatches)
while useManualClusters.ts's assignImages calls setClusterDetail after the API
update; either remove appendImages to shrink the slice surface or keep it but
add a short TODO comment explaining its purpose (e.g., intended for incremental
client-side updates to avoid full reloads) and reference where it would be used
(assignImages / setClusterDetail) so future devs know why it exists; update
exports accordingly (remove from export list if deleting, or leave exported with
the explanatory comment if retaining).
frontend/src/hooks/useManualClusters.ts (1)

35-35: useDispatch() should use the typed AppDispatch.

Using the untyped useDispatch() loses type-safety for dispatched actions. Create and use a typed hook as is standard practice in Redux Toolkit projects.

♻️ Proposed fix
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
+import type { AppDispatch } from '@/app/store';

 export function useManualClusters() {
-  const dispatch = useDispatch();
+  const dispatch = useDispatch<AppDispatch>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useManualClusters.ts` at line 35, The code uses the
untyped useDispatch() in useManualClusters.ts which loses action type-safety;
create a typed hook useAppDispatch that returns useDispatch<AppDispatch>() (or
add/consume an existing useAppDispatch) and replace the raw useDispatch() call
with useAppDispatch(); import AppDispatch from your store types if needed and
update the declaration `const dispatch = useDispatch();` to `const dispatch =
useAppDispatch();` so dispatched actions in useManualClusters are properly
typed.
backend/app/database/manual_clusters.py (1)

207-231: Loop-based insert in db_add_images_to_manual_cluster — use executemany for better performance.

Iterating Python-side to detect inserted rows is the only reason for the loop. An executemany for the bulk insert, combined with a pre-query to find already-assigned ids, would be faster on large batches and reduce per-row overhead.

♻️ Proposed refactor
 def db_add_images_to_manual_cluster(cluster_id: str, image_ids: List[str]) -> List[str]:
-    inserted: List[str] = []
     with get_db_connection() as conn:
-        for image_id in image_ids:
-            cursor = conn.execute(
-                """
-                INSERT OR IGNORE INTO manual_cluster_images (cluster_id, image_id)
-                VALUES (?, ?)
-                """,
-                (cluster_id, image_id),
-            )
-            if cursor.rowcount > 0:
-                inserted.append(image_id)
+        # Find already-assigned images before bulk insert
+        placeholders = ",".join("?" * len(image_ids))
+        existing_cursor = conn.execute(
+            f"SELECT image_id FROM manual_cluster_images "
+            f"WHERE cluster_id = ? AND image_id IN ({placeholders})",
+            [cluster_id, *image_ids],
+        )
+        already_assigned = {row[0] for row in existing_cursor.fetchall()}
+        inserted = [iid for iid in image_ids if iid not in already_assigned]
+        conn.executemany(
+            "INSERT OR IGNORE INTO manual_cluster_images (cluster_id, image_id) VALUES (?, ?)",
+            [(cluster_id, iid) for iid in inserted],
+        )
         # Bump updated_at on the parent cluster
         conn.execute(
             "UPDATE manual_clusters SET updated_at = ? WHERE cluster_id = ?",
             (_utcnow(), cluster_id),
         )
     return inserted
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/database/manual_clusters.py` around lines 207 - 231, The loop in
db_add_images_to_manual_cluster is slow; instead, query existing image_ids for
the given cluster_id via get_db_connection() (SELECT image_id FROM
manual_cluster_images WHERE cluster_id = ? AND image_id IN (...)) to compute the
delta, then use conn.executemany(...) to perform a single bulk "INSERT OR IGNORE
INTO manual_cluster_images (cluster_id, image_id) VALUES (?, ?)" for only the
new image ids, update the parent row's updated_at with _utcnow(), and return the
list of actually-inserted ids; reference the function
db_add_images_to_manual_cluster, get_db_connection, and _utcnow when making the
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/app/database/manual_clusters.py`:
- Around line 23-29: The TypedDict ManualClusterRecord is missing the
image_count field which _row_to_cluster(row, include_count=True) always adds;
update ManualClusterRecord to include image_count: int (or mark it Optional[int]
if it can be missing) so the declared return types of db_get_all_manual_clusters
and db_get_manual_cluster_by_id correctly reflect the actual dict shape returned
by _row_to_cluster; ensure the type matches how _row_to_cluster populates
image_count and adjust callers only if image_count can be absent/None.
- Around line 234-249: Move the return inside the database context to avoid
reading cursor.rowcount after the connection is closed: in
db_remove_image_from_manual_cluster, keep the with get_db_connection() as conn:
block wrapping the DELETE and UPDATE logic and return cursor.rowcount > 0 before
exiting that block (i.e., directly after the optional UPDATE using cursor and
conn), referencing the existing cursor, conn, get_db_connection, _utcnow,
manual_cluster_images and manual_clusters symbols so the function returns while
the connection is still open.

In `@backend/app/routes/manual_clusters.py`:
- Around line 116-123: The catch-all except blocks in manual_clusters.py (where
logger.exception(...) is used and an HTTPException is raised with ErrorResponse)
currently put message=str(exc) into the response and leak internal details;
change each handler to keep logger.exception(exc) for server-side diagnostics
but replace message=str(exc) with a generic message such as "Internal server
error" (or "An internal error occurred") when constructing ErrorResponse passed
to HTTPException, and apply this change to all similar blocks that raise
HTTPException(status_code=500, detail=ErrorResponse(...).model_dump()) so no
exception text is returned to clients.

In `@backend/app/schemas/manual_clusters.py`:
- Around line 115-119: AssignImagesResponse currently places assigned_count and
skipped_count at the top level, breaking the common response envelope and
forcing an any cast in useManualClusters.ts; update AssignImagesResponse to wrap
the payload into a data object (e.g., data: { assigned_count: int,
skipped_count: int }) so the schema matches other responses and the frontend can
read response.data.assigned_count without casting, then update any server code
that returns this model to populate the data field and ensure the client hook
useManualClusters.ts reads the counts from response.data.
- Around line 26-45: Extract the duplicated name field and validator into a
shared BaseModel (e.g., class ClusterNameRequest(BaseModel)) that defines name:
str and the `@field_validator` "name" method name_must_not_be_blank, then have
CreateClusterRequest and RenameClusterRequest inherit from ClusterNameRequest
instead of redefining the field and validator so both classes are DRY and reuse
the common validation logic.
- Line 76: Rename the Pydantic model field thumbnailPath to snake_case
(thumbnail_path) and add a JSON alias so external JSON still uses
"thumbnailPath"; update the model by changing the attribute in the model class
(e.g., ManualCluster or whichever class contains thumbnailPath) to
thumbnail_path: Optional[str] = None and supply an alias via
Field(alias="thumbnailPath") or via model_config/Config alias settings (and
enable allow_population_by_field_name if needed) so internal Python code follows
PEP 8 while wire format remains unchanged.

In `@backend/app/utils/manual_cluster_service.py`:
- Around line 53-64: The docstring mentions DuplicateClusterNameError but the
create_cluster function never raises it; either remove the misleading docstring
entry and delete the unused DuplicateClusterNameError class, or implement a
uniqueness check and raise it: before calling db_insert_manual_cluster in
create_cluster(name: str) query for an existing cluster by name (e.g., via an
existing db lookup helper or add one), if found raise DuplicateClusterNameError,
otherwise proceed to call db_insert_manual_cluster and log as before.
- Around line 98-103: In rename_cluster, avoid the TOCTOU by capturing the
result of _require_cluster(cluster_id) into a local variable (e.g., cluster),
remove the redundant comment, call db_update_manual_cluster_name(cluster_id,
name), update cluster.name = name and return that cluster instead of calling
db_get_manual_cluster_by_id; also remove the unnecessary "# type:
ignore[return-value]" and keep the existing logger.info("Manual cluster renamed:
id=%s new_name=%s", cluster_id, name) call. This reuses the fetched value,
prevents a race where a concurrent delete makes db_get_manual_cluster_by_id
return None, and preserves type safety.

In `@backend/tests/test_manual_clusters.py`:
- Around line 56-81: Add a test method to TestCreateCluster that verifies the
POST /clusters/ returns 409 when create_cluster raises
DuplicateClusterNameError: patch "app.routes.manual_clusters.create_cluster" to
raise app.utils.manual_cluster_service.DuplicateClusterNameError (use
side_effect), call client.post("/clusters/", json={"name": "Existing"}) and
assert response.status_code == 409; name the test e.g.
test_create_cluster_duplicate_name_returns_409 and import or reference
DuplicateClusterNameError in the test.

In `@frontend/src/components/Clusters/ImagePickerModal.tsx`:
- Around line 60-66: When debouncedSearch changes we must reset the pagination
so previously loaded pages don't carry over; update the effect that sets
debouncedSearch (the useEffect using searchTimer and calling setDebouncedSearch)
to also call setPage(1) (or add a separate useEffect that watches
debouncedSearch and calls setPage(1)), ensuring the component state variable
page is reset whenever debouncedSearch changes so paged (page * PAGE_SIZE)
reflects results from the first page; apply the same reset logic to the other
search-related effect that manages debouncedSearch/filters in ImagePickerModal
so all search updates reset page.
- Around line 109-111: The selectAll callback currently sets selection to all
filtered image IDs including those in assignedIds; update selectAll so it builds
the new Set from filtered.map(i => i.id) but filters out any id present in
assignedIds before calling setSelected, and add assignedIds to the useCallback
dependency array so the callback updates when assignments change; this keeps
PickerImageCard (which blocks toggling assigned images) consistent and prevents
already-assigned images from being sent to onAssign.

In `@frontend/src/hooks/useManualClusters.ts`:
- Around line 196-221: The removeImageFromClusters function currently omits
updating the hook's loading state; update removeImageFromClusters to call
setLoading(true) at the start and setLoading(false) in a finally block so
loading is cleared on both success and failure, preserving the existing
optimistic dispatch(removeImage({ clusterId, imageId })), rollback logic that
uses fetchManualClusterById and dispatch(setClusterDetail(...)), and the boolean
return values; ensure setLoading(false) runs after both the normal return true
and all error/rollback returns.
- Around line 178-179: The cast to any is used because AssignImagesResponse
places assigned_count/skipped_count at the top level; update the typing and
access pattern to avoid any: replace uses of (res as any).assigned_count and
(res as any).skipped_count in useManualClusters (the block setting assigned and
skipped) with properly typed accesses from AssignImagesResponse (e.g.,
res.data.assigned_count / res.data.skipped_count) or update AssignImagesResponse
to include those fields in a typed data envelope, then change the variable type
from unknown/any to AssignImagesResponse so no any cast is needed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx`:
- Around line 273-276: The onRemove prop in ClusterImageCardProps is declared as
returning void but is awaited by ClusterImageCard and implemented by the async
function handleRemove; update the prop signature in interface
ClusterImageCardProps so onRemove returns Promise<void> (i.e., change its type
to (imageId: string) => Promise<void>), and ensure any callers of
ClusterImageCard (or implementations like handleRemove) continue to return a
Promise<void>.

---

Nitpick comments:
In `@backend/app/database/manual_clusters.py`:
- Around line 207-231: The loop in db_add_images_to_manual_cluster is slow;
instead, query existing image_ids for the given cluster_id via
get_db_connection() (SELECT image_id FROM manual_cluster_images WHERE cluster_id
= ? AND image_id IN (...)) to compute the delta, then use conn.executemany(...)
to perform a single bulk "INSERT OR IGNORE INTO manual_cluster_images
(cluster_id, image_id) VALUES (?, ?)" for only the new image ids, update the
parent row's updated_at with _utcnow(), and return the list of actually-inserted
ids; reference the function db_add_images_to_manual_cluster, get_db_connection,
and _utcnow when making the changes.

In `@backend/app/routes/manual_clusters.py`:
- Line 201: The cluster_id parameter is inconsistently annotated across
endpoints; update the signature of rename_cluster_endpoint (and similarly
assign_images_endpoint) to use FastAPI's Path(...) annotation for cluster_id so
it matches other handlers (e.g., the endpoints at lines referenced that already
use Path(...)); change the parameter declaration to accept cluster_id: str =
Path(..., description="cluster id") (or equivalent) to harmonize annotations and
include an explanatory description if present in other handlers.

In `@backend/tests/test_manual_clusters.py`:
- Around line 143-167: Add a new test method in the TestRenameCluster class
(e.g., test_rename_conflict) that patches
app.routes.manual_clusters.rename_cluster to raise DuplicateClusterNameError and
then calls the PATCH /clusters/{id} endpoint with the conflicting name; assert
the response.status_code is 409 and the body indicates conflict. Reference the
DuplicateClusterNameError exception and the rename_cluster patch used in other
tests to implement this test consistently with test_rename_missing_cluster.

In `@frontend/src/components/Clusters/ClusterCard.tsx`:
- Around line 48-50: Replace the hardcoded path in handleCardClick with the
ROUTES constant: instead of navigate(`/clusters/${cluster.cluster_id}`) use the
ROUTES.CLUSTER_DETAIL template (or a helper that formats it) to build the URL
using cluster.cluster_id; update the call in ClusterCard's handleCardClick and
mirror the same change where the literal path is used in ClusterDetailPage (the
occurrences around the handlers at lines referenced) so all navigation uses
ROUTES.CLUSTER_DETAIL with the id parameter rather than a duplicated string.

In `@frontend/src/features/manualClustersSlice.ts`:
- Around line 51-69: The appendImages reducer in manualClustersSlice.ts is
unused (no imports/dispatches) while useManualClusters.ts's assignImages calls
setClusterDetail after the API update; either remove appendImages to shrink the
slice surface or keep it but add a short TODO comment explaining its purpose
(e.g., intended for incremental client-side updates to avoid full reloads) and
reference where it would be used (assignImages / setClusterDetail) so future
devs know why it exists; update exports accordingly (remove from export list if
deleting, or leave exported with the explanatory comment if retaining).

In `@frontend/src/hooks/useManualClusters.ts`:
- Line 35: The code uses the untyped useDispatch() in useManualClusters.ts which
loses action type-safety; create a typed hook useAppDispatch that returns
useDispatch<AppDispatch>() (or add/consume an existing useAppDispatch) and
replace the raw useDispatch() call with useAppDispatch(); import AppDispatch
from your store types if needed and update the declaration `const dispatch =
useDispatch();` to `const dispatch = useAppDispatch();` so dispatched actions in
useManualClusters are properly typed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx`:
- Around line 42-45: The effect currently silences eslint for missing deps;
update the useEffect for loading details to include loadClusterDetail in the
dependency array (i.e., useEffect(() => { if (clusterId)
loadClusterDetail(clusterId); }, [clusterId, loadClusterDetail])) since
loadClusterDetail is already memoized via useCallback([dispatch]) and won't
cause extra renders; keep clusterId in the deps as well and remove the
eslint-disable comment.
- Around line 47-55: The code is accessing nested response data via
allImagesData?.data instead of using the hook's normalized success payload;
update the use of usePictoQuery to read from its successData field (provided by
useQueryExtension) — replace references to allImagesData and the memo that
derives allImages from allImagesData?.data with successData (e.g., const {
successData: allImagesSuccess } = usePictoQuery(...) and derive allImages from
that) while keeping the existing queryKey and queryFn (fetchAllImages) and
preserving the allImages: Image[] type and useMemo wrapper.
- Around line 290-294: Replace the inline style on the JSX container with
Tailwind's utility: in the ClusterDetailPage component update the returned <div>
(the element with className "group relative overflow-hidden rounded-md border")
to remove the style={{ aspectRatio: '1/1' }} prop and add the Tailwind class
"aspect-square" to its className so it becomes "group relative overflow-hidden
rounded-md border aspect-square".

In `@frontend/src/pages/Clusters/ClusterListPage.tsx`:
- Around line 32-35: Replace the eslint suppression by including the memoized
loadClusters function in the useEffect dependency array: update the useEffect
that currently calls loadClusters() so its deps include loadClusters, and remove
the "// eslint-disable-next-line react-hooks/exhaustive-deps" comment;
loadClusters is defined with useCallback and only depends on dispatch so it is
stable and safe to add as a dependency.
- Around line 119-126: The Dialog's onOpenChange currently closes the modal and
resets state even when a create request is in flight; update the onOpenChange
handler (the function passed to Dialog) to guard against closing when the
creating flag is true by returning early if v is false and creating is true, and
only call setShowCreate(false) and setNewName('') when not creating; reference
Dialog's onOpenChange, the creating variable, setShowCreate and setNewName to
locate where to add the guard.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b270a35 and cbc1b12.

📒 Files selected for processing (21)
  • backend/app/database/manual_clusters.py
  • backend/app/routes/manual_clusters.py
  • backend/app/schemas/manual_clusters.py
  • backend/app/utils/manual_cluster_service.py
  • backend/main.py
  • backend/tests/conftest.py
  • backend/tests/test_manual_clusters.py
  • frontend/src/api/api-functions/index.ts
  • frontend/src/api/api-functions/manual_clusters.ts
  • frontend/src/api/apiEndpoints.ts
  • frontend/src/app/store.ts
  • frontend/src/components/Clusters/ClusterCard.tsx
  • frontend/src/components/Clusters/ImagePickerModal.tsx
  • frontend/src/components/Navigation/Sidebar/AppSidebar.tsx
  • frontend/src/constants/routes.ts
  • frontend/src/features/manualClustersSlice.ts
  • frontend/src/hooks/useManualClusters.ts
  • frontend/src/pages/Clusters/ClusterDetailPage.tsx
  • frontend/src/pages/Clusters/ClusterListPage.tsx
  • frontend/src/routes/AppRoutes.tsx
  • frontend/src/types/ManualCluster.ts

Comment on lines +23 to +29
class ManualClusterRecord(TypedDict):
cluster_id: str
name: str
created_at: str
updated_at: str
is_auto_generated: bool

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ManualClusterRecord TypedDict is missing image_count — return type annotations are inaccurate.

db_get_all_manual_clusters and db_get_manual_cluster_by_id both call _row_to_cluster(row, include_count=True), which unconditionally adds image_count to the returned dict. However, ManualClusterRecord has no image_count field, so the declared return types List[ManualClusterRecord] and Optional[ManualClusterRecord] are incorrect. Callers relying on these type hints will get no IDE/mypy support for image_count.

♻️ Proposed fix
 class ManualClusterRecord(TypedDict):
     cluster_id: str
     name: str
     created_at: str
     updated_at: str
     is_auto_generated: bool
+    image_count: int

Also applies to: 101-121, 124-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/database/manual_clusters.py` around lines 23 - 29, The TypedDict
ManualClusterRecord is missing the image_count field which _row_to_cluster(row,
include_count=True) always adds; update ManualClusterRecord to include
image_count: int (or mark it Optional[int] if it can be missing) so the declared
return types of db_get_all_manual_clusters and db_get_manual_cluster_by_id
correctly reflect the actual dict shape returned by _row_to_cluster; ensure the
type matches how _row_to_cluster populates image_count and adjust callers only
if image_count can be absent/None.

Comment on lines +234 to +249
def db_remove_image_from_manual_cluster(cluster_id: str, image_id: str) -> bool:
"""Remove a single image from a cluster. Returns True if removed."""
with get_db_connection() as conn:
cursor = conn.execute(
"""
DELETE FROM manual_cluster_images
WHERE cluster_id = ? AND image_id = ?
""",
(cluster_id, image_id),
)
if cursor.rowcount > 0:
conn.execute(
"UPDATE manual_clusters SET updated_at = ? WHERE cluster_id = ?",
(_utcnow(), cluster_id),
)
return cursor.rowcount > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

cursor.rowcount is accessed on line 249 outside the with block — move the return inside.

The with get_db_connection() as conn: block closes (and commits) the connection on exit. While the cursor local variable remains in scope and its rowcount attribute is still readable after the with exits, this is fragile and non-obvious. Move the return inside the block for clarity and safety.

♻️ Proposed fix
 def db_remove_image_from_manual_cluster(cluster_id: str, image_id: str) -> bool:
     """Remove a single image from a cluster.  Returns True if removed."""
     with get_db_connection() as conn:
         cursor = conn.execute(
             """
             DELETE FROM manual_cluster_images
             WHERE cluster_id = ? AND image_id = ?
             """,
             (cluster_id, image_id),
         )
         if cursor.rowcount > 0:
             conn.execute(
                 "UPDATE manual_clusters SET updated_at = ? WHERE cluster_id = ?",
                 (_utcnow(), cluster_id),
             )
-    return cursor.rowcount > 0
+        return cursor.rowcount > 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/database/manual_clusters.py` around lines 234 - 249, Move the
return inside the database context to avoid reading cursor.rowcount after the
connection is closed: in db_remove_image_from_manual_cluster, keep the with
get_db_connection() as conn: block wrapping the DELETE and UPDATE logic and
return cursor.rowcount > 0 before exiting that block (i.e., directly after the
optional UPDATE using cursor and conn), referencing the existing cursor, conn,
get_db_connection, _utcnow, manual_cluster_images and manual_clusters symbols so
the function returns while the connection is still open.

Comment on lines +116 to +123
except Exception as exc:
logger.exception("Unexpected error creating cluster")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ErrorResponse(
success=False, error="Internal Error", message=str(exc)
).model_dump(),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Internal error details are leaked to the client in 500 responses.

All catch-all handlers use message=str(exc), which may expose stack traces, file paths, or database details to the API consumer. Use a generic message for the response and rely on the logger.exception call for server-side diagnostics.

🛡️ Suggested change (apply to all 500 handlers)
-            detail=ErrorResponse(
-                success=False, error="Internal Error", message=str(exc)
-            ).model_dump(),
+            detail=ErrorResponse(
+                success=False, error="Internal Error", message="An unexpected error occurred."
+            ).model_dump(),

Also applies to: 145-152, 180-187, 214-221, 245-252, 289-296, 329-338

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/routes/manual_clusters.py` around lines 116 - 123, The catch-all
except blocks in manual_clusters.py (where logger.exception(...) is used and an
HTTPException is raised with ErrorResponse) currently put message=str(exc) into
the response and leak internal details; change each handler to keep
logger.exception(exc) for server-side diagnostics but replace message=str(exc)
with a generic message such as "Internal server error" (or "An internal error
occurred") when constructing ErrorResponse passed to HTTPException, and apply
this change to all similar blocks that raise HTTPException(status_code=500,
detail=ErrorResponse(...).model_dump()) so no exception text is returned to
clients.

Comment on lines +26 to +45
class CreateClusterRequest(BaseModel):
name: str

@field_validator("name")
@classmethod
def name_must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Cluster name must not be blank")
return v.strip()


class RenameClusterRequest(BaseModel):
name: str

@field_validator("name")
@classmethod
def name_must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Cluster name must not be blank")
return v.strip()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

CreateClusterRequest and RenameClusterRequest are identical — extract a shared base.

Both classes have the same name: str field and the same name_must_not_be_blank validator. This is a direct DRY violation.

♻️ Proposed refactor
+class _ClusterNameMixin(BaseModel):
+    name: str
+
+    `@field_validator`("name")
+    `@classmethod`
+    def name_must_not_be_blank(cls, v: str) -> str:
+        if not v.strip():
+            raise ValueError("Cluster name must not be blank")
+        return v.strip()
+
+
-class CreateClusterRequest(BaseModel):
-    name: str
-
-    `@field_validator`("name")
-    `@classmethod`
-    def name_must_not_be_blank(cls, v: str) -> str:
-        if not v.strip():
-            raise ValueError("Cluster name must not be blank")
-        return v.strip()
-
-
-class RenameClusterRequest(BaseModel):
-    name: str
-
-    `@field_validator`("name")
-    `@classmethod`
-    def name_must_not_be_blank(cls, v: str) -> str:
-        if not v.strip():
-            raise ValueError("Cluster name must not be blank")
-        return v.strip()
+class CreateClusterRequest(_ClusterNameMixin):
+    pass
+
+
+class RenameClusterRequest(_ClusterNameMixin):
+    pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/schemas/manual_clusters.py` around lines 26 - 45, Extract the
duplicated name field and validator into a shared BaseModel (e.g., class
ClusterNameRequest(BaseModel)) that defines name: str and the `@field_validator`
"name" method name_must_not_be_blank, then have CreateClusterRequest and
RenameClusterRequest inherit from ClusterNameRequest instead of redefining the
field and validator so both classes are DRY and reuse the common validation
logic.

class ImageInCluster(BaseModel):
id: str
path: str
thumbnailPath: Optional[str] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

thumbnailPath is camelCase — use snake_case with a field alias for JSON serialization.

Python field names must be snake_case per PEP 8. Use Pydantic's alias or model_config to keep the JSON wire format unchanged.

♻️ Proposed fix
 class ImageInCluster(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
     id: str
     path: str
-    thumbnailPath: Optional[str] = None
+    thumbnail_path: Optional[str] = Field(None, alias="thumbnailPath")
     metadata: Optional[Any] = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/schemas/manual_clusters.py` at line 76, Rename the Pydantic model
field thumbnailPath to snake_case (thumbnail_path) and add a JSON alias so
external JSON still uses "thumbnailPath"; update the model by changing the
attribute in the model class (e.g., ManualCluster or whichever class contains
thumbnailPath) to thumbnail_path: Optional[str] = None and supply an alias via
Field(alias="thumbnailPath") or via model_config/Config alias settings (and
enable allow_population_by_field_name if needed) so internal Python code follows
PEP 8 while wire format remains unchanged.

Comment on lines +60 to +66
useEffect(() => {
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => setDebouncedSearch(search), 300);
return () => {
if (searchTimer.current) clearTimeout(searchTimer.current);
};
}, [search]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Page count doesn't reset when search query changes.

If the user has loaded several pages (e.g., page=5) and then types a search query, paged will show up to page * PAGE_SIZE items from the new filtered results rather than resetting to the first page. Consider resetting page when debouncedSearch changes.

♻️ Proposed fix: reset page on search
  // Debounce search input
  useEffect(() => {
    if (searchTimer.current) clearTimeout(searchTimer.current);
-   searchTimer.current = setTimeout(() => setDebouncedSearch(search), 300);
+   searchTimer.current = setTimeout(() => {
+     setDebouncedSearch(search);
+     setPage(1);
+   }, 300);
    return () => {
      if (searchTimer.current) clearTimeout(searchTimer.current);
    };
  }, [search]);

Also applies to: 78-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Clusters/ImagePickerModal.tsx` around lines 60 - 66,
When debouncedSearch changes we must reset the pagination so previously loaded
pages don't carry over; update the effect that sets debouncedSearch (the
useEffect using searchTimer and calling setDebouncedSearch) to also call
setPage(1) (or add a separate useEffect that watches debouncedSearch and calls
setPage(1)), ensuring the component state variable page is reset whenever
debouncedSearch changes so paged (page * PAGE_SIZE) reflects results from the
first page; apply the same reset logic to the other search-related effect that
manages debouncedSearch/filters in ImagePickerModal so all search updates reset
page.

Comment on lines +109 to +111
const selectAll = useCallback(() => {
setSelected(new Set(filtered.map((i) => i.id)));
}, [filtered]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

selectAll includes already-assigned images, causing incorrect behavior.

selectAll adds every filtered image ID to the selection, including those in assignedIds. Since PickerImageCard prevents toggling assigned images off (line 237), the user cannot deselect them, and they will be sent to onAssign — potentially causing duplicate-assignment errors or confusing UI state.

🐛 Proposed fix: exclude already-assigned images
  const selectAll = useCallback(() => {
-   setSelected(new Set(filtered.map((i) => i.id)));
+   setSelected(new Set(filtered.filter((i) => !assignedIds.has(i.id)).map((i) => i.id)));
- }, [filtered]);
+ }, [filtered, assignedIds]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const selectAll = useCallback(() => {
setSelected(new Set(filtered.map((i) => i.id)));
}, [filtered]);
const selectAll = useCallback(() => {
setSelected(new Set(filtered.filter((i) => !assignedIds.has(i.id)).map((i) => i.id)));
}, [filtered, assignedIds]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Clusters/ImagePickerModal.tsx` around lines 109 -
111, The selectAll callback currently sets selection to all filtered image IDs
including those in assignedIds; update selectAll so it builds the new Set from
filtered.map(i => i.id) but filters out any id present in assignedIds before
calling setSelected, and add assignedIds to the useCallback dependency array so
the callback updates when assignments change; this keeps PickerImageCard (which
blocks toggling assigned images) consistent and prevents already-assigned images
from being sent to onAssign.

Comment on lines +178 to +179
assigned: (res as any).assigned_count ?? 0,
skipped: (res as any).skipped_count ?? 0,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

(res as any) violates the no-any guideline — root cause is the schema design.

The any cast exists because AssignImagesResponse places assigned_count/skipped_count at the top level of the response rather than inside a typed data field (see schema comment above). Once the backend schema is aligned with the envelope pattern, this can be typed properly:

-            return {
-              assigned: (res as any).assigned_count ?? 0,
-              skipped: (res as any).skipped_count ?? 0,
-            };
+            return {
+              assigned: res.data?.assigned_count ?? 0,
+              skipped: res.data?.skipped_count ?? 0,
+            };

As per coding guidelines, any must be avoided; use explicit types.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useManualClusters.ts` around lines 178 - 179, The cast to
any is used because AssignImagesResponse places assigned_count/skipped_count at
the top level; update the typing and access pattern to avoid any: replace uses
of (res as any).assigned_count and (res as any).skipped_count in
useManualClusters (the block setting assigned and skipped) with properly typed
accesses from AssignImagesResponse (e.g., res.data.assigned_count /
res.data.skipped_count) or update AssignImagesResponse to include those fields
in a typed data envelope, then change the variable type from unknown/any to
AssignImagesResponse so no any cast is needed.

Comment on lines +196 to +221
const removeImageFromClusters = useCallback(
async (clusterId: string, imageId: string): Promise<boolean> => {
// Optimistic update
dispatch(removeImage({ clusterId, imageId }));
try {
const res = await removeImageFromCluster(clusterId, imageId);
if (!res.success) {
// Rollback: reload detail
const detailRes = await fetchManualClusterById(clusterId);
if (detailRes.success && detailRes.data) {
dispatch(setClusterDetail(detailRes.data as ManualClusterDetail));
}
setError(res.error ?? 'Failed to remove image');
return false;
}
return true;
} catch {
// Rollback
const detailRes = await fetchManualClusterById(clusterId);
if (detailRes.success && detailRes.data) {
dispatch(setClusterDetail(detailRes.data as ManualClusterDetail));
}
setError('Failed to remove image');
return false;
}
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

removeImageFromClusters never sets or clears loading — inconsistent with all other operations.

Every other exported function in this hook calls setLoading(true) at entry and setLoading(false) in a finally block. removeImageFromClusters skips this entirely, so consumers cannot rely on the loading flag to show a spinner or disable UI while the delete is in flight.

♻️ Proposed fix
   const removeImageFromClusters = useCallback(
     async (clusterId: string, imageId: string): Promise<boolean> => {
+      setLoading(true);
+      setError(null);
       // Optimistic update
       dispatch(removeImage({ clusterId, imageId }));
       try {
         const res = await removeImageFromCluster(clusterId, imageId);
         if (!res.success) {
           // Rollback: reload detail
           const detailRes = await fetchManualClusterById(clusterId);
           if (detailRes.success && detailRes.data) {
             dispatch(setClusterDetail(detailRes.data as ManualClusterDetail));
           }
           setError(res.error ?? 'Failed to remove image');
           return false;
         }
         return true;
       } catch {
         // Rollback
         const detailRes = await fetchManualClusterById(clusterId);
         if (detailRes.success && detailRes.data) {
           dispatch(setClusterDetail(detailRes.data as ManualClusterDetail));
         }
         setError('Failed to remove image');
         return false;
+      } finally {
+        setLoading(false);
       }
     },
     [dispatch],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useManualClusters.ts` around lines 196 - 221, The
removeImageFromClusters function currently omits updating the hook's loading
state; update removeImageFromClusters to call setLoading(true) at the start and
setLoading(false) in a finally block so loading is cleared on both success and
failure, preserving the existing optimistic dispatch(removeImage({ clusterId,
imageId })), rollback logic that uses fetchManualClusterById and
dispatch(setClusterDetail(...)), and the boolean return values; ensure
setLoading(false) runs after both the normal return true and all error/rollback
returns.

Comment on lines +273 to +276
interface ClusterImageCardProps {
image: ManualClusterImage;
onRemove: (imageId: string) => void;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

onRemove return type should be Promise<void>, not void.

handleRemove (line 108) returns Promise<void>, and ClusterImageCard awaits it (line 286). Declaring the prop as void hides the async nature and can mislead callers/linters.

♻️ Suggested fix
 interface ClusterImageCardProps {
   image: ManualClusterImage;
-  onRemove: (imageId: string) => void;
+  onRemove: (imageId: string) => Promise<void>;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/pages/Clusters/ClusterDetailPage.tsx` around lines 273 - 276,
The onRemove prop in ClusterImageCardProps is declared as returning void but is
awaited by ClusterImageCard and implemented by the async function handleRemove;
update the prop signature in interface ClusterImageCardProps so onRemove returns
Promise<void> (i.e., change its type to (imageId: string) => Promise<void>), and
ensure any callers of ClusterImageCard (or implementations like handleRemove)
continue to return a Promise<void>.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant