Feat: Bulk AI Tagging & Progress Tracking for Folder Management#747
Feat: Bulk AI Tagging & Progress Tracking for Folder Management#747hrshdas wants to merge 1 commit intoAOSSIE-Org:mainfrom
Conversation
|
|
WalkthroughChanges introduce bulk AI tagging capabilities to folder management by adding batch mutation operations in the hook layer and refactoring the UI to a sectioned, collapsible interface with selection mechanics. OpenAPI schema structure is updated to standardize parameter definitions. Changes
Sequence DiagramsequenceDiagram
actor User
participant FolderMgmtCard as FolderManagementCard
participant Hook as useFolderOperations
participant Mutation as GraphQL Mutation
participant Backend as Backend API
User->>FolderMgmtCard: Select folders & click "Tag Selected"
FolderMgmtCard->>Hook: bulkEnableAITagging(folderIds)
Hook->>Hook: Batch folderIds into chunks (size: 20)
loop For each batch
Hook->>Mutation: Execute bulkEnableMutation(batchIds)
Mutation->>Backend: POST /folder-tagging/bulk-enable
Backend->>Backend: Process batch
Backend-->>Mutation: Success response
end
Mutation-->>Hook: All mutations complete
Hook-->>FolderMgmtCard: Promise resolved
FolderMgmtCard->>FolderMgmtCard: Update folder state & UI sections
FolderMgmtCard-->>User: Display updated folder status
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
|
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/backend/backend_python/openapi.json (1)
1114-1130: Input_type parameter uses inconsistent schema pattern with redundantallOfwrapper.The
input_typeparameter (lines 1120–1127) wraps an InputType reference in anallOfcomposition, unlike all other parameters in the spec. Compare againstshow_hidden(uses directtype: boolean),album_id(uses directtype: string), andcluster_id(uses directtype: string). TheallOfis the only such usage in the entire OpenAPI specification.This pattern is both inconsistent and redundant—a single-item
allOfadds unnecessary complexity without benefit. Additionally, the description appears twice: inside the schema (line 1126) and at the parameter level (line 1130).Consider unwrapping the schema to match the pattern used throughout the spec:
"schema": { "$ref": "#/components/schemas/InputType", "description": "Choose input type: 'path' or 'base64'", "default": "path", "title": "Input Type" }Verify this matches your intended schema structure and confirm client code generation is not negatively impacted by the current pattern.
🧹 Nitpick comments (7)
frontend/src/hooks/useFolderOperations.tsx (2)
145-155: Avoid re-fetch thrash fromautoInvalidateTagson every bulk batch
Right now each batch triggersinvalidateQueries(['folders']), which can cause repeated refetches (and UI churn) for large selections. Consider invalidating once after the loop (or using a largerstaleTime/debounced invalidate) and/or disabling auto-invalidate for the bulk mutations.Example approach (invalidate once after batching):
+import { useQueryClient } from '@tanstack/react-query'; ... export const useFolderOperations = () => { const dispatch = useDispatch(); + const queryClient = useQueryClient(); ... const bulkEnableMutation = usePictoMutation({ mutationFn: async (folder_ids: string[]) => enableAITagging({ folder_ids }), - autoInvalidateTags: ['folders'], + // invalidate once after all batches complete }); ... const bulkDisableMutation = usePictoMutation({ mutationFn: async (folder_ids: string[]) => disableAITagging({ folder_ids }), - autoInvalidateTags: ['folders'], + // invalidate once after all batches complete });(Then call
queryClient.invalidateQueries({ queryKey: ['folders'] })once at the end of each bulk function.)
210-218:bulkPendingmay under-report during sequential batches
Because batching is sequential,bulkPendingdepends onisPendingbeing set/unset per batch; some UIs expect “pending” for the whole bulk operation. Consider tracking an explicitisBulkRunningstate inside the hook (set true before loop, false infinally) and expose that instead (or OR it with mutation pending).frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx (5)
35-62: Persisted collapse state: good defensive init; consider namespacing the storage key
The lazy initializer + try/catch is solid. Minor:folder-sections-collapsedis fairly generic; consider prefixing with app/feature to avoid collisions.
63-95: Progress math likely misleads (only counts “completed”, ignores in-progress %)
progressPctis based oncompleted/total, so UI can show 0% even when many folders are mid-tag. If you want “real progress”, compute an average of per-folder percentages (clamped 0–100), or weight by item counts if available.
239-257: “Select all pending” checkbox should be controlled (and ideally indeterminate)
Right now it doesn’t reflect current selection state. Also the label isn’t associated with the input.Sketch:
- Compute
pendingIds,allPendingSelected,somePendingSelected.- Use
checked={allPendingSelected}and setref.current.indeterminate = somePendingSelected && !allPendingSelected.- Wrap with
<label>or addaria-label.
293-313: Section header button: addaria-expanded+aria-controls
This will improve accessibility for the collapsible regions.
315-387: Add accessible labels for row checkbox + delete button
The row checkbox has no label, and the icon-only delete button should have anaria-label.- <input + <input type="checkbox" className="mt-1 h-4 w-4 cursor-pointer accent-blue-600" checked={checked} onChange={onCheckedChange} disabled={disableAll} + aria-label={`Select folder ${folder.folder_path}`} /> ... <Button onClick={deleteFolder} variant="outline" size="sm" className="h-8 w-8 cursor-pointer text-gray-500 hover:border-red-300 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400" disabled={disableAll} + aria-label={`Delete folder ${folder.folder_path}`} >
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
docs/backend/backend_python/openapi.json(1 hunks)frontend/src/hooks/useFolderOperations.tsx(3 hunks)frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
frontend/src/hooks/useFolderOperations.tsx (2)
frontend/src/hooks/useQueryExtension.ts (1)
usePictoMutation(26-78)frontend/src/api/api-functions/folders.ts (2)
enableAITagging(44-52)disableAITagging(54-62)
frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx (3)
frontend/src/hooks/useFolderOperations.tsx (1)
useFolderOperations(20-219)frontend/src/components/ui/progress.tsx (1)
Progress(37-37)frontend/src/components/ui/switch.tsx (1)
Switch(29-29)
🔇 Additional comments (1)
docs/backend/backend_python/openapi.json (1)
2204-2213: Verify metadata schema doesn't need explicit property constraints.The
ImageInCluster.metadatafield (lines 2204–2213) now omits explicitadditionalPropertiesdefinition. While JSON Schema defaults to allowing additional properties (no-op functionally), this makes the schema less explicit and may indicate reduced validation strictness.Confirm that:
- The backend allows arbitrary properties on metadata objects (which the current schema permits).
- The removal was intentional and not an unintended loss of validation constraints.
- Any downstream code that relies on metadata structure is compatible with this permissiveness.
| /** | ||
| * Enable AI tagging for many folders with batching to handle rate limits. | ||
| */ | ||
| const bulkEnableAITagging = async ( | ||
| folderIds: string[], | ||
| batchSize: number = 20, | ||
| ) => { | ||
| const ids = Array.from(new Set(folderIds)); | ||
| for (let i = 0; i < ids.length; i += batchSize) { | ||
| const batch = ids.slice(i, i + batchSize); | ||
| await bulkEnableMutation.mutateAsync(batch); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Disable AI tagging for many folders with batching. | ||
| */ | ||
| const bulkDisableAITagging = async ( | ||
| folderIds: string[], | ||
| batchSize: number = 20, | ||
| ) => { | ||
| const ids = Array.from(new Set(folderIds)); | ||
| for (let i = 0; i < ids.length; i += batchSize) { | ||
| const batch = ids.slice(i, i + batchSize); | ||
| await bulkDisableMutation.mutateAsync(batch); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Guard batchSize and decide on partial-failure behavior (stop vs continue)
- If
batchSize <= 0, theforloop never progresses (i += batchSize) → infinite loop. - Current behavior aborts on the first failed batch; if you want “partial failure recovery”, catch per-batch, collect failures, and continue.
const bulkEnableAITagging = async (
folderIds: string[],
batchSize: number = 20,
) => {
+ if (!Number.isFinite(batchSize) || batchSize <= 0) {
+ throw new Error('batchSize must be a positive number');
+ }
const ids = Array.from(new Set(folderIds));
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
await bulkEnableMutation.mutateAsync(batch);
}
};If partial recovery is desired:
+ const failed: string[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
- await bulkEnableMutation.mutateAsync(batch);
+ try {
+ await bulkEnableMutation.mutateAsync(batch);
+ } catch {
+ failed.push(...batch);
+ }
}
+ if (failed.length) throw new Error(`Failed to enable AI tagging for ${failed.length} folder(s).`);📝 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.
| /** | |
| * Enable AI tagging for many folders with batching to handle rate limits. | |
| */ | |
| const bulkEnableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkEnableMutation.mutateAsync(batch); | |
| } | |
| }; | |
| /** | |
| * Disable AI tagging for many folders with batching. | |
| */ | |
| const bulkDisableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkDisableMutation.mutateAsync(batch); | |
| } | |
| }; | |
| /** | |
| * Enable AI tagging for many folders with batching to handle rate limits. | |
| */ | |
| const bulkEnableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| if (!Number.isFinite(batchSize) || batchSize <= 0) { | |
| throw new Error('batchSize must be a positive number'); | |
| } | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkEnableMutation.mutateAsync(batch); | |
| } | |
| }; | |
| /** | |
| * Disable AI tagging for many folders with batching. | |
| */ | |
| const bulkDisableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| if (!Number.isFinite(batchSize) || batchSize <= 0) { | |
| throw new Error('batchSize must be a positive number'); | |
| } | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkDisableMutation.mutateAsync(batch); | |
| } | |
| }; |
| /** | |
| * Enable AI tagging for many folders with batching to handle rate limits. | |
| */ | |
| const bulkEnableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkEnableMutation.mutateAsync(batch); | |
| } | |
| }; | |
| /** | |
| * Disable AI tagging for many folders with batching. | |
| */ | |
| const bulkDisableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| const ids = Array.from(new Set(folderIds)); | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| await bulkDisableMutation.mutateAsync(batch); | |
| } | |
| }; | |
| /** | |
| * Enable AI tagging for many folders with batching to handle rate limits. | |
| */ | |
| const bulkEnableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| if (!Number.isFinite(batchSize) || batchSize <= 0) { | |
| throw new Error('batchSize must be a positive number'); | |
| } | |
| const ids = Array.from(new Set(folderIds)); | |
| const failed: string[] = []; | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| try { | |
| await bulkEnableMutation.mutateAsync(batch); | |
| } catch { | |
| failed.push(...batch); | |
| } | |
| } | |
| if (failed.length) throw new Error(`Failed to enable AI tagging for ${failed.length} folder(s).`); | |
| }; | |
| /** | |
| * Disable AI tagging for many folders with batching. | |
| */ | |
| const bulkDisableAITagging = async ( | |
| folderIds: string[], | |
| batchSize: number = 20, | |
| ) => { | |
| if (!Number.isFinite(batchSize) || batchSize <= 0) { | |
| throw new Error('batchSize must be a positive number'); | |
| } | |
| const ids = Array.from(new Set(folderIds)); | |
| const failed: string[] = []; | |
| for (let i = 0; i < ids.length; i += batchSize) { | |
| const batch = ids.slice(i, i + batchSize); | |
| try { | |
| await bulkDisableMutation.mutateAsync(batch); | |
| } catch { | |
| failed.push(...batch); | |
| } | |
| } | |
| if (failed.length) throw new Error(`Failed to disable AI tagging for ${failed.length} folder(s).`); | |
| }; |
🤖 Prompt for AI Agents
In frontend/src/hooks/useFolderOperations.tsx around lines 174 to 200, the
bulkEnableAITagging and bulkDisableAITagging functions don't guard against
invalid batchSize (<= 0) which can cause an infinite loop and they
unconditionally await batch mutations which abort the whole operation on the
first failed batch; to fix this, validate batchSize at the top (throw or coerce
to a safe default > 0) and decide desired failure policy: for fail-fast keep
current awaits and rethrow on any mutation error, or for partial-recovery wrap
each await in try/catch, collect failed batch ids (or errors) into an array,
continue processing remaining batches, and return a result object summarizing
successes and failures so callers can handle partial failures.
| // Bulk actions | ||
| const handleTagAll = async () => { | ||
| // Preserve user intent: only enable for folders that are not already enabled/completed | ||
| const toEnable = pending.map((f) => f.folder_id); | ||
| if (toEnable.length === 0) return; | ||
| await bulkEnableAITagging(toEnable); | ||
| }; | ||
|
|
||
| const handleTagSelected = async () => { | ||
| if (selectedIds.length === 0) return; | ||
| await bulkEnableAITagging(selectedIds); | ||
| clearSelection(); | ||
| }; | ||
|
|
||
| const handleDisableSelected = async () => { | ||
| if (selectedIds.length === 0) return; | ||
| await bulkDisableAITagging(selectedIds); | ||
| clearSelection(); | ||
| }; | ||
|
|
||
| const anyMutationPending = | ||
| enableAITaggingPending || disableAITaggingPending || deleteFolderPending || bulkPending; | ||
|
|
There was a problem hiding this comment.
Handle bulk action errors to avoid unhandled promise rejections
If bulkEnableAITagging() rejects, this will bubble out of the click handler and can become an unhandled rejection (and selection won’t clear). Wrap in try/catch (and show feedback if needed).
const handleTagSelected = async () => {
if (selectedIds.length === 0) return;
- await bulkEnableAITagging(selectedIds);
- clearSelection();
+ try {
+ await bulkEnableAITagging(selectedIds);
+ clearSelection();
+ } catch {
+ // keep selection so user can retry / adjust
+ }
};Same for handleTagAll and handleDisableSelected.
📝 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.
| // Bulk actions | |
| const handleTagAll = async () => { | |
| // Preserve user intent: only enable for folders that are not already enabled/completed | |
| const toEnable = pending.map((f) => f.folder_id); | |
| if (toEnable.length === 0) return; | |
| await bulkEnableAITagging(toEnable); | |
| }; | |
| const handleTagSelected = async () => { | |
| if (selectedIds.length === 0) return; | |
| await bulkEnableAITagging(selectedIds); | |
| clearSelection(); | |
| }; | |
| const handleDisableSelected = async () => { | |
| if (selectedIds.length === 0) return; | |
| await bulkDisableAITagging(selectedIds); | |
| clearSelection(); | |
| }; | |
| const anyMutationPending = | |
| enableAITaggingPending || disableAITaggingPending || deleteFolderPending || bulkPending; | |
| // Bulk actions | |
| const handleTagAll = async () => { | |
| // Preserve user intent: only enable for folders that are not already enabled/completed | |
| const toEnable = pending.map((f) => f.folder_id); | |
| if (toEnable.length === 0) return; | |
| await bulkEnableAITagging(toEnable); | |
| }; | |
| const handleTagSelected = async () => { | |
| if (selectedIds.length === 0) return; | |
| try { | |
| await bulkEnableAITagging(selectedIds); | |
| clearSelection(); | |
| } catch { | |
| // keep selection so user can retry / adjust | |
| } | |
| }; | |
| const handleDisableSelected = async () => { | |
| if (selectedIds.length === 0) return; | |
| await bulkDisableAITagging(selectedIds); | |
| clearSelection(); | |
| }; | |
| const anyMutationPending = | |
| enableAITaggingPending || disableAITaggingPending || deleteFolderPending || bulkPending; |
🤖 Prompt for AI Agents
In frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx around
lines 114 to 136, the bulk action handlers call async functions without catching
rejections which can produce unhandled promise rejections and prevent selection
clearing; wrap the awaits in try/catch and ensure selection is cleared in a
finally block (or after a successful call) so selection is always reset even if
the API fails, and optionally surface an error/notification to the user inside
the catch for feedback; apply this pattern to handleTagAll, handleTagSelected,
and handleDisableSelected.
| {folders.length > 0 ? ( | ||
| <div className="space-y-3"> | ||
| {folders.map((folder: FolderDetails, index: number) => ( | ||
| <div | ||
| key={index} | ||
| className="group border-border bg-background/50 relative rounded-lg border p-4 transition-all hover:border-gray-300 hover:shadow-sm dark:hover:border-gray-600" | ||
| > | ||
| <div className="flex items-center justify-between"> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="flex items-center gap-3"> | ||
| <Folder className="h-4 w-4 flex-shrink-0 text-gray-500 dark:text-gray-400" /> | ||
| <span className="text-foreground truncate"> | ||
| {folder.folder_path} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="ml-4 flex items-center gap-4"> | ||
| <div className="flex items-center gap-3"> | ||
| <span className="text-muted-foreground text-sm"> | ||
| AI Tagging | ||
| </span> | ||
| <Switch | ||
| className="cursor-pointer" | ||
| checked={folder.AI_Tagging} | ||
| onCheckedChange={() => toggleAITagging(folder)} | ||
| disabled={ | ||
| enableAITaggingPending || disableAITaggingPending | ||
| } | ||
| /> | ||
| </div> | ||
|
|
||
| <Button | ||
| onClick={() => deleteFolder(folder.folder_id)} | ||
| variant="outline" | ||
| size="sm" | ||
| className="h-8 w-8 cursor-pointer text-gray-500 hover:border-red-300 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400" | ||
| disabled={deleteFolderPending} | ||
| > | ||
| <Trash2 className="h-4 w-4" /> | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| <div className="space-y-4"> | ||
| {/* Completed Section */} | ||
| <Section | ||
| title={`Completed (${completed.length})`} | ||
| collapsed={collapsed.completed} | ||
| onToggle={() => setCollapsed((c) => ({ ...c, completed: !c.completed }))} | ||
| > | ||
| {completed.map((folder: FolderDetails) => ( | ||
| <FolderRow | ||
| key={folder.folder_id} | ||
| folder={folder} | ||
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 100)} | ||
| checked={isSelected(folder.folder_id)} | ||
| onCheckedChange={() => toggleSelect(folder.folder_id)} | ||
| toggleAITagging={() => toggleAITagging(folder)} | ||
| deleteFolder={() => deleteFolder(folder.folder_id)} | ||
| disableAll={anyMutationPending} | ||
| /> | ||
| ))} | ||
| </Section> | ||
|
|
||
| {/* In Progress Section */} | ||
| <Section | ||
| title={`In Progress (${inProgress.length})`} | ||
| collapsed={collapsed.inProgress} | ||
| onToggle={() => setCollapsed((c) => ({ ...c, inProgress: !c.inProgress }))} | ||
| > | ||
| {inProgress.map((folder: FolderDetails) => ( | ||
| <FolderRow | ||
| key={folder.folder_id} | ||
| folder={folder} | ||
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | ||
| checked={isSelected(folder.folder_id)} | ||
| onCheckedChange={() => toggleSelect(folder.folder_id)} | ||
| toggleAITagging={() => toggleAITagging(folder)} | ||
| deleteFolder={() => deleteFolder(folder.folder_id)} | ||
| disableAll={anyMutationPending} | ||
| /> | ||
| ))} | ||
| </Section> | ||
|
|
||
| {folder.AI_Tagging && ( | ||
| <div className="mt-3"> | ||
| <div className="text-muted-foreground mb-1 flex items-center justify-between text-xs"> | ||
| <span>AI Tagging Progress</span> | ||
| <span | ||
| className={ | ||
| (taggingStatus[folder.folder_id]?.tagging_percentage ?? | ||
| 0) >= 100 | ||
| ? 'flex items-center gap-1 text-green-500' | ||
| : 'text-muted-foreground' | ||
| } | ||
| > | ||
| {(taggingStatus[folder.folder_id]?.tagging_percentage ?? | ||
| 0) >= 100 && <Check className="h-3 w-3" />} | ||
| {Math.round( | ||
| taggingStatus[folder.folder_id]?.tagging_percentage ?? | ||
| 0, | ||
| )} | ||
| % | ||
| </span> | ||
| </div> | ||
| <Progress | ||
| value={ | ||
| taggingStatus[folder.folder_id]?.tagging_percentage ?? 0 | ||
| } | ||
| indicatorClassName={ | ||
| (taggingStatus[folder.folder_id]?.tagging_percentage ?? | ||
| 0) >= 100 | ||
| ? 'bg-green-500' | ||
| : 'bg-blue-500' | ||
| } | ||
| /> | ||
| </div> | ||
| )} | ||
| {/* Pending Section */} | ||
| <Section | ||
| title={`Pending (${pending.length})`} | ||
| collapsed={collapsed.pending} | ||
| onToggle={() => setCollapsed((c) => ({ ...c, pending: !c.pending }))} | ||
| > | ||
| <div className="mb-2 flex items-center gap-2"> | ||
| <input | ||
| type="checkbox" | ||
| className="h-4 w-4 cursor-pointer accent-blue-600" | ||
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => | ||
| selectAllVisible( | ||
| pending.map((f) => f.folder_id), | ||
| e.currentTarget.checked, | ||
| ) | ||
| } | ||
| /> | ||
| <span className="text-sm text-muted-foreground">Select all pending</span> | ||
| </div> | ||
| ))} | ||
| {pending.map((folder: FolderDetails) => ( | ||
| <FolderRow | ||
| key={folder.folder_id} | ||
| folder={folder} | ||
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | ||
| checked={isSelected(folder.folder_id)} | ||
| onCheckedChange={() => toggleSelect(folder.folder_id)} | ||
| toggleAITagging={() => toggleAITagging(folder)} | ||
| deleteFolder={() => deleteFolder(folder.folder_id)} | ||
| disableAll={anyMutationPending} | ||
| /> | ||
| ))} | ||
| </Section> |
There was a problem hiding this comment.
“Completed” rows can display <100% despite being in Completed section
Completed classification includes folder.taggingCompleted, but you pass pct={Math.round(taggingStatus[...] ?? 100)}; if status exists and is <100, UI shows e.g. 95% in “Completed”. Clamp to 100 for completed rows.
{completed.map((folder: FolderDetails) => (
<FolderRow
key={folder.folder_id}
folder={folder}
- pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 100)}
+ pct={100}
checked={isSelected(folder.folder_id)}
onCheckedChange={() => toggleSelect(folder.folder_id)}
toggleAITagging={() => toggleAITagging(folder)}
deleteFolder={() => deleteFolder(folder.folder_id)}
disableAll={anyMutationPending}
/>
))}📝 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.
| {folders.length > 0 ? ( | |
| <div className="space-y-3"> | |
| {folders.map((folder: FolderDetails, index: number) => ( | |
| <div | |
| key={index} | |
| className="group border-border bg-background/50 relative rounded-lg border p-4 transition-all hover:border-gray-300 hover:shadow-sm dark:hover:border-gray-600" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex items-center gap-3"> | |
| <Folder className="h-4 w-4 flex-shrink-0 text-gray-500 dark:text-gray-400" /> | |
| <span className="text-foreground truncate"> | |
| {folder.folder_path} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="ml-4 flex items-center gap-4"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-muted-foreground text-sm"> | |
| AI Tagging | |
| </span> | |
| <Switch | |
| className="cursor-pointer" | |
| checked={folder.AI_Tagging} | |
| onCheckedChange={() => toggleAITagging(folder)} | |
| disabled={ | |
| enableAITaggingPending || disableAITaggingPending | |
| } | |
| /> | |
| </div> | |
| <Button | |
| onClick={() => deleteFolder(folder.folder_id)} | |
| variant="outline" | |
| size="sm" | |
| className="h-8 w-8 cursor-pointer text-gray-500 hover:border-red-300 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400" | |
| disabled={deleteFolderPending} | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="space-y-4"> | |
| {/* Completed Section */} | |
| <Section | |
| title={`Completed (${completed.length})`} | |
| collapsed={collapsed.completed} | |
| onToggle={() => setCollapsed((c) => ({ ...c, completed: !c.completed }))} | |
| > | |
| {completed.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 100)} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> | |
| {/* In Progress Section */} | |
| <Section | |
| title={`In Progress (${inProgress.length})`} | |
| collapsed={collapsed.inProgress} | |
| onToggle={() => setCollapsed((c) => ({ ...c, inProgress: !c.inProgress }))} | |
| > | |
| {inProgress.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> | |
| {folder.AI_Tagging && ( | |
| <div className="mt-3"> | |
| <div className="text-muted-foreground mb-1 flex items-center justify-between text-xs"> | |
| <span>AI Tagging Progress</span> | |
| <span | |
| className={ | |
| (taggingStatus[folder.folder_id]?.tagging_percentage ?? | |
| 0) >= 100 | |
| ? 'flex items-center gap-1 text-green-500' | |
| : 'text-muted-foreground' | |
| } | |
| > | |
| {(taggingStatus[folder.folder_id]?.tagging_percentage ?? | |
| 0) >= 100 && <Check className="h-3 w-3" />} | |
| {Math.round( | |
| taggingStatus[folder.folder_id]?.tagging_percentage ?? | |
| 0, | |
| )} | |
| % | |
| </span> | |
| </div> | |
| <Progress | |
| value={ | |
| taggingStatus[folder.folder_id]?.tagging_percentage ?? 0 | |
| } | |
| indicatorClassName={ | |
| (taggingStatus[folder.folder_id]?.tagging_percentage ?? | |
| 0) >= 100 | |
| ? 'bg-green-500' | |
| : 'bg-blue-500' | |
| } | |
| /> | |
| </div> | |
| )} | |
| {/* Pending Section */} | |
| <Section | |
| title={`Pending (${pending.length})`} | |
| collapsed={collapsed.pending} | |
| onToggle={() => setCollapsed((c) => ({ ...c, pending: !c.pending }))} | |
| > | |
| <div className="mb-2 flex items-center gap-2"> | |
| <input | |
| type="checkbox" | |
| className="h-4 w-4 cursor-pointer accent-blue-600" | |
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => | |
| selectAllVisible( | |
| pending.map((f) => f.folder_id), | |
| e.currentTarget.checked, | |
| ) | |
| } | |
| /> | |
| <span className="text-sm text-muted-foreground">Select all pending</span> | |
| </div> | |
| ))} | |
| {pending.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> | |
| {folders.length > 0 ? ( | |
| <div className="space-y-4"> | |
| {/* Completed Section */} | |
| <Section | |
| title={`Completed (${completed.length})`} | |
| collapsed={collapsed.completed} | |
| onToggle={() => setCollapsed((c) => ({ ...c, completed: !c.completed }))} | |
| > | |
| {completed.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={100} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> | |
| {/* In Progress Section */} | |
| <Section | |
| title={`In Progress (${inProgress.length})`} | |
| collapsed={collapsed.inProgress} | |
| onToggle={() => setCollapsed((c) => ({ ...c, inProgress: !c.inProgress }))} | |
| > | |
| {inProgress.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> | |
| {/* Pending Section */} | |
| <Section | |
| title={`Pending (${pending.length})`} | |
| collapsed={collapsed.pending} | |
| onToggle={() => setCollapsed((c) => ({ ...c, pending: !c.pending }))} | |
| > | |
| <div className="mb-2 flex items-center gap-2"> | |
| <input | |
| type="checkbox" | |
| className="h-4 w-4 cursor-pointer accent-blue-600" | |
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => | |
| selectAllVisible( | |
| pending.map((f) => f.folder_id), | |
| e.currentTarget.checked, | |
| ) | |
| } | |
| /> | |
| <span className="text-sm text-muted-foreground">Select all pending</span> | |
| </div> | |
| {pending.map((folder: FolderDetails) => ( | |
| <FolderRow | |
| key={folder.folder_id} | |
| folder={folder} | |
| pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 0)} | |
| checked={isSelected(folder.folder_id)} | |
| onCheckedChange={() => toggleSelect(folder.folder_id)} | |
| toggleAITagging={() => toggleAITagging(folder)} | |
| deleteFolder={() => deleteFolder(folder.folder_id)} | |
| disableAll={anyMutationPending} | |
| /> | |
| ))} | |
| </Section> |
🤖 Prompt for AI Agents
In frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx around
lines 197-270, the Completed section passes
pct={Math.round(taggingStatus[folder.folder_id]?.tagging_percentage ?? 100)}
which can render <100% for folders marked completed; change the pct prop for
Completed rows to ensure it always shows 100 (either set pct={100}
unconditionally for completed folders or clamp the computed value with
Math.max(Math.round(...), 100) so any existing tagging_status <100 is coerced to
100 while keeping the 100 fallback).
Summary
This PR introduces Bulk AI Tagging and Progress Tracking to enhance the folder management workflow. Users can now select multiple files/folders and apply AI-generated tags in one go, with real-time progress updates to ensure a smooth and transparent tagging experience.
What’s New
Added support for sending multiple items to the AI tagging engine.
Implemented batching logic to prevent blocking or timeouts.
Improved response handling for multi-file metadata generation.
Added progress indicators for:
Total items processed
Current tagging status
Completed vs pending items
Updated UI to display live tagging updates.
Integrated tagging results back into folder structure.
Improved state updates and error handling for bulk operations.
Added checks for already-tagged files to prevent reprocessing.
Technical Changes
New API method: POST /tag/bulk
Introduced utility processBulkTagging()
Updated components/services:
FolderView / FileList
AI Tagging Service
Progress Overlay / Toast Notifications
Improved error recovery for partial tagging failures.
Testing
Added tests for:
Multi-file tagging flows
Progress state updates
Failure handling (network, AI errors)
Manually verified on folders containing 5–100+ files.
Why This Matters
This feature significantly improves user experience when working with large folders. Instead of tagging files one by one, users can now automate the entire process with AI—saving time and increasing accuracy.
Impact
Faster content organization
Better UX for large datasets
More reliable AI tagging performance
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.