diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx
index e3b66c10643..51d2bdc4f8c 100644
--- a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx
+++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx
@@ -22,6 +22,7 @@ import { AnnotatorModes } from './annotator-modes/annotator-modes-toggle.compone
import { PredictionInferenceDevices } from './annotator-modes/prediction-inference-devices.component';
import { PredictionModelSelector } from './annotator-modes/prediction-model-selector.component';
import { PredictionButtons } from './annotator-modes/predictions-buttons.component';
+import { useIsSubmitDisabled } from './use-is-submit-disabled.hook';
import { getNextItem } from './util';
import classes from './secondary-toolbar.module.scss';
@@ -95,7 +96,7 @@ export const SecondaryToolbar = ({
const { selectableModels } = usePredictionSetup();
const isPlaying = videoPlayerContext?.videoControls?.isPlaying ?? false;
- const { canSubmit, isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } =
+ const { isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } =
useAnnotationActions();
const handleSubmit = async () => {
@@ -121,8 +122,11 @@ export const SecondaryToolbar = ({
const isPredictionMode = mode === 'prediction';
const isAnnotationMode = mode === 'annotation';
- // If annotations are not changed but subset has changed we want to allow user to submit
- const isSubmitDisabled = (!canSubmit && !hasSubsetChanged) || isSaving || isLoadingPredictions;
+ const isSubmitDisabled = useIsSubmitDisabled({
+ mode,
+ hasSubsetChanged,
+ isLoadingPredictions,
+ });
useHotkeys(
HOTKEYS.submit,
diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx
new file mode 100644
index 00000000000..e5dfca322ba
--- /dev/null
+++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx
@@ -0,0 +1,119 @@
+// Copyright (C) 2026 Intel Corporation
+// SPDX-License-Identifier: Apache-2.0
+
+import { type ReactNode } from 'react';
+
+import { waitFor } from '@testing-library/react';
+import { getMockedShape } from 'mocks/mock-annotation';
+import { getMockedLabel } from 'mocks/mock-labels';
+import { getMockedMediaImage } from 'mocks/mock-media';
+import { getMockedProject } from 'mocks/mock-project';
+import { HttpResponse } from 'msw';
+
+import { http } from '../../../../api/utils';
+import type { AnnotationDTO, Label } from '../../../../constants/shared-types';
+import { server } from '../../../../msw-node-setup';
+import { AnnotationActionsProvider } from '../../../../shared/annotator/annotation-actions-provider.component';
+import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode';
+import { renderHook } from '../../../../test-utils/render';
+import { useIsSubmitDisabled } from './use-is-submit-disabled.hook';
+
+type RenderIsSubmitDisabledParams = {
+ mode?: AnnotatorMode;
+ hasSubsetChanged?: boolean;
+ isLoadingPredictions?: boolean;
+ initialAnnotationsDTO?: AnnotationDTO[];
+ initialPredictionsDTO?: AnnotationDTO[];
+ labels?: Label[];
+};
+
+const renderIsSubmitDisabled = ({
+ mode = 'annotation',
+ hasSubsetChanged = false,
+ isLoadingPredictions = false,
+ initialAnnotationsDTO = [],
+ initialPredictionsDTO = [],
+ labels = [],
+}: RenderIsSubmitDisabledParams) => {
+ server.use(
+ http.get('/api/projects/{project_id}', () =>
+ HttpResponse.json(getMockedProject({ task: { task_type: 'detection', exclusive_labels: false, labels } }))
+ )
+ );
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ return renderHook(() => useIsSubmitDisabled({ mode, hasSubsetChanged, isLoadingPredictions }), { wrapper });
+};
+
+describe('useIsSubmitDisabled', () => {
+ const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });
+
+ it('annotation mode: disabled when annotations and subset are both unchanged', async () => {
+ const annotationsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const { result } = renderIsSubmitDisabled({
+ mode: 'annotation',
+ labels: [label1],
+ initialAnnotationsDTO: annotationsDTO,
+ hasSubsetChanged: false,
+ });
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it('annotation mode: enabled when annotations are unchanged but subset changed', async () => {
+ const annotationsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const { result } = renderIsSubmitDisabled({
+ mode: 'annotation',
+ labels: [label1],
+ initialAnnotationsDTO: annotationsDTO,
+ hasSubsetChanged: true,
+ });
+
+ await waitFor(() => expect(result.current).toBe(false));
+ });
+
+ it('prediction mode: enabled when a prediction is present', async () => {
+ const predictionsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] },
+ ];
+
+ const { result } = renderIsSubmitDisabled({
+ mode: 'prediction',
+ labels: [label1],
+ initialPredictionsDTO: predictionsDTO,
+ });
+
+ await waitFor(() => expect(result.current).toBe(false));
+ });
+
+ it('prediction mode: disabled while loading predictions, even with a prediction present', async () => {
+ const predictionsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] },
+ ];
+
+ const { result } = renderIsSubmitDisabled({
+ mode: 'prediction',
+ labels: [label1],
+ initialPredictionsDTO: predictionsDTO,
+ isLoadingPredictions: true,
+ });
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+});
diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts
new file mode 100644
index 00000000000..37beed2f3a9
--- /dev/null
+++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts
@@ -0,0 +1,24 @@
+// Copyright (C) 2025-2026 Intel Corporation
+// SPDX-License-Identifier: Apache-2.0
+
+import { useAnnotationActions } from '../../../../shared/annotator/annotation-actions-provider.component';
+import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode';
+
+type UseIsSubmitDisabledParams = {
+ mode: AnnotatorMode;
+ hasSubsetChanged: boolean;
+ isLoadingPredictions: boolean;
+};
+
+export const useIsSubmitDisabled = ({
+ mode,
+ hasSubsetChanged,
+ isLoadingPredictions,
+}: UseIsSubmitDisabledParams): boolean => {
+ const { canSubmit, hasInvalidAnnotation, isSaving } = useAnnotationActions();
+
+ const isContentSubmittable =
+ mode === 'prediction' ? canSubmit : !hasInvalidAnnotation && (canSubmit || hasSubsetChanged);
+
+ return !isContentSubmittable || isSaving || isLoadingPredictions;
+};
diff --git a/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx b/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx
index 46f5c6330ac..e1d5d76de87 100644
--- a/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx
+++ b/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx
@@ -196,3 +196,54 @@ describe('Label normalization', () => {
});
});
});
+
+describe('canSubmit on initial load', () => {
+ it('is false when initial annotations are unchanged, including a null confidences field from the server', async () => {
+ const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });
+
+ // This is the exact shape the backend returns for a human annotation with no confidence
+ // scores: `confidences` is present and `null` (see DatasetItemAnnotation), never omitted.
+ const annotationsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const { result } = renderAnnotationActions({
+ mode: 'annotation',
+ labels: [label1],
+ initialAnnotationsDTO: annotationsDTO,
+ });
+
+ await waitFor(() => expect(result.current).not.toBeNull());
+
+ await waitFor(() => {
+ expect(result.current.annotations).toHaveLength(1);
+ expect(result.current.canSubmit).toBe(false);
+ });
+ });
+
+ it('is true once an unchanged annotation set is actually edited', async () => {
+ const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });
+ const label2 = getMockedLabel({ id: 'label-2', name: 'Dog', color: '#00FF00' });
+
+ const annotationsDTO: AnnotationDTO[] = [
+ { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const { result } = renderAnnotationActions({
+ mode: 'annotation',
+ labels: [label1, label2],
+ initialAnnotationsDTO: annotationsDTO,
+ });
+
+ await waitFor(() => expect(result.current.canSubmit).toBe(false));
+
+ act(() => {
+ result.current.addAnnotations([getMockedShape({ type: 'rectangle' })], [{ id: label2.id }]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.annotations).toHaveLength(2);
+ expect(result.current.canSubmit).toBe(true);
+ });
+ });
+});
diff --git a/application/ui/src/shared/annotator/annotation-mappers.test.ts b/application/ui/src/shared/annotator/annotation-mappers.test.ts
new file mode 100644
index 00000000000..651bb2faa39
--- /dev/null
+++ b/application/ui/src/shared/annotator/annotation-mappers.test.ts
@@ -0,0 +1,111 @@
+// Copyright (C) 2025-2026 Intel Corporation
+// SPDX-License-Identifier: Apache-2.0
+
+import { getMockedShape } from 'mocks/mock-annotation';
+import { getMockedAnnotationLabelRef } from 'mocks/mock-labels';
+
+import type { AnnotationDTO } from '../../constants/shared-types';
+import { mapLocalAnnotationsToServer, mapServerAnnotationsToLocal } from './annotation-mappers';
+
+describe('mapServerAnnotationsToLocal', () => {
+ it('maps a label with no confidence score to a ref without a probability', () => {
+ const dto: AnnotationDTO[] = [
+ { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const [annotation] = mapServerAnnotationsToLocal(dto);
+
+ expect(annotation.labels).toEqual([{ id: 'label-1' }]);
+ expect(annotation.labels[0]).not.toHaveProperty('probability');
+ });
+
+ it('maps a label with a confidence score to a ref with a matching probability', () => {
+ const dto: AnnotationDTO[] = [
+ { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.87] },
+ ];
+
+ const [annotation] = mapServerAnnotationsToLocal(dto);
+
+ expect(annotation.labels).toEqual([{ id: 'label-1', probability: 0.87 }]);
+ });
+});
+
+describe('mapLocalAnnotationsToServer', () => {
+ it('maps confidences to null when no label has a probability', () => {
+ const annotation = {
+ id: 'annotation-1',
+ shape: getMockedShape({ type: 'rectangle' }),
+ labels: [getMockedAnnotationLabelRef({ id: 'label-1' })],
+ };
+
+ const [result] = mapLocalAnnotationsToServer([annotation]);
+
+ expect(result).toHaveProperty('confidences', null);
+ });
+
+ it('maps confidences to an array of probabilities, in label order, when present', () => {
+ const annotation = {
+ id: 'annotation-1',
+ shape: getMockedShape({ type: 'rectangle' }),
+ labels: [
+ getMockedAnnotationLabelRef({ id: 'label-1', probability: 0.6 }),
+ getMockedAnnotationLabelRef({ id: 'label-2', probability: 0.4 }),
+ ],
+ };
+
+ const [result] = mapLocalAnnotationsToServer([annotation]);
+
+ expect(result.confidences).toEqual([0.6, 0.4]);
+ });
+
+ it('strips labels that are not in the provided valid id set', () => {
+ const annotation = {
+ id: 'annotation-1',
+ shape: getMockedShape({ type: 'rectangle' }),
+ labels: [
+ getMockedAnnotationLabelRef({ id: 'label-1' }),
+ getMockedAnnotationLabelRef({ id: 'deleted-label' }),
+ ],
+ };
+
+ const [result] = mapLocalAnnotationsToServer([annotation], new Set(['label-1']));
+
+ expect(result.labels).toEqual([{ id: 'label-1' }]);
+ });
+
+ it('keeps all labels when no valid id set is provided', () => {
+ const annotation = {
+ id: 'annotation-1',
+ shape: getMockedShape({ type: 'rectangle' }),
+ labels: [getMockedAnnotationLabelRef({ id: 'label-1' })],
+ };
+
+ const [result] = mapLocalAnnotationsToServer([annotation]);
+
+ expect(result.labels).toEqual([{ id: 'label-1' }]);
+ });
+});
+
+describe('round trip: mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto))', () => {
+ it('is deep-equal to a plain (non-prediction) server annotation, confidences included', () => {
+ // This is the exact shape the backend returns for a human annotation with no confidence
+ // scores: `confidences` is present and `null`, never omitted.
+ const dto: AnnotationDTO[] = [
+ { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
+ ];
+
+ const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto));
+
+ expect(roundTripped).toEqual(dto);
+ });
+
+ it('is deep-equal to a prediction-style server annotation, confidences included', () => {
+ const dto: AnnotationDTO[] = [
+ { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.95] },
+ ];
+
+ const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto));
+
+ expect(roundTripped).toEqual(dto);
+ });
+});
diff --git a/application/ui/src/shared/annotator/annotation-mappers.ts b/application/ui/src/shared/annotator/annotation-mappers.ts
index 9cb2d3ab655..40cf1596c72 100644
--- a/application/ui/src/shared/annotator/annotation-mappers.ts
+++ b/application/ui/src/shared/annotator/annotation-mappers.ts
@@ -37,14 +37,16 @@ export const mapLocalAnnotationsToServer = (
const hasProbabilities = filteredLabels.some((ref) => ref.probability !== undefined);
+ const confidences = hasProbabilities
+ ? filteredLabels
+ .filter((labelRef): labelRef is Required => labelRef.probability !== undefined)
+ .map((labelRef) => labelRef.probability)
+ : null;
+
return {
labels: filteredLabels.map(({ id }) => ({ id })),
shape: annotation.shape,
- ...(hasProbabilities && {
- confidences: filteredLabels
- .filter((labelRef): labelRef is Required => labelRef.probability !== undefined)
- .map((labelRef) => labelRef.probability),
- }),
+ confidences,
};
});
};