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:
Thomas Hallock
2026-01-03 06:53:42 -06:00
parent 63dcca13cf
commit 2bb22dd1f2
4 changed files with 183 additions and 1 deletions

View File

@@ -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 },
);
}
}

View File

@@ -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 && (

View File

@@ -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>
)}

View File

@@ -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
*/