refactor(worksheet-parsing): remove backward-compatible prop fallbacks

Components now require WorksheetParsingContext instead of accepting
optional streaming props as fallbacks. This simplifies the codebase
by removing ~300 lines of fallback logic.

Changes:
- Create MockWorksheetParsingProvider for Storybook/testing
- Simplify OfflineWorkSection to use required context
- Simplify PhotoViewerEditor to use required context (~100 lines removed)
- Update stories to wrap with mock provider
- Remove unused StreamingParseState/StreamingReparseState types

🤖 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-05 07:25:19 -06:00
parent e544688d25
commit 23f79802e8
8 changed files with 399 additions and 494 deletions

View File

@ -653,7 +653,6 @@ export function SummaryClient({
)?.corrections?.[0]?.problemNumber ?? null)
: null
}
reparsingPhotoId={getPendingAttachmentId(reparseSelected)}
onCancelParsing={(attachmentId) => cancelParsing.mutate(attachmentId)}
onApproveProblem={async (photoId, problemIndex) => {
await updateReviewProgress.mutateAsync({

View File

@ -2,9 +2,9 @@
import { useMutation } from "@tanstack/react-query";
import type { RefObject } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { ParsingStatus } from "@/db/schema/practice-attachments";
import { useWorksheetParsingContextOptional } from "@/contexts/WorksheetParsingContext";
import { useWorksheetParsingContext } from "@/contexts/WorksheetParsingContext";
import { api } from "@/lib/queryClient";
import type {
WorksheetParsingResult,
@ -12,7 +12,6 @@ import type {
} from "@/lib/worksheet-parsing";
import { css } from "../../../styled-system/css";
import { WorksheetReviewSummary } from "../worksheet-parsing";
import type { StreamingParseState } from "@/hooks/useWorksheetParsing";
import { ParsingProgressOverlay } from "./ParsingProgressOverlay";
import { ParsingProgressPanel } from "./ParsingProgressPanel";
import { ProgressiveHighlightOverlayCompact } from "./ProgressiveHighlightOverlay";
@ -82,17 +81,6 @@ export interface OfflineWorkSectionProps {
onUnapprove?: (attachmentId: string) => Promise<void>;
/** ID of attachment currently being unapproved */
unaprovingId?: string | null;
// === Streaming parsing props ===
/** Streaming parsing state (when using streaming mode) */
streamingState?: StreamingParseState | null;
/** ID of attachment currently being streamed */
streamingAttachmentId?: string | null;
/** Cancel streaming parsing */
onCancelStreaming?: () => void;
/** Toggle expanded state for streaming panel */
isStreamingPanelExpanded?: boolean;
/** Callback to toggle streaming panel */
onToggleStreamingPanel?: () => void;
}
/**
@ -133,130 +121,59 @@ export function OfflineWorkSection({
approvingId = null,
onUnapprove,
unaprovingId = null,
// Streaming props
streamingState = null,
streamingAttachmentId = null,
onCancelStreaming,
isStreamingPanelExpanded = false,
onToggleStreamingPanel,
}: OfflineWorkSectionProps) {
// ============================================================================
// Context Integration (optional - falls back to props if not in provider)
// Context Integration (required - must be wrapped in WorksheetParsingProvider)
// ============================================================================
const parsingContext = useWorksheetParsingContextOptional();
const parsingContext = useWorksheetParsingContext();
// Local state for streaming panel expansion (used when context is available)
const [localIsStreamingPanelExpanded, setLocalIsStreamingPanelExpanded] =
// Local state for streaming panel expansion
const [isStreamingPanelExpanded, setIsStreamingPanelExpanded] =
useState(false);
// Derive effective streaming state from context or props
const effectiveStreamingState = useMemo<StreamingParseState | null>(() => {
if (parsingContext?.state.streaming) {
// Map context state to StreamingParseState format for backward compat
const { streaming } = parsingContext.state;
// Map statuses that don't exist in StreamingParseState
let mappedStatus: StreamingParseState["status"] = streaming.status as StreamingParseState["status"];
if (streaming.status === "cancelled") {
mappedStatus = "idle";
} else if (streaming.status === "processing") {
mappedStatus = "generating"; // processing is similar to generating
}
return {
status: mappedStatus,
reasoningText: streaming.reasoningText,
outputText: streaming.outputText,
error: null,
progressMessage: streaming.progressMessage,
result: null, // Result is in context state
stats: null,
completedProblems: streaming.completedProblems,
};
}
return streamingState;
}, [parsingContext?.state.streaming, streamingState]);
const effectiveStreamingAttachmentId = useMemo(() => {
if (parsingContext) {
return parsingContext.state.activeAttachmentId;
}
return streamingAttachmentId;
}, [parsingContext, streamingAttachmentId]);
// Effective handlers - use context if available, otherwise props
// Handlers that delegate to context
const handleParse = useCallback(
(attachmentId: string) => {
if (parsingContext) {
parsingContext.startParse({ attachmentId });
} else if (onParse) {
onParse(attachmentId);
}
parsingContext.startParse({ attachmentId });
},
[parsingContext, onParse],
[parsingContext],
);
const handleCancelStreaming = useCallback(() => {
if (parsingContext) {
parsingContext.cancel();
} else if (onCancelStreaming) {
onCancelStreaming();
}
}, [parsingContext, onCancelStreaming]);
parsingContext.cancel();
}, [parsingContext]);
const handleApproveAll = useCallback(
async (attachmentId: string) => {
if (parsingContext) {
await parsingContext.approve(attachmentId);
} else if (onApproveAll) {
await onApproveAll(attachmentId);
}
await parsingContext.approve(attachmentId);
},
[parsingContext, onApproveAll],
[parsingContext],
);
const handleUnapprove = useCallback(
async (attachmentId: string) => {
if (parsingContext) {
await parsingContext.unapprove(attachmentId);
} else if (onUnapprove) {
await onUnapprove(attachmentId);
}
await parsingContext.unapprove(attachmentId);
},
[parsingContext, onUnapprove],
[parsingContext],
);
// Effective panel expansion state - use local state when context available, otherwise props
const effectiveIsStreamingPanelExpanded = parsingContext
? localIsStreamingPanelExpanded
: isStreamingPanelExpanded;
// Effective toggle handler - use local setter when context available, otherwise prop
const handleToggleStreamingPanel = useCallback(() => {
if (parsingContext) {
setLocalIsStreamingPanelExpanded((prev) => !prev);
} else if (onToggleStreamingPanel) {
onToggleStreamingPanel();
}
}, [parsingContext, onToggleStreamingPanel]);
setIsStreamingPanelExpanded((prev) => !prev);
}, []);
// Reset local panel expansion when streaming completes
// Reset panel expansion when streaming completes
useEffect(() => {
if (
parsingContext?.state.streaming?.status === "complete" ||
parsingContext?.state.streaming?.status === "error" ||
parsingContext?.state.streaming?.status === "cancelled"
) {
// Reset expansion after streaming ends
const status = parsingContext.state.streaming?.status;
if (status === "complete" || status === "error" || status === "cancelled") {
const timer = setTimeout(() => {
setLocalIsStreamingPanelExpanded(false);
setIsStreamingPanelExpanded(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [parsingContext?.state.streaming?.status]);
}, [parsingContext.state.streaming?.status]);
// Check if any parsing operation is active (via context or props)
const isParsingActive = parsingContext
? parsingContext.isAnyParsingActive()
: streamingAttachmentId !== null;
// Check if any parsing operation is active
const isParsingActive = parsingContext.isAnyParsingActive();
// ============================================================================
// Original Component Logic
@ -426,13 +343,14 @@ export function OfflineWorkSection({
{/* Existing photos */}
{attachments.map((att, index) => {
// Check if this tile is currently streaming
const streamingState = parsingContext.state.streaming;
const isStreaming =
effectiveStreamingAttachmentId === att.id && effectiveStreamingState !== null;
parsingContext.state.activeAttachmentId === att.id && streamingState !== null;
const streamingActive =
isStreaming &&
(effectiveStreamingState?.status === "connecting" ||
effectiveStreamingState?.status === "reasoning" ||
effectiveStreamingState?.status === "generating");
(streamingState?.status === "connecting" ||
streamingState?.status === "reasoning" ||
streamingState?.status === "generating");
return (
<div
@ -794,34 +712,32 @@ export function OfflineWorkSection({
)}
{/* Streaming: Progressive highlight overlay */}
{streamingActive && effectiveStreamingState && (
{streamingActive && streamingState && (
<ProgressiveHighlightOverlayCompact
completedProblems={effectiveStreamingState.completedProblems}
completedProblems={streamingState.completedProblems}
staggerDelay={30}
/>
)}
{/* Streaming: Progress overlay */}
{streamingActive &&
effectiveStreamingState &&
(onCancelStreaming || parsingContext) && (
<ParsingProgressOverlay
progressMessage={effectiveStreamingState.progressMessage}
completedCount={effectiveStreamingState.completedProblems.length}
isPanelExpanded={effectiveIsStreamingPanelExpanded}
onTogglePanel={handleToggleStreamingPanel}
onCancel={handleCancelStreaming}
hasReasoningText={effectiveStreamingState.reasoningText.length > 0}
/>
)}
{streamingActive && streamingState && (
<ParsingProgressOverlay
progressMessage={streamingState.progressMessage}
completedCount={streamingState.completedProblems.length}
isPanelExpanded={isStreamingPanelExpanded}
onTogglePanel={handleToggleStreamingPanel}
onCancel={handleCancelStreaming}
hasReasoningText={streamingState.reasoningText.length > 0}
/>
)}
</div>
{/* Streaming: Reasoning panel below tile */}
{isStreaming && effectiveStreamingState && (
{isStreaming && streamingState && (
<ParsingProgressPanel
isExpanded={effectiveIsStreamingPanelExpanded}
reasoningText={effectiveStreamingState.reasoningText}
status={effectiveStreamingState.status}
isExpanded={isStreamingPanelExpanded}
reasoningText={streamingState.reasoningText}
status={streamingState.status}
isDark={isDark}
/>
)}

View File

@ -15,14 +15,16 @@ export interface ParsingProgressPanelProps {
isExpanded: boolean;
/** The reasoning text from the LLM */
reasoningText: string;
/** Current parsing status */
/** Current parsing status (from StreamingStatus) */
status:
| "idle"
| "connecting"
| "reasoning"
| "processing"
| "generating"
| "complete"
| "error"
| "idle";
| "cancelled";
/** Dark mode */
isDark?: boolean;
}

View File

@ -10,13 +10,9 @@ import {
type ProblemCorrection,
} from "@/components/worksheet-parsing";
import type { ReviewProgress } from "@/lib/worksheet-parsing";
import type {
StreamingParseState,
StreamingReparseState,
} from "@/hooks/useWorksheetParsing";
import { Z_INDEX } from "@/constants/zIndex";
import { useVisualDebug } from "@/contexts/VisualDebugContext";
import { useWorksheetParsingContextOptional } from "@/contexts/WorksheetParsingContext";
import { useWorksheetParsingContext } from "@/contexts/WorksheetParsingContext";
import type {
ModelConfig,
WorksheetParsingResult,
@ -89,22 +85,6 @@ export interface PhotoViewerEditorProps {
corners: Array<{ x: number; y: number }>,
rotation: 0 | 90 | 180 | 270,
) => Promise<void>;
/** Callback to parse worksheet (optional - if not provided, no parse button shown) */
onParse?: (
photoId: string,
modelConfigId?: string,
additionalContext?: string,
preservedBoundingBoxes?: Record<
number,
{ x: number; y: number; width: number; height: number }
>,
) => void;
/** ID of the photo currently being parsed (null if none) */
parsingPhotoId?: string | null;
/** Streaming parse state (null if not streaming) */
streamingParseState?: StreamingParseState | null;
/** Callback to cancel streaming parse */
onCancelStreamingParse?: () => void;
/** Available model configurations for parsing */
modelConfigs?: ModelConfig[];
/** Callback to approve parsed worksheet and create session */
@ -118,24 +98,6 @@ export interface PhotoViewerEditorProps {
) => Promise<void>;
/** Problem number currently being saved (null if none) */
savingProblemNumber?: number | null;
/** Callback to re-parse selected problems */
onReparseSelected?: (
photoId: string,
problemIndices: number[],
boundingBoxes: Array<{
x: number;
y: number;
width: number;
height: number;
}>,
additionalContext?: string,
) => Promise<void>;
/** ID of photo currently being re-parsed (null if none) */
reparsingPhotoId?: string | null;
/** Streaming re-parse state (null if not streaming) */
streamingReparseState?: StreamingReparseState | null;
/** Callback to cancel streaming re-parse */
onCancelStreamingReparse?: () => void;
/** Callback to cancel parsing in progress */
onCancelParsing?: (photoId: string) => void;
/** Callback when a single problem is approved in focus review mode */
@ -162,19 +124,11 @@ export function PhotoViewerEditor({
isOpen,
onClose,
onEditConfirm,
onParse,
parsingPhotoId = null,
streamingParseState = null,
onCancelStreamingParse,
modelConfigs = [],
onApprove,
approvingPhotoId = null,
onSubmitCorrection,
savingProblemNumber = null,
onReparseSelected,
reparsingPhotoId = null,
streamingReparseState = null,
onCancelStreamingReparse,
onCancelParsing,
onApproveProblem,
onFlagProblem,
@ -236,94 +190,23 @@ export function PhotoViewerEditor({
cv: opencvRef,
} = useDocumentDetection();
// Try to use context if available (for centralized parsing state management)
const parsingContext = useWorksheetParsingContextOptional();
// Use the worksheet parsing context (required)
const parsingContext = useWorksheetParsingContext();
// Derive effective streaming state from context or props
const effectiveStreamingState = useMemo((): StreamingParseState | null => {
if (
parsingContext?.state.streaming &&
parsingContext.state.streaming.streamType === "initial"
) {
const { streaming } = parsingContext.state;
// Map context status to StreamingParseState status
let mappedStatus: StreamingParseState["status"];
if (streaming.status === "cancelled") {
mappedStatus = "idle";
} else if (streaming.status === "processing") {
mappedStatus = "generating";
} else {
mappedStatus = streaming.status as StreamingParseState["status"];
}
return {
status: mappedStatus,
reasoningText: streaming.reasoningText ?? "",
outputText: streaming.outputText ?? "",
error: parsingContext.state.lastError,
progressMessage: streaming.progressMessage,
result: parsingContext.state.lastResult,
// Context uses different ParsingStats type - pass null since stats isn't used in this component
stats: null,
completedProblems: streaming.completedProblems ?? [],
};
}
return streamingParseState ?? null;
}, [parsingContext?.state.streaming, parsingContext?.state.lastError, parsingContext?.state.lastResult, streamingParseState]);
// Derive streaming state from context
const streamingState = parsingContext.state.streaming;
const isInitialParsing =
streamingState?.streamType === "initial" &&
(streamingState.status === "connecting" ||
streamingState.status === "reasoning" ||
streamingState.status === "generating");
const isReparsing =
streamingState?.streamType === "reparse" &&
(streamingState.status === "connecting" ||
streamingState.status === "processing");
const activeAttachmentId = parsingContext.state.activeAttachmentId;
const effectiveStreamingReparseState = useMemo(
(): StreamingReparseState | null => {
if (
parsingContext?.state.streaming &&
parsingContext.state.streaming.streamType === "reparse"
) {
const { streaming } = parsingContext.state;
// Map context status to StreamingReparseState status
let mappedStatus: StreamingReparseState["status"];
if (streaming.status === "generating" || streaming.status === "reasoning") {
mappedStatus = "processing"; // These map to processing in reparse
} else {
mappedStatus = streaming.status as StreamingReparseState["status"];
}
return {
status: mappedStatus,
reasoningText: streaming.reasoningText ?? "",
currentProblemIndex: streaming.currentProblemIndex ?? 0,
totalProblems: streaming.totalProblems ?? 0,
currentProblemWorksheetIndex: null, // Not tracked in context state
completedIndices: streaming.completedIndices ?? [],
progressMessage: streaming.progressMessage ?? "",
error: parsingContext.state.lastError ?? null,
result: parsingContext.state.lastResult,
};
}
return streamingReparseState ?? null;
},
[parsingContext?.state.streaming, parsingContext?.state.lastError, parsingContext?.state.lastResult, streamingReparseState],
);
// Derive effective parsing state
const effectiveParsingPhotoId = useMemo(() => {
if (parsingContext?.state.activeAttachmentId) {
return parsingContext.state.activeAttachmentId;
}
return parsingPhotoId;
}, [parsingContext?.state.activeAttachmentId, parsingPhotoId]);
const effectiveReparsingPhotoId = useMemo(() => {
if (
parsingContext?.state.activeAttachmentId &&
parsingContext.state.streaming?.streamType === "reparse"
) {
return parsingContext.state.activeAttachmentId;
}
return reparsingPhotoId;
}, [
parsingContext?.state.activeAttachmentId,
parsingContext?.state.streaming?.streamType,
reparsingPhotoId,
]);
// Effective handlers - prefer context if available
// Handlers using context directly
const handleParse = useCallback(
(
photoId: string,
@ -334,56 +217,36 @@ export function PhotoViewerEditor({
{ x: number; y: number; width: number; height: number }
>,
) => {
if (parsingContext) {
parsingContext.startParse({
attachmentId: photoId,
modelConfigId,
additionalContext,
preservedBoundingBoxes,
});
} else if (onParse) {
onParse(photoId, modelConfigId, additionalContext, preservedBoundingBoxes);
}
parsingContext.startParse({
attachmentId: photoId,
modelConfigId,
additionalContext,
preservedBoundingBoxes,
});
},
[parsingContext, onParse],
[parsingContext],
);
const handleCancelStreaming = useCallback(() => {
if (parsingContext) {
parsingContext.cancel();
} else if (onCancelStreamingParse) {
onCancelStreamingParse();
}
}, [parsingContext, onCancelStreamingParse]);
parsingContext.cancel();
}, [parsingContext]);
const handleCancelStreamingReparse = useCallback(() => {
if (parsingContext) {
parsingContext.cancel();
} else if (onCancelStreamingReparse) {
onCancelStreamingReparse();
}
}, [parsingContext, onCancelStreamingReparse]);
parsingContext.cancel();
}, [parsingContext]);
const handleApprove = useCallback(
(photoId: string) => {
if (parsingContext) {
parsingContext.approve(photoId);
} else if (onApprove) {
onApprove(photoId);
}
parsingContext.approve(photoId);
},
[parsingContext, onApprove],
[parsingContext],
);
const handleSubmitCorrection = useCallback(
async (photoId: string, correction: ProblemCorrection) => {
if (parsingContext) {
await parsingContext.submitCorrection(photoId, [correction]);
} else if (onSubmitCorrection) {
await onSubmitCorrection(photoId, correction);
}
await parsingContext.submitCorrection(photoId, [correction]);
},
[parsingContext, onSubmitCorrection],
[parsingContext],
);
const executeReparseSelected = useCallback(
@ -398,23 +261,14 @@ export function PhotoViewerEditor({
}>,
additionalContext?: string,
) => {
if (parsingContext) {
await parsingContext.startReparse({
attachmentId: photoId,
problemIndices,
boundingBoxes,
additionalContext,
});
} else if (onReparseSelected) {
await onReparseSelected(
photoId,
problemIndices,
boundingBoxes,
additionalContext,
);
}
await parsingContext.startReparse({
attachmentId: photoId,
problemIndices,
boundingBoxes,
additionalContext,
});
},
[parsingContext, onReparseSelected],
[parsingContext],
);
// Reset state when opening
@ -595,10 +449,7 @@ export function PhotoViewerEditor({
// Confirm and execute re-parse
const confirmReparseSelected = useCallback(async () => {
// Check if we can execute - need either context or prop handler
const canExecute = parsingContext || onReparseSelected;
if (!currentPhoto || !canExecute || selectedForReparse.size === 0)
return;
if (!currentPhoto || selectedForReparse.size === 0) return;
const problems = currentPhoto.rawParsingResult?.problems ?? [];
const indices = Array.from(selectedForReparse).sort((a, b) => a - b);
@ -629,13 +480,11 @@ export function PhotoViewerEditor({
setAdjustedBoxes(new Map());
await executeReparseSelected(currentPhoto.id, indices, boundingBoxes);
}, [currentPhoto, parsingContext, onReparseSelected, selectedForReparse, adjustedBoxes, executeReparseSelected]);
}, [currentPhoto, selectedForReparse, adjustedBoxes, executeReparseSelected]);
// Bulk exclude selected problems
const handleExcludeSelected = useCallback(async () => {
const canSubmit = parsingContext || onSubmitCorrection;
if (!currentPhoto || !canSubmit || selectedForReparse.size === 0)
return;
if (!currentPhoto || selectedForReparse.size === 0) return;
const problems = currentPhoto.rawParsingResult?.problems ?? [];
const indices = Array.from(selectedForReparse).sort((a, b) => a - b);
@ -654,13 +503,11 @@ export function PhotoViewerEditor({
// Clear selections after excluding
setSelectedForReparse(new Set());
setAdjustedBoxes(new Map());
}, [currentPhoto, parsingContext, onSubmitCorrection, selectedForReparse, handleSubmitCorrection]);
}, [currentPhoto, selectedForReparse, handleSubmitCorrection]);
// Bulk restore selected problems
const handleRestoreSelected = useCallback(async () => {
const canSubmit = parsingContext || onSubmitCorrection;
if (!currentPhoto || !canSubmit || selectedForReparse.size === 0)
return;
if (!currentPhoto || selectedForReparse.size === 0) return;
const problems = currentPhoto.rawParsingResult?.problems ?? [];
const indices = Array.from(selectedForReparse).sort((a, b) => a - b);
@ -679,7 +526,7 @@ export function PhotoViewerEditor({
// Clear selections after restoring
setSelectedForReparse(new Set());
setAdjustedBoxes(new Map());
}, [currentPhoto, parsingContext, onSubmitCorrection, selectedForReparse, handleSubmitCorrection]);
}, [currentPhoto, selectedForReparse, handleSubmitCorrection]);
// Count how many selected problems are excluded vs non-excluded
const selectedExcludedCount = useMemo(() => {
@ -1047,16 +894,11 @@ export function PhotoViewerEditor({
onReviewSubModeChange={setReviewSubMode}
showReparsePreview={showReparsePreview}
selectedForReparseCount={selectedForReparse.size}
canParse={!!(parsingContext || onParse)}
isParsing={effectiveParsingPhotoId === currentPhoto.id}
isReparsing={
effectiveReparsingPhotoId === currentPhoto.id ||
effectiveStreamingReparseState?.status === "connecting" ||
effectiveStreamingReparseState?.status === "processing" ||
false
}
canParse={true}
isParsing={activeAttachmentId === currentPhoto.id && isInitialParsing}
isReparsing={activeAttachmentId === currentPhoto.id && isReparsing}
isApproving={approvingPhotoId === currentPhoto.id}
canApprove={!!(parsingContext || onApprove)}
canApprove={true}
onBack={() => {
setMode("view");
setSelectedProblemIndex(null);
@ -1077,7 +919,7 @@ export function PhotoViewerEditor({
}
}}
onApprove={() => {
if ((parsingContext || onApprove) && currentPhoto) {
if (currentPhoto) {
handleApprove(currentPhoto.id);
}
}}
@ -1115,7 +957,7 @@ export function PhotoViewerEditor({
}
}}
onCorrectProblem={async (problemIndex, correction) => {
if ((parsingContext || onSubmitCorrection) && currentPhoto) {
if (currentPhoto) {
await handleSubmitCorrection(currentPhoto.id, correction);
}
}}
@ -1226,9 +1068,7 @@ export function PhotoViewerEditor({
</h3>
{/* Streaming re-parse progress */}
{effectiveStreamingReparseState &&
(effectiveStreamingReparseState.status === "connecting" ||
effectiveStreamingReparseState.status === "processing") ? (
{isReparsing && streamingState ? (
<div
data-element="streaming-reparse-progress"
className={css({
@ -1275,33 +1115,31 @@ export function PhotoViewerEditor({
color: "blue.300",
})}
>
{effectiveStreamingReparseState.progressMessage ||
`Problem ${effectiveStreamingReparseState.currentProblemIndex + 1} of ${effectiveStreamingReparseState.totalProblems}`}
{streamingState.progressMessage ||
`Problem ${(streamingState.currentProblemIndex ?? 0) + 1} of ${streamingState.totalProblems ?? 0}`}
</div>
</div>
{/* Cancel button */}
{(parsingContext || onCancelStreamingReparse) && (
<button
type="button"
onClick={handleCancelStreamingReparse}
className={css({
marginLeft: "auto",
padding: "4px 12px",
fontSize: "xs",
fontWeight: "medium",
color: "blue.300",
backgroundColor: "transparent",
border: "1px solid token(colors.blue.500)",
borderRadius: "md",
cursor: "pointer",
_hover: {
backgroundColor: "blue.800",
},
})}
>
Cancel
</button>
)}
<button
type="button"
onClick={handleCancelStreamingReparse}
className={css({
marginLeft: "auto",
padding: "4px 12px",
fontSize: "xs",
fontWeight: "medium",
color: "blue.300",
backgroundColor: "transparent",
border: "1px solid token(colors.blue.500)",
borderRadius: "md",
cursor: "pointer",
_hover: {
backgroundColor: "blue.800",
},
})}
>
Cancel
</button>
</div>
{/* Progress bar */}
@ -1321,9 +1159,9 @@ export function PhotoViewerEditor({
})}
style={{
width: `${
effectiveStreamingReparseState.totalProblems > 0
? (effectiveStreamingReparseState.currentProblemIndex /
effectiveStreamingReparseState.totalProblems) *
(streamingState.totalProblems ?? 0) > 0
? ((streamingState.currentProblemIndex ?? 0) /
(streamingState.totalProblems ?? 1)) *
100
: 0
}%`,
@ -1332,7 +1170,7 @@ export function PhotoViewerEditor({
</div>
{/* Reasoning text panel */}
{effectiveStreamingReparseState.reasoningText && (
{streamingState.reasoningText && (
<div
className={css({
flex: 1,
@ -1363,13 +1201,13 @@ export function PhotoViewerEditor({
fontFamily: "monospace",
})}
>
{effectiveStreamingReparseState.reasoningText}
{streamingState.reasoningText}
</pre>
</div>
)}
{/* Completed problems list */}
{effectiveStreamingReparseState.completedIndices.length > 0 && (
{(streamingState.completedIndices?.length ?? 0) > 0 && (
<div
className={css({
fontSize: "xs",
@ -1377,7 +1215,7 @@ export function PhotoViewerEditor({
})}
>
Completed:{" "}
{effectiveStreamingReparseState.completedIndices
{(streamingState.completedIndices ?? [])
.map((i) => `#${i + 1}`)
.join(", ")}
</div>
@ -1581,7 +1419,7 @@ export function PhotoViewerEditor({
)
}
onSubmitCorrection={(correction) => {
if ((parsingContext || onSubmitCorrection) && currentPhoto) {
if (currentPhoto) {
handleSubmitCorrection(currentPhoto.id, correction);
}
}}
@ -1755,7 +1593,7 @@ export function PhotoViewerEditor({
isOpen={showReparseModal}
onClose={() => setShowReparseModal(false)}
onConfirm={(hints) => {
if ((parsingContext || onParse) && currentPhoto) {
if (currentPhoto) {
// Pass adjusted bounding boxes if any exist
const preserved =
adjustedBoxes.size > 0
@ -1765,7 +1603,7 @@ export function PhotoViewerEditor({
setShowReparseModal(false);
}
}}
isProcessing={effectiveParsingPhotoId === currentPhoto.id}
isProcessing={activeAttachmentId === currentPhoto.id && isInitialParsing}
/>
</div>
);
@ -1872,8 +1710,7 @@ export function PhotoViewerEditor({
</button>
{/* Parse button - split button with model selection dropdown */}
{(parsingContext || onParse) &&
(!currentPhoto.parsingStatus ||
{(!currentPhoto.parsingStatus ||
currentPhoto.parsingStatus === "failed") &&
!currentPhoto.sessionCreated && (
<div
@ -1894,12 +1731,7 @@ export function PhotoViewerEditor({
: undefined;
handleParse(currentPhoto.id, undefined, undefined, preserved);
}}
disabled={
effectiveParsingPhotoId === currentPhoto.id ||
effectiveStreamingState?.status === "connecting" ||
effectiveStreamingState?.status === "reasoning" ||
effectiveStreamingState?.status === "generating"
}
disabled={isInitialParsing}
className={css({
px: 4,
py: 2,
@ -1934,10 +1766,7 @@ export function PhotoViewerEditor({
: "Parse worksheet"
}
>
{effectiveParsingPhotoId === currentPhoto.id ||
effectiveStreamingState?.status === "connecting" ||
effectiveStreamingState?.status === "reasoning" ||
effectiveStreamingState?.status === "generating" ? (
{isInitialParsing ? (
<>
<span
className={css({
@ -1947,11 +1776,11 @@ export function PhotoViewerEditor({
</span>
<span>
{effectiveStreamingState?.status === "connecting"
{streamingState?.status === "connecting"
? "Connecting..."
: effectiveStreamingState?.status === "reasoning"
: streamingState?.status === "reasoning"
? "Thinking..."
: effectiveStreamingState?.status === "generating"
: streamingState?.status === "generating"
? "Extracting..."
: "Analyzing..."}
</span>
@ -1976,12 +1805,7 @@ export function PhotoViewerEditor({
type="button"
data-action="toggle-model-dropdown"
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
disabled={
effectiveParsingPhotoId === currentPhoto.id ||
effectiveStreamingState?.status === "connecting" ||
effectiveStreamingState?.status === "reasoning" ||
effectiveStreamingState?.status === "generating"
}
disabled={isInitialParsing}
className={css({
px: 2,
py: 2,
@ -2143,10 +1967,7 @@ export function PhotoViewerEditor({
)}
{/* Streaming Parse Progress Panel */}
{effectiveStreamingState &&
(effectiveStreamingState.status === "connecting" ||
effectiveStreamingState.status === "reasoning" ||
effectiveStreamingState.status === "generating") && (
{isInitialParsing && streamingState && (
<div
data-element="streaming-parse-progress"
className={css({
@ -2194,39 +2015,37 @@ export function PhotoViewerEditor({
color: "blue.300",
})}
>
{effectiveStreamingState.status === "connecting"
{streamingState.status === "connecting"
? "Connecting to AI..."
: effectiveStreamingState.status === "reasoning"
: streamingState.status === "reasoning"
? "AI is analyzing worksheet..."
: "Extracting problems..."}
</span>
</div>
{(parsingContext || onCancelStreamingParse) && (
<button
type="button"
onClick={handleCancelStreaming}
className={css({
padding: "4px 12px",
fontSize: "xs",
fontWeight: "medium",
color: "gray.400",
backgroundColor: "transparent",
border: "1px solid token(colors.gray.600)",
borderRadius: "md",
cursor: "pointer",
_hover: {
backgroundColor: "gray.700",
color: "white",
},
})}
>
Cancel
</button>
)}
<button
type="button"
onClick={handleCancelStreaming}
className={css({
padding: "4px 12px",
fontSize: "xs",
fontWeight: "medium",
color: "gray.400",
backgroundColor: "transparent",
border: "1px solid token(colors.gray.600)",
borderRadius: "md",
cursor: "pointer",
_hover: {
backgroundColor: "gray.700",
color: "white",
},
})}
>
Cancel
</button>
</div>
{/* Reasoning text - collapsible */}
{effectiveStreamingState.reasoningText && (
{streamingState.reasoningText && (
<div
className={css({
maxHeight: "120px",
@ -2240,12 +2059,12 @@ export function PhotoViewerEditor({
fontFamily: "monospace",
})}
>
{effectiveStreamingState.reasoningText}
{streamingState.reasoningText}
</div>
)}
{/* Progress indicator for problems found */}
{effectiveStreamingState.completedProblems.length > 0 && (
{(streamingState.completedProblems?.length ?? 0) > 0 && (
<div
className={css({
marginTop: 2,
@ -2253,7 +2072,7 @@ export function PhotoViewerEditor({
color: "green.400",
})}
>
Found {effectiveStreamingState.completedProblems.length}{" "}
Found {streamingState.completedProblems?.length ?? 0}{" "}
problems...
</div>
)}

View File

@ -38,6 +38,7 @@ import {
type PhotoViewerEditorPhoto,
} from "../practice/PhotoViewerEditor";
import { VisualDebugProvider } from "@/contexts/VisualDebugContext";
import { MockWorksheetParsingProvider } from "@/contexts/WorksheetParsingContext.mock";
// Create a fresh query client for stories
function createQueryClient() {
@ -1170,7 +1171,9 @@ function FullFlowDemo() {
export const FullFlow: SummaryStory = {
render: () => (
<QueryClientProvider client={createQueryClient()}>
<FullFlowDemo />
<MockWorksheetParsingProvider>
<FullFlowDemo />
</MockWorksheetParsingProvider>
</QueryClientProvider>
),
parameters: {

View File

@ -0,0 +1,224 @@
"use client";
/**
* Mock Worksheet Parsing Context Provider
*
* Provides a controllable mock of the WorksheetParsingContext for use in:
* - Storybook stories (testing specific UI states)
* - Unit tests (isolated component testing)
*
* @example
* ```tsx
* // In a Storybook story
* <MockWorksheetParsingProvider
* state={{
* activeAttachmentId: "photo-1",
* streaming: {
* status: "generating",
* streamType: "initial",
* reasoningText: "Analyzing worksheet...",
* // ...
* },
* }}
* >
* <OfflineWorkSection {...props} />
* </MockWorksheetParsingProvider>
* ```
*/
import { createContext, useContext, type ReactNode } from "react";
import {
initialParsingState,
isParsingAttachment as isParsingAttachmentHelper,
isAnyParsingActive as isAnyParsingActiveHelper,
getStreamingStatus as getStreamingStatusHelper,
type ParsingContextState,
} from "@/lib/worksheet-parsing/state-machine";
import type {
WorksheetParsingContextValue,
ApproveResponse,
} from "./WorksheetParsingContext";
// ============================================================================
// Mock Provider
// ============================================================================
interface MockWorksheetParsingProviderProps {
children: ReactNode;
/** Override the default initial state */
state?: Partial<ParsingContextState>;
/** Mock implementations for actions (optional - defaults to no-ops) */
actions?: Partial<{
startParse: WorksheetParsingContextValue["startParse"];
startReparse: WorksheetParsingContextValue["startReparse"];
cancel: WorksheetParsingContextValue["cancel"];
reset: WorksheetParsingContextValue["reset"];
submitCorrection: WorksheetParsingContextValue["submitCorrection"];
approve: WorksheetParsingContextValue["approve"];
unapprove: WorksheetParsingContextValue["unapprove"];
}>;
}
// Re-use the same context from the real provider
const MockWorksheetParsingContext =
createContext<WorksheetParsingContextValue | null>(null);
/**
* Mock provider for testing and Storybook
*
* Allows explicit control over parsing state without real API calls.
*/
export function MockWorksheetParsingProvider({
children,
state: stateOverrides = {},
actions = {},
}: MockWorksheetParsingProviderProps) {
// Merge overrides with initial state
const state: ParsingContextState = {
...initialParsingState,
...stateOverrides,
};
// Create mock context value with no-op defaults for actions
const value: WorksheetParsingContextValue = {
state,
// Derived helpers use actual logic with mock state
isParsingAttachment: (attachmentId: string) =>
isParsingAttachmentHelper(state, attachmentId),
isAnyParsingActive: () => isAnyParsingActiveHelper(state),
getStreamingStatus: (attachmentId: string) =>
getStreamingStatusHelper(state, attachmentId),
// Actions default to no-ops but can be overridden
startParse: actions.startParse ?? (async () => {}),
startReparse: actions.startReparse ?? (async () => {}),
cancel: actions.cancel ?? (() => {}),
reset: actions.reset ?? (() => {}),
submitCorrection: actions.submitCorrection ?? (async () => {}),
approve:
actions.approve ??
(async (): Promise<ApproveResponse> => ({
success: true,
sessionId: "mock-session-id",
problemCount: 0,
correctCount: 0,
accuracy: null,
skillsExercised: [],
stats: {
totalProblems: 0,
correctCount: 0,
incorrectCount: 0,
unansweredCount: 0,
accuracy: null,
skillsDetected: [],
},
})),
unapprove: actions.unapprove ?? (async () => {}),
};
return (
<MockWorksheetParsingContext.Provider value={value}>
{children}
</MockWorksheetParsingContext.Provider>
);
}
/**
* Hook to access the mock context
*
* Can be used interchangeably with useWorksheetParsingContext in tests
*/
export function useMockWorksheetParsingContext(): WorksheetParsingContextValue {
const context = useContext(MockWorksheetParsingContext);
if (!context) {
throw new Error(
"useMockWorksheetParsingContext must be used within a MockWorksheetParsingProvider",
);
}
return context;
}
// ============================================================================
// Storybook Helpers
// ============================================================================
/** Pre-configured states for common Storybook scenarios */
export const mockParsingStates = {
/** Initial idle state - no parsing activity */
idle: initialParsingState,
/** Actively parsing a worksheet */
parsing: (attachmentId: string): Partial<ParsingContextState> => ({
activeAttachmentId: attachmentId,
streaming: {
status: "generating",
streamType: "initial",
reasoningText: "I can see a worksheet with arithmetic problems...",
outputText: '{"problems": [',
progressMessage: "Extracting problems... 5 found",
completedProblems: [],
},
}),
/** Parsing complete with results */
complete: (attachmentId: string): Partial<ParsingContextState> => ({
activeAttachmentId: null,
streaming: null,
lastResult: {
problems: [],
pageMetadata: {
lessonId: null,
weekId: null,
pageNumber: null,
detectedFormat: "vertical",
totalRows: 0,
problemsPerRow: 0,
},
overallConfidence: 0.95,
needsReview: false,
warnings: [],
},
lastStats: {
totalProblems: 10,
correctCount: 8,
incorrectCount: 2,
unansweredCount: 0,
accuracy: 0.8,
skillsDetected: ["add-1-digit"],
},
lastError: null,
}),
/** Parsing failed with error */
error: (
attachmentId: string,
errorMessage: string,
): Partial<ParsingContextState> => ({
activeAttachmentId: null,
streaming: null,
lastResult: null,
lastStats: null,
lastError: errorMessage,
}),
/** Re-parsing specific problems */
reparsing: (
attachmentId: string,
currentIndex: number,
total: number,
): Partial<ParsingContextState> => ({
activeAttachmentId: attachmentId,
streaming: {
status: "processing",
streamType: "reparse",
reasoningText: "Re-analyzing this problem more carefully...",
outputText: "",
progressMessage: `Re-parsing problem ${currentIndex + 1} of ${total}`,
completedProblems: [],
currentProblemIndex: currentIndex,
totalProblems: total,
completedIndices: Array.from({ length: currentIndex }, (_, i) => i),
},
}),
};

View File

@ -92,7 +92,7 @@ export interface StartReparseOptions {
}
/** Response from approve API */
interface ApproveResponse {
export interface ApproveResponse {
success: boolean;
sessionId: string;
problemCount: number;

View File

@ -229,70 +229,12 @@ export function useStartParsing(playerId: string, sessionId: string) {
}
// ============================================================================
// Streaming Parsing Hook
// Streaming State Management
// ============================================================================
/** Streaming parsing state */
export interface StreamingParseState {
/** Current status */
status:
| "idle"
| "connecting"
| "reasoning"
| "generating"
| "complete"
| "error";
/** Accumulated reasoning text (model's thinking process) */
reasoningText: string;
/** Accumulated output text (partial JSON) */
outputText: string;
/** Error message if failed */
error: string | null;
/** Progress stage message */
progressMessage: string | null;
/** Final result when complete */
result: WorksheetParsingResult | null;
/** Final stats when complete */
stats: ParsingStats | null;
/** Problems that have been fully streamed (for progressive highlighting) */
completedProblems: CompletedProblem[];
}
// NOTE: useStreamingParse hook was removed - use WorksheetParsingContext instead
// ============================================================================
// Streaming Re-parse State Types
// ============================================================================
/** Streaming re-parse state */
export interface StreamingReparseState {
/** Current status */
status:
| "idle"
| "connecting"
| "processing"
| "complete"
| "error"
| "cancelled";
/** Current problem being processed (index in problemIndices array) */
currentProblemIndex: number;
/** Total problems to process */
totalProblems: number;
/** Current problem's worksheet index */
currentProblemWorksheetIndex: number | null;
/** Accumulated reasoning text for current problem */
reasoningText: string;
/** Error message if failed */
error: string | null;
/** Progress message */
progressMessage: string | null;
/** Completed problem indices */
completedIndices: number[];
/** Final result when complete */
result: WorksheetParsingResult | null;
}
// NOTE: useStreamingReparse hook was removed - use WorksheetParsingContext instead
// NOTE: Streaming state is now managed via WorksheetParsingContext.
// See src/contexts/WorksheetParsingContext.tsx for the context provider
// and src/lib/worksheet-parsing/state-machine.ts for the state types.
/**
* Hook to submit corrections to parsed problems