diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 914c7fcb..9a7bdcdd 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -45,6 +45,7 @@ import { apiClient } from '@/lib/api/client'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { + useClearFailedGenerations, useDeleteGeneration, useExportGeneration, useExportGenerationAudio, @@ -124,6 +125,8 @@ export function HistoryTable() { }); const deleteGeneration = useDeleteGeneration(); + const clearFailed = useClearFailedGenerations(); + const [clearFailedDialogOpen, setClearFailedDialogOpen] = useState(false); const exportGeneration = useExportGeneration(); const exportGenerationAudio = useExportGenerationAudio(); const importGeneration = useImportGeneration(); @@ -157,11 +160,11 @@ export function HistoryTable() { const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size); const prevPendingCountRef = useRef(pendingCount); useEffect(() => { - if (deleteGeneration.isSuccess || importGeneration.isSuccess) { + if (deleteGeneration.isSuccess || importGeneration.isSuccess || clearFailed.isSuccess) { setPage(0); setAllHistory([]); } - }, [deleteGeneration.isSuccess, importGeneration.isSuccess]); + }, [deleteGeneration.isSuccess, importGeneration.isSuccess, clearFailed.isSuccess]); useEffect(() => { // A generation finished (pending count decreased) — scroll back to show it @@ -415,6 +418,27 @@ export function HistoryTable() { const history = allHistory; const hasMore = allHistory.length < total; + const failedCount = history.filter((g) => g.status === 'failed').length; + + const handleClearFailedConfirm = () => { + clearFailed.mutate(undefined, { + onSuccess: (data) => { + setClearFailedDialogOpen(false); + toast({ + title: 'Cleared failed generations', + description: `${data.deleted} failed ${data.deleted === 1 ? 'generation' : 'generations'} removed.`, + }); + }, + onError: (error) => { + setClearFailedDialogOpen(false); + toast({ + title: 'Failed to clear', + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive', + }); + }, + }); + }; return (
@@ -424,6 +448,23 @@ export function HistoryTable() {
) : ( <> + {failedCount > 0 && ( +
+ + {failedCount} failed {failedCount === 1 ? 'generation' : 'generations'} + + +
+ )} {isScrolled && (
)} @@ -759,6 +800,31 @@ export function HistoryTable() { + + + + Clear failed generations + + This will permanently delete {failedCount} failed{' '} + {failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be + undone. + + + + + + + + + diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 98a375e3..1374f27b 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -270,6 +270,12 @@ class ApiClient { }); } + async clearFailedGenerations(): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>(`/history/failed`, { + method: 'DELETE', + }); + } + async exportGeneration(generationId: string): Promise { const url = `${this.getBaseUrl()}/history/${generationId}/export`; const response = await fetch(url); diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts index ec0c20cc..e6f4aa7a 100644 --- a/app/src/lib/hooks/useHistory.ts +++ b/app/src/lib/hooks/useHistory.ts @@ -29,6 +29,17 @@ export function useDeleteGeneration() { }); } +export function useClearFailedGenerations() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => apiClient.clearFailedGenerations(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }); + }, + }); +} + export function useExportGeneration() { const platform = usePlatform(); diff --git a/backend/routes/history.py b/backend/routes/history.py index df97b6f4..1cd7694c 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -62,6 +62,13 @@ async def import_generation( raise HTTPException(status_code=500, detail=str(e)) +@router.delete("/history/failed") +async def clear_failed_generations(db: Session = Depends(get_db)): + """Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410).""" + count = await history.delete_failed_generations(db) + return {"deleted": count} + + @router.get("/history/{generation_id}", response_model=models.HistoryResponse) async def get_generation( generation_id: str, diff --git a/backend/services/history.py b/backend/services/history.py index 473c4b37..18c9a659 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -264,6 +264,43 @@ async def delete_generation( return True +async def delete_failed_generations(db: Session) -> int: + """ + Delete every generation whose status is 'failed'. + + Used by the "Clear failed" action in the UI so users can tidy up + history after the model wasn't loaded, the app was closed mid-run, + or a generation otherwise errored out (see issue #410). + + Returns: + Number of generations deleted. + """ + from . import versions as versions_mod + + failed = db.query(DBGeneration).filter(DBGeneration.status == "failed").all() + count = 0 + for generation in failed: + # Clean up version files/rows first. + versions_mod.delete_versions_for_generation(generation.id, db) + + # Remove the main audio file if it somehow made it to disk. + if generation.audio_path: + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is not None and audio_path.exists(): + try: + audio_path.unlink() + except OSError: + # Best-effort cleanup — don't abort the whole sweep + # if a single file can't be removed. + pass + + db.delete(generation) + count += 1 + + db.commit() + return count + + async def delete_generations_by_profile( profile_id: str, db: Session,