Skip to content

Commit adeb87e

Browse files
JasonWildMeclaude
andcommitted
Fix encounter polling lifecycle for bulk-import detection
Three issues prevented the bounding box from rendering after detection completes for bulk-imported encounters: 1. setMediaAssets mutated _encounterData.mediaAssets in place. React useEffects keyed on store.encounterData saw the same reference and never re-ran, so rects/imageReady never updated when polling returned new annotations. Switched to immutable update: replace _encounterData with a new object. 2. The polling predicate treated null detectionStatus as terminal. Bulk imports create encounters with a trivial placeholder annotation and detectionStatus=null while detection is queued to WBIA. The first poll saw null, stopped polling, and never noticed when detection actually completed. Added isAwaitingBulkImportDetection helper that keeps polling alive when detectionStatus is null, importTaskId is set, and only trivial annotations exist. 3. ImageCard's "Detection in progress" indicator used the same broken predicate. Now uses the shared helper so the spinner shows during the WBIA wait. Centralized the predicates in pollingHelpers.js so Encounter.jsx and ImageCard.jsx can't drift. Added MAX_POLL_CYCLES=100 (~5 min at 3s interval) safety cap so encounters that never get detection don't poll forever. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 909bdd8 commit adeb87e

4 files changed

Lines changed: 59 additions & 31 deletions

File tree

frontend/src/pages/Encounter/Encounter.jsx

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ import { Divider } from "antd";
3838
import { get } from "lodash-es";
3939
import CollabModal from "./CollabModal";
4040
import Alert from "react-bootstrap/Alert";
41+
import {
42+
shouldContinuePollingEncounter,
43+
POLL_INTERVAL_MS,
44+
MAX_POLL_CYCLES,
45+
} from "./pollingHelpers";
4146

4247
const Encounter = observer(() => {
4348
const [store] = useState(() => new EncounterStore());
@@ -72,26 +77,7 @@ const Encounter = observer(() => {
7277
useEffect(() => {
7378
let cancelled = false;
7479
let timeoutId = null;
75-
76-
const isTerminalDetectionStatus = (status) =>
77-
!status ||
78-
status === "complete" ||
79-
status === "error" ||
80-
status === "pending";
81-
82-
const shouldContinuePolling = (encounterData) => {
83-
const mediaAssets = Array.isArray(encounterData?.mediaAssets)
84-
? encounterData.mediaAssets
85-
: [];
86-
87-
if (mediaAssets.length === 0) {
88-
return false;
89-
}
90-
91-
return mediaAssets.some(
92-
(asset) => !isTerminalDetectionStatus(asset?.detectionStatus),
93-
);
94-
};
80+
let pollCount = 0;
9581

9682
const fetchEncounter = async () => {
9783
try {
@@ -108,8 +94,12 @@ const Encounter = observer(() => {
10894
store.setMediaAssets(res.data.mediaAssets);
10995
}
11096

111-
if (shouldContinuePolling(res.data)) {
112-
timeoutId = window.setTimeout(fetchEncounter, 3000);
97+
pollCount++;
98+
if (
99+
pollCount < MAX_POLL_CYCLES &&
100+
shouldContinuePollingEncounter(res.data)
101+
) {
102+
timeoutId = window.setTimeout(fetchEncounter, POLL_INTERVAL_MS);
113103
}
114104
} catch (_err) {
115105
if (!cancelled && isInitialLoad.current) {

frontend/src/pages/Encounter/ImageCard.jsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Tooltip from "../../components/ToolTip";
1414
import axios from "axios";
1515
import { useIntl } from "react-intl";
1616
import SpotMappingIcon2 from "../../components/icons/SpotMappingIcon2";
17+
import { isAssetActivelyAwaitingDetection } from "./pollingHelpers";
1718

1819
const ImageCard = observer(({ store = {} }) => {
1920
const imgRef = useRef(null);
@@ -67,17 +68,11 @@ const ImageCard = observer(({ store = {} }) => {
6768
JSON.stringify(editAnnotationParams),
6869
);
6970

70-
const isTerminalDetectionStatus = (status) =>
71-
!status ||
72-
status === "complete" ||
73-
status === "error" ||
74-
status === "pending";
75-
7671
const selectedAsset =
7772
store.encounterData?.mediaAssets?.[store.selectedImageIndex];
78-
const selectedAssetDetectionStatus = selectedAsset?.detectionStatus;
7973
const isDetectionInProgress =
80-
!!selectedAsset && !isTerminalDetectionStatus(selectedAssetDetectionStatus);
74+
!!selectedAsset &&
75+
isAssetActivelyAwaitingDetection(selectedAsset, store.encounterData);
8176

8277
const handleEnter = (text) => setTip((s) => ({ ...s, show: true, text }));
8378
const handleMove = (e) => {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Shared detection-status predicates used by Encounter.jsx polling loop and
2+
// ImageCard.jsx "Detection in progress" indicator. Keeping them here avoids
3+
// the two files drifting out of sync.
4+
5+
const POLL_INTERVAL_MS = 3000;
6+
const MAX_POLL_CYCLES = 100; // 100 cycles * 3s = ~5 minutes
7+
8+
const isAnnotationTrivial = (a) => a?.isTrivial === true || a?.trivial === true;
9+
10+
export const isTerminalDetectionStatus = (status) =>
11+
!status ||
12+
status === "complete" ||
13+
status === "error" ||
14+
status === "pending";
15+
16+
// True when an asset is from a bulk import and detection has been queued
17+
// to WBIA but the callback hasn't returned yet. In this state the API
18+
// returns detectionStatus=null and only a trivial placeholder annotation.
19+
// Scoped to encounters that have an importTaskId so we don't poll forever
20+
// on legacy/manual encounters that intentionally never run detection.
21+
export const isAwaitingBulkImportDetection = (asset, encounterData) => {
22+
if (!encounterData?.importTaskId) return false;
23+
if (asset?.detectionStatus !== null && asset?.detectionStatus !== undefined)
24+
return false;
25+
const anns = Array.isArray(asset?.annotations) ? asset.annotations : [];
26+
return anns.length > 0 && anns.every(isAnnotationTrivial);
27+
};
28+
29+
export const isAssetActivelyAwaitingDetection = (asset, encounterData) =>
30+
!isTerminalDetectionStatus(asset?.detectionStatus) ||
31+
isAwaitingBulkImportDetection(asset, encounterData);
32+
33+
export const shouldContinuePollingEncounter = (encounterData) => {
34+
const mediaAssets = Array.isArray(encounterData?.mediaAssets)
35+
? encounterData.mediaAssets
36+
: [];
37+
if (mediaAssets.length === 0) return false;
38+
return mediaAssets.some((asset) =>
39+
isAssetActivelyAwaitingDetection(asset, encounterData),
40+
);
41+
};
42+
43+
export { POLL_INTERVAL_MS, MAX_POLL_CYCLES };

frontend/src/pages/Encounter/stores/EncounterStore.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ class EncounterStore {
162162

163163
setMediaAssets(mediaAssets) {
164164
if (this._encounterData) {
165-
this._encounterData.mediaAssets = mediaAssets;
165+
this._encounterData = { ...this._encounterData, mediaAssets };
166166
}
167167
}
168168

0 commit comments

Comments
 (0)