feat(worksheet-parsing): add cancel button for parsing in progress
Users can now immediately cancel a parsing operation instead of waiting for it to complete or fail. This prevents attachments from getting stuck in "processing" state with no way to recover. Changes: - Added DELETE endpoint to parse API route to cancel/reset parsing - Added useCancelParsing mutation hook with optimistic updates - Added cancel button (✕) to the "Analyzing..." badge in OfflineWorkSection - Cancel button resets the attachment to unparsed state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -384,3 +384,79 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Cancel/reset parsing status
|
||||
*
|
||||
* Allows user to cancel a stuck or in-progress parsing operation.
|
||||
* Resets the parsing status to null so they can retry.
|
||||
*/
|
||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const { playerId, attachmentId } = await params;
|
||||
|
||||
if (!playerId || !attachmentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Player ID and Attachment ID required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Authorization check
|
||||
const userId = await getDbUserId();
|
||||
const canModify = await canPerformAction(userId, playerId, "start-session");
|
||||
if (!canModify) {
|
||||
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get attachment to verify it exists and belongs to player
|
||||
const attachment = await db
|
||||
.select()
|
||||
.from(practiceAttachments)
|
||||
.where(eq(practiceAttachments.id, attachmentId))
|
||||
.get();
|
||||
|
||||
if (!attachment || attachment.playerId !== playerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Attachment not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Reset parsing status
|
||||
await db
|
||||
.update(practiceAttachments)
|
||||
.set({
|
||||
parsingStatus: null,
|
||||
parsedAt: null,
|
||||
parsingError: null,
|
||||
rawParsingResult: null,
|
||||
approvedResult: null,
|
||||
confidenceScore: null,
|
||||
needsReview: null,
|
||||
// Clear LLM metadata
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
llmPromptUsed: null,
|
||||
llmRawResponse: null,
|
||||
llmJsonSchema: null,
|
||||
llmImageSource: null,
|
||||
llmAttempts: null,
|
||||
llmPromptTokens: null,
|
||||
llmCompletionTokens: null,
|
||||
llmTotalTokens: null,
|
||||
})
|
||||
.where(eq(practiceAttachments.id, attachmentId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Parsing cancelled",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error cancelling parse:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to cancel parsing" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useApproveAndCreateSession,
|
||||
useSubmitCorrections,
|
||||
useReparseSelected,
|
||||
useCancelParsing,
|
||||
} from "@/hooks/useWorksheetParsing";
|
||||
import { PARSING_MODEL_CONFIGS } from "@/lib/worksheet-parsing";
|
||||
import {
|
||||
@@ -223,8 +224,9 @@ export function SummaryClient({
|
||||
sessionCreated: att.sessionCreated,
|
||||
}));
|
||||
|
||||
// Worksheet parsing mutation
|
||||
// Worksheet parsing mutations
|
||||
const startParsing = useStartParsing(studentId, session?.id ?? "");
|
||||
const cancelParsing = useCancelParsing(studentId, session?.id ?? "");
|
||||
|
||||
// Approve and create session mutation
|
||||
const approveAndCreateSession = useApproveAndCreateSession(
|
||||
@@ -766,6 +768,9 @@ export function SummaryClient({
|
||||
onParse={(attachmentId) =>
|
||||
startParsing.mutate({ attachmentId })
|
||||
}
|
||||
onCancelParsing={(attachmentId) =>
|
||||
cancelParsing.mutate(attachmentId)
|
||||
}
|
||||
/>
|
||||
{/* All Problems - complete session listing */}
|
||||
{hasProblems && (
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface OfflineWorkSectionProps {
|
||||
onDeletePhoto: (id: string) => void;
|
||||
/** Start parsing a worksheet photo */
|
||||
onParse?: (id: string) => void;
|
||||
/** Cancel parsing in progress */
|
||||
onCancelParsing?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +90,7 @@ export function OfflineWorkSection({
|
||||
onOpenViewer,
|
||||
onDeletePhoto,
|
||||
onParse,
|
||||
onCancelParsing,
|
||||
}: OfflineWorkSectionProps) {
|
||||
const photoCount = attachments.length;
|
||||
// Show add tile unless we have 8+ photos (max reasonable gallery size)
|
||||
@@ -474,6 +477,38 @@ export function OfflineWorkSection({
|
||||
: att.parsingStatus === "approved"
|
||||
? `${att.rawParsingResult?.problems?.length ?? "?"} problems`
|
||||
: att.parsingStatus}
|
||||
{/* Cancel button for processing state */}
|
||||
{att.parsingStatus === "processing" && onCancelParsing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancelParsing(att.id);
|
||||
}}
|
||||
className={css({
|
||||
marginLeft: "0.25rem",
|
||||
padding: "0.125rem",
|
||||
borderRadius: "full",
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
color: "white",
|
||||
fontSize: "0.625rem",
|
||||
lineHeight: "1",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
_hover: {
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
},
|
||||
})}
|
||||
title="Cancel parsing"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -462,6 +462,72 @@ export function useReparseSelected(playerId: string, sessionId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to cancel/reset parsing status
|
||||
*/
|
||||
export function useCancelParsing(playerId: string, sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = attachmentKeys.session(playerId, sessionId);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
const res = await api(
|
||||
`curriculum/${playerId}/attachments/${attachmentId}/parse`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "Failed to cancel parsing");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
onMutate: async (attachmentId) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
// Snapshot current state
|
||||
const previous = queryClient.getQueryData<AttachmentsCache>(queryKey);
|
||||
|
||||
// Optimistic update: reset to unparsed
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AttachmentsCache>(queryKey, {
|
||||
...previous,
|
||||
attachments: previous.attachments.map((a) =>
|
||||
a.id === attachmentId
|
||||
? {
|
||||
...a,
|
||||
parsingStatus: null,
|
||||
parsedAt: null,
|
||||
parsingError: null,
|
||||
rawParsingResult: null,
|
||||
confidenceScore: null,
|
||||
needsReview: false,
|
||||
}
|
||||
: a,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { previous };
|
||||
},
|
||||
|
||||
onError: (_err, _attachmentId, context) => {
|
||||
// Revert on error
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsing status badge color
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user