Repository: MSEP-one/msep.one
Pull request #215: Fix alerts panel issues
Author: login: HungryProton
Created: 2025-05-28T19:25:19Z | Merged: 2025-05-29T20:57:11Z

Fixes: Undo/Redo won't update Alert Panel messages about fixed issues

Also fixes:
+ After creating a group & atoms, and after running a validation about these atoms, undoing everything still shows the alert: clicking it triggers an assert.
+ My laptop doesn't display the unicode glyphs sometimes (despite the font being there, not sure why that happens). The alerts now uses actual icons instead.

---

Alerts panel now registers as a snapshotable system. The internal tree items are no longer exposed, but it generates and return an ID for each alert. You can mark these alerts as fixed or invalid by using this ID.

![image](https://github.com/user-attachments/assets/f8f6a230-1d39-494f-8acd-bd1a6fe243c7)


The Fixed and Invalid filter buttons only appear if relevant alerts are present. If not, they're hidden to limit clutter.

![image](https://github.com/user-attachments/assets/18478656-dd0f-452b-9da4-b9e6f15ab8c7)



Diff:
diff --git a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
index a88c68aa..335906e0 100644
--- a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
+++ b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
@@ -13,16 +13,9 @@ const MAX_SHAKE_ITERATIONS: int = 5
 
 
 var _thread: Thread
-var _overlaps: Dictionary = {
-#	overlap_data<OverlapData> = tree_item<TreeItem>
-}
-var _tree_items: Dictionary = {
-#	tree_item<TreeItem> = true<bool>
-}
-
-
+var _overlaps: Dictionary[OverlapData, int] = {} # Overlap data : Alert ID
+var _data: Dictionary[Metadata, int] = {} # Data : Alert ID
 var _workspace_context: WorkspaceContext = null
-var _overlaps_are_fixed: bool = false
 
 
 func set_workspace_context(in_workspace_context: WorkspaceContext) -> void:
@@ -34,7 +27,7 @@ func set_workspace_context(in_workspace_context: WorkspaceContext) -> void:
 
 
 func _on_history_changed() -> void:
-	_update_state_of_tree_items()
+	_update_alerts()
 
 
 func _validate_bonds_in_thread(
@@ -97,7 +90,7 @@ func validate_atomic_model(in_selection_only: bool) -> void:
 	_thread = Thread.new()
 	_thread.start(_validate_bonds_in_thread.bind(_workspace_context.get_visible_structure_contexts(), promise, in_selection_only))
 	
-	_workspace_context.start_async_work(_workspace_context.tr("Validating bonds ..."))
+	_workspace_context.start_async_work(_workspace_context.tr("Validating model ..."))
 	await promise.wait_for_fulfill()
 	_thread.wait_to_finish()
 	_workspace_context.end_async_work()
@@ -105,19 +98,17 @@ func validate_atomic_model(in_selection_only: bool) -> void:
 	
 	var validation_results: Array[Metadata] = promise.get_result()
 	_overlaps.clear()
-	_tree_items.clear()
-	_overlaps_are_fixed = false
+	_data.clear()
 	
 	for metadata: Metadata in validation_results:
-		var item: TreeItem
+		var alert_id: int
 		if metadata.alert_level == Metadata.AlertLevel.WARNING:
-			item = _workspace_context.push_warning_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
+			alert_id = _workspace_context.push_warning_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
 		else:
-			item = _workspace_context.push_error_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
-		_tree_items[item] = true
-		item.set_metadata(0, metadata)
+			alert_id = _workspace_context.push_error_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
+		_data[metadata] = alert_id
 		if metadata is OverlapData:
-			_overlaps[metadata] = item
+			_overlaps[metadata as OverlapData] = alert_id
 	
 	validation_finished.emit(not _overlaps.is_empty())
 
@@ -196,25 +187,21 @@ func fix_overlapping_atoms() -> void:
 		var new_positions: PackedVector3Array = PackedVector3Array(atoms_positions.values())
 		nano_structure.atoms_set_positions(atoms_id, new_positions)
 		
-		# Gray out the tree item for this overlap
-		var item: TreeItem = _overlaps[overlap]
-		var prefix: String = item.get_text(0).substr(0, 2) # This is the warning or error unicode character
-		item.set_text(0, prefix + "(Fixed) " + item.get_text(0))
-		item.set_custom_color(0, COLOR_DELETED)
-		item.set_icon_modulate(0, COLOR_DELETED)
+		# Update the alert for this overlap
 		overlap.is_fixed = true
+		var alert_id: int = _overlaps[overlap]
+		_workspace_context.mark_alert_as_fixed(alert_id)
 
 	for overlap: OverlapData in _overlaps:
 		var nano_structure: NanoStructure = overlap.structure_context.nano_structure
 		if nano_structure.is_being_edited():
 			nano_structure.end_edit()
 	
-	_overlaps_are_fixed = true
 	_workspace_context.snapshot_moment("Fix Overlapping Atoms Errors")
 
 
-func _on_tree_item_selected(in_selected_item: TreeItem, show_hidden: bool = false) -> void:
-	var metadata: Metadata = in_selected_item.get_metadata(0)
+func _on_alert_selected(in_alert_id: int, show_hidden: bool = false) -> void:
+	var metadata: Metadata = _data.find_key(in_alert_id)
 	if not metadata or metadata.has_invalid_atoms():
 		alert_selected.emit(false)
 		return
@@ -296,48 +283,26 @@ func _on_tree_item_selected(in_selected_item: TreeItem, show_hidden: bool = fals
 
 
 func _on_workspace_context_structure_contents_changed(_structure: StructureContext) -> void:
-	ScriptUtils.call_deferred_once(_update_state_of_tree_items)
+	ScriptUtils.call_deferred_once(_update_alerts)
 
 
 func _on_workspace_context_structure_about_to_remove(_in_struct: NanoStructure) -> void:
-	ScriptUtils.call_deferred_once(_update_state_of_tree_items)
+	ScriptUtils.call_deferred_once(_update_alerts)
 
 
-func _update_state_of_tree_items() -> void:
+func _update_alerts() -> void:
 	var is_outdated: bool = false
-	# The tree item may have been freed
-	# because of this we cast it to Variant first to avoid errors
-	for v_item: Variant in _tree_items.keys():
-		if not is_instance_valid(v_item):
-			continue
-		var item := v_item as TreeItem
-		var prefix: String = item.get_text(0).substr(0, 2) # This is the warning or error unicode character
-		var metadata: Metadata = item.get_metadata(0)
-		if metadata.has_invalid_atoms():
+	for data: Metadata in _data:
+		if data.has_invalid_atoms():
 			is_outdated = true
-			item.set_icon(0, DELETED_ICON)
-			item.set_text(0, prefix + "(Deleted) " + metadata.text)
-			item.set_custom_color(0, COLOR_DELETED)
-			item.set_icon_modulate(0, COLOR_DELETED)
-			item.set_selectable(0, false)
-			item.deselect(0)
-		elif metadata is OverlapData and metadata.is_fixed:
-			# Gray out the tree item for this overlap
-			item.set_text(0, prefix + "(Fixed) " + metadata.text)
-			item.set_custom_color(0, COLOR_DELETED)
-			item.set_icon_modulate(0, COLOR_DELETED)
-		else:
-			item.set_icon(0, null)
-			item.set_text(0, prefix + metadata.text)
-			item.clear_custom_color(0)
-			item.set_selectable(0, true)
+			var alert_id: int = _data[data]
+			_workspace_context.mark_alert_as_invalid(alert_id)
 	if is_outdated:
 		results_outdated.emit()
 
 
-func show_hidden_atoms(in_selected_alert: TreeItem) -> void:
-	if in_selected_alert:
-		_on_tree_item_selected(in_selected_alert, true)
+func show_hidden_atoms(in_selected_alert: int) -> void:
+	_on_alert_selected(in_selected_alert, true)
 
 
 class Metadata:
@@ -347,7 +312,8 @@ class Metadata:
 	}
 	var alert_level: AlertLevel = AlertLevel.WARNING
 	var text: String
-	
+	var is_fixed: bool
+
 	func has_invalid_atoms() -> bool:
 		return true
 
@@ -414,8 +380,7 @@ class OverlapData extends Metadata:
 	var atoms_id: PackedInt32Array
 	var structure_context: StructureContext
 	var other_structures: Array # Array[StructureContext]
-	var is_fixed: bool = false
-	
+
 	func _init(in_atoms: Array[AtomData], in_structure_context: StructureContext, 
 			in_other_structures: Array = []) -> void:
 		structure_context = in_structure_context
diff --git a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/openmm_failure_tracker.gd b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/openmm_failure_tracker.gd
index 69ed33ca..d0fd6cb7 100644
--- a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/openmm_failure_tracker.gd
+++ b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/openmm_failure_tracker.gd
@@ -11,9 +11,7 @@ const _TREE_COLUMN_0 = 0
 
 
 var _workspace_context: WorkspaceContext
-var _tree_item_to_error_metadata: Dictionary = {
-#	item<TreeItem> = meta<ErrorMetadata>
-}
+var _alert_id_to_error_metadata: Dictionary[int, ErrorMetadata] = {} # Alert id : Metadata
 
 
 func set_workspace_context(in_workspace_context: WorkspaceContext) -> void:
@@ -47,10 +45,8 @@ func process_relax_failure_alerts(in_request: RelaxRequest) -> void:
 	_process_openmm_errors(in_request.promise, in_request.original_payload)
 
 
-func _on_alert_item_selected(in_item: TreeItem) -> void:
-	if not is_instance_valid(in_item):
-		return
-	var meta: ErrorMetadata = _tree_item_to_error_metadata.get(in_item, null) as ErrorMetadata
+func _on_alert_selected(in_id: int) -> void:
+	var meta: ErrorMetadata = _alert_id_to_error_metadata.get(in_id, null) as ErrorMetadata
 	if meta == null:
 		return
 	meta.on_clicked(_workspace_context)
@@ -108,7 +104,7 @@ func _strip_bbcode(in_text :String) -> String:
 
 
 func _create_invalid_valence_item(in_original_payload: OpenMMPayload, in_openff_to_zmq_atom_id: Dictionary, in_line: String, in_extract_indices_callback: Callable = _extract_indices) -> void:
-	var item: TreeItem = _workspace_context.push_error_alert(in_line, _on_alert_item_selected)
+	var alert_id: int = _workspace_context.push_error_alert(in_line, _on_alert_selected)
 	var openmm_atom_ids: PackedInt32Array = in_extract_indices_callback.call(in_line, in_openff_to_zmq_atom_id)
 	assert(openmm_atom_ids.size() > 0)
 	const FIRST_ATOM = 0
@@ -127,7 +123,7 @@ func _create_invalid_valence_item(in_original_payload: OpenMMPayload, in_openff_
 		assert(structure_id == atom_data[STRUCTURE_ID_DATA])
 		msep_atom_ids.push_back(atom_data[MSEP_ATOM_ID_DATA])
 	
-	_tree_item_to_error_metadata[item] = InvalidValenceMetadata.new(structure_id, msep_atom_ids, [])
+	_alert_id_to_error_metadata[alert_id] = InvalidValenceMetadata.new(structure_id, msep_atom_ids, [])
 
 
 func _extract_indices(in_line: String, in_openff_to_zmq_atom_id: Dictionary) -> PackedInt32Array:
diff --git a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd
index 7df4d840..cdd101c4 100644
--- a/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd
+++ b/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd
@@ -119,7 +119,7 @@ func _on_only_selected_checkbox_toggled(_enabled: bool) -> void:
 
 
 func _on_invisible_selection_label_meta_clicked(_meta: Variant) -> void:
-	var selected_alert: TreeItem = _workspace_context.get_alert_selected()
+	var selected_alert: int = _workspace_context.get_alert_selected()
 	if selected_alert:
 		_atomic_structure_model_validator.show_hidden_atoms(selected_alert)
 
diff --git a/godot_project/editor/controls/workspace_view/WorkspaceMainView.gd b/godot_project/editor/controls/workspace_view/WorkspaceMainView.gd
index 1c6cdc0a..e52473aa 100644
--- a/godot_project/editor/controls/workspace_view/WorkspaceMainView.gd
+++ b/godot_project/editor/controls/workspace_view/WorkspaceMainView.gd
@@ -46,6 +46,7 @@ func _ready_deferred() -> void:
 		workspace_context.structure_about_to_remove.connect(_on_workspace_context_structure_about_to_remove.bind(workspace_context))
 	_structure_selector_bar.initialize(workspace_context)
 	_mode_selector.initialize(workspace_context)
+	_alerts_panel.initialize(workspace_context)
 
 
 func _on_window_size_changed(in_window: Window) -> void:
diff --git a/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd b/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
index 5ae96e74..b0401d36 100644
--- a/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
+++ b/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
@@ -1,32 +1,52 @@
 class_name AlertsPanel extends PanelContainer
 
+
+const ICONS: Dictionary[AlertLevel, Texture2D] = {
+	AlertLevel.WARNING: preload("res://editor/icons/icon_warning_x16.svg"),
+	AlertLevel.ERROR: preload("res://editor/icons/icon_error_x16.svg"),
+	AlertLevel.FIXED: preload("res://editor/icons/icon_valid_x16.svg"),
+	AlertLevel.INVALID: preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg"),
+}
+const COLOR_OUTDATED: Color = Color.WEB_GRAY
+
+enum AlertLevel {
+	WARNING,
+	ERROR,
+	FIXED,
+	INVALID,
+}
+
+var _fixed_mask_button: Button
 var _warnings_mask_button: Button
 var _errors_mask_button: Button
+var _invalid_mask_button: Button
 var _close_button: Button
 var _tree: Tree
-
-var _warnings_count: int = 0
-var _errors_count: int = 0
-var _callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
-var _activated_callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
+var _data: Dictionary[int, AlertData] = {} # AlertID : Data
+var _highest_id: int = 0
 
 
 func _notification(what: int) -> void:
 	if what == NOTIFICATION_SCENE_INSTANTIATED:
+		_fixed_mask_button = %FixedMaskButton as Button
 		_warnings_mask_button = %WarningsMaskButton as Button
 		_errors_mask_button = %ErrorsMaskButton as Button
+		_invalid_mask_button = %InvalidMaskButton as Button
 		_close_button = %CloseButton as Button
 		_tree = %Tree as Tree
+		_fixed_mask_button.toggled.connect(_on_fixed_mask_button_toggled)
 		_warnings_mask_button.toggled.connect(_on_warnings_mask_button_toggled)
 		_errors_mask_button.toggled.connect(_on_errors_mask_button_toggled)
+		_invalid_mask_button.toggled.connect(_on_invalid_mask_button_toggled)
 		_close_button.pressed.connect(_on_close_button_pressed)
 		_tree.item_selected.connect(_on_tree_item_selected)
 		_tree.item_activated.connect(_on_tree_item_activated)
 		clear_and_close()
+		hide()
+
+
+func initialize(workspace_context: WorkspaceContext) -> void:
+	workspace_context.register_snapshotable(self)
 
 
 ## Returns the number of alerts being displayed
@@ -34,56 +54,107 @@ func get_alerts_count() -> int:
 	return _tree.get_root().get_child_count()
 
 
-## Returns the selected TreeItem, if any
-func get_alert_selected() -> TreeItem:
-	return _tree.get_selected()
+## Returns the selected alert id, or 0 if nothing is selected.
+func get_alert_selected() -> int:
+	var selected: TreeItem = _tree.get_selected()
+	if not selected:
+		return 0
+	var alert_data: AlertData = selected.get_meta(&"alert_data")
+	return alert_data.id
 
 
-## Clears the content and hides the window
 func clear_and_close() -> void:
-	_callbacks = {}
-	_activated_callbacks = {}
+	clear_contents()
+	hide()
+
+
+func clear_contents() -> void:
+	_highest_id = 0
 	_tree.clear()
-	_warnings_count = 0
-	_errors_count = 0
+	_data.clear()
 	# Create Root
 	_tree.create_item(null)
 	_tree.hide_root = true
 	_warnings_mask_button.set_pressed_no_signal(false)
 	_errors_mask_button.set_pressed_no_signal(false)
 	ScriptUtils.call_deferred_once(_update_mask_buttons)
-	hide()
 
 
 ## Adds a Warning item to the Alerts list, [code]in_selected_callback[/code] should
-## take a [TreeItem] as  first argument
-func add_warning(in_text:String, in_selected_callback: Callable, in_activated_callback: Callable) -> TreeItem:
-	_warnings_count += 1
+## take the alert ID [int] as first argument
+func add_warning(in_text:String, in_selected_callback: Callable, in_activated_callback: Callable) -> int:
 	_warnings_mask_button.button_pressed = true
 	ScriptUtils.call_deferred_once(_update_mask_buttons)
-	return _add_item("⚠ " + in_text, in_selected_callback, in_activated_callback)
+	return _add_item(in_text, AlertLevel.WARNING, in_selected_callback, in_activated_callback)
 
 
 ## Adds an Error item to the Alerts list, [code]in_selected_callback[/code] should
-## take a [TreeItem] as  first argument
-func add_error(in_text:String, in_selected_callback: Callable, in_activated_callback: Callable) -> TreeItem:
-	_errors_count += 1
+## take the alert ID [int] as first argument
+func add_error(in_text:String, in_selected_callback: Callable, in_activated_callback: Callable) -> int:
 	_errors_mask_button.button_pressed = true
 	ScriptUtils.call_deferred_once(_update_mask_buttons)
-	return _add_item("⛔ " + in_text, in_selected_callback, in_activated_callback)
+	return _add_item(in_text, AlertLevel.ERROR, in_selected_callback, in_activated_callback)
+
+
+func mark_as_fixed(in_alert_id: int) -> void:
+	var alert: AlertData = _data.get(in_alert_id)
+	if not alert:
+		return
+	alert.level = AlertLevel.FIXED
+	alert.update_tree_item()
+	_update_mask_buttons()
+
+
+func mark_as_invalid(in_alert_id: int) -> void:
+	var alert: AlertData = _data.get(in_alert_id)
+	if not alert:
+		return
+	alert.level = AlertLevel.INVALID
+	alert.update_tree_item()
+	_invalid_mask_button.button_pressed = true
+	_update_mask_buttons()
+
+
+func create_state_snapshot() -> Dictionary:
+	var copy: Dictionary = {}
+	for alert_id: int in _data:
+		var data: AlertData = _data[alert_id]
+		copy[alert_id] = data.get_copy()
+	return {"data": copy}
+
+
+func apply_state_snapshot(in_snapshot: Dictionary) -> void:
+	clear_contents()
+	var snapshot_data: Dictionary = in_snapshot.get("data", {})
+	for alert_id: int in snapshot_data:
+		var alert_data: AlertData = snapshot_data[alert_id].get_copy()
+		_data[alert_id] = alert_data
+		alert_data.create_tree_item(_tree)
+		_highest_id = max(_highest_id, alert_id)
+	_update_mask_buttons()
+
+
+func _on_fixed_mask_button_toggled(in_button_pressed: bool) -> void:
+	_filter_contents(AlertLevel.FIXED, in_button_pressed)
 
 
 func _on_warnings_mask_button_toggled(in_button_pressed: bool) -> void:
-	_filter_contents("⚠ ", in_button_pressed)
+	_filter_contents(AlertLevel.WARNING, in_button_pressed)
+
 
 func _on_errors_mask_button_toggled(in_button_pressed: bool) -> void:
-	_filter_contents("⛔ ", in_button_pressed)
+	_filter_contents(AlertLevel.ERROR, in_button_pressed)
+
+
+func _on_invalid_mask_button_toggled(in_button_pressed: bool) -> void:
+	_filter_contents(AlertLevel.INVALID, in_button_pressed)
+
 
-func _filter_contents(in_prefix: String, in_visible: bool) -> void:
-	const COLUMN_0 = 0
-	var alert_items: Array[TreeItem] = _tree.get_root().get_children()
-	for item in alert_items:
-		if item.get_text(COLUMN_0).begins_with(in_prefix):
+func _filter_contents(in_type: AlertLevel, in_visible: bool) -> void:
+	var tree_items: Array[TreeItem] = _tree.get_root().get_children()
+	for item in tree_items:
+		var alert_data: AlertData = item.get_meta(&"alert_data")
+		if alert_data.level == in_type:
 			item.visible = in_visible
 
 
@@ -93,31 +164,86 @@ func _on_close_button_pressed() -> void:
 
 func _on_tree_item_selected() -> void:
 	var item: TreeItem = _tree.get_selected()
-	var callback: Callable = _callbacks.get(item, Callable()) as Callable
+	var alert_data: AlertData = item.get_meta(&"alert_data")
+	var callback: Callable = alert_data.selected_callback
 	if callback.is_valid():
-		callback.call(item)
+		callback.call(alert_data.id)
 
 
 func _on_tree_item_activated() -> void:
 	var item: TreeItem = _tree.get_selected()
-	var callback: Callable = _activated_callbacks.get(item, Callable()) as Callable
+	var alert_data: AlertData = item.get_meta(&"alert_data")
+	var callback: Callable = alert_data.activated_callback
 	if callback.is_valid():
-		callback.call(item)
+		callback.call(alert_data.id)
 
 
 func _update_mask_buttons() -> void:
-	_warnings_mask_button.text = "⚠ " + str(_warnings_count)
-	_warnings_mask_button.disabled = (_warnings_count == 0)
-	_errors_mask_button.text = "⛔ " + str(_errors_count)
-	_errors_mask_button.disabled = (_errors_count == 0)
-
-
-func _add_item(in_text: String, in_selected_callback: Callable, in_activated_callback: Callable) -> TreeItem:
-	var item: TreeItem = _tree.create_item(_tree.get_root())
-	const COLUMN_0 = 0
-	item.set_text(COLUMN_0, in_text)
-	if in_selected_callback.is_valid():
-		_callbacks[item] = in_selected_callback
-	if in_activated_callback.is_valid():
-		_activated_callbacks[item] = in_activated_callback
-	return item
+	var fixed_count: int = 0
+	var warnings_count: int = 0
+	var errors_count: int = 0
+	var invalid_count: int = 0
+	for alert: AlertData in _data.values():
+		match alert.level:
+			AlertLevel.WARNING:
+				warnings_count += 1
+			AlertLevel.ERROR:
+				errors_count += 1
+			AlertLevel.FIXED:
+				fixed_count += 1
+			AlertLevel.INVALID:
+				invalid_count += 1
+	
+	_warnings_mask_button.text = str(warnings_count)
+	_warnings_mask_button.disabled = (warnings_count == 0)
+	_errors_mask_button.text = str(errors_count)
+	_errors_mask_button.disabled = (errors_count == 0)
+	_fixed_mask_button.text = str(fixed_count)
+	_fixed_mask_button.visible = fixed_count > 0
+	_invalid_mask_button.text = str(invalid_count)
+	_invalid_mask_button.visible = invalid_count > 0
+
+
+func _add_item(in_text: String, in_level: AlertLevel, in_selected_callback: Callable, in_activated_callback: Callable) -> int:
+	_highest_id += 1
+	var alert_data := AlertData.new(_highest_id, in_text, in_level, in_selected_callback, in_activated_callback)
+	_data[alert_data.id] = alert_data
+	alert_data.create_tree_item(_tree)
+	return alert_data.id
+
+
+class AlertData:
+	var id: int
+	var level: AlertLevel
+	var text: String
+	var selected_callback: Callable
+	var activated_callback: Callable
+	var _tree_item: TreeItem
+	
+	func _init(in_id: int, in_text: String, in_level: AlertLevel, in_selected: Callable, in_activated: Callable) -> void:
+		id = in_id
+		text = in_text
+		level = in_level
+		selected_callback = in_selected
+		activated_callback = in_activated
+	
+	func create_tree_item(tree: Tree) -> TreeItem:
+		_tree_item = tree.create_item(tree.get_root())
+		_tree_item.set_meta(&"alert_data", self)
+		update_tree_item()
+		return _tree_item
+	
+	func update_tree_item() -> void:
+		if not _tree_item:
+			return
+		const COLUMN_0 = 0
+		_tree_item.set_text(COLUMN_0, text)
+		_tree_item.set_icon(COLUMN_0, ICONS[level])
+		if level == AlertLevel.INVALID:
+			_tree_item.set_custom_color(0, COLOR_OUTDATED)
+			_tree_item.set_icon_modulate(0, COLOR_OUTDATED)
+			_tree_item.set_selectable(0, false)
+			_tree_item.deselect(0)
+	
+	func get_copy() -> AlertData:
+		return AlertData.new(id, text, level, selected_callback, activated_callback)
diff --git a/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.tscn b/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.tscn
index af9301ca..deef74f3 100644
--- a/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.tscn
+++ b/godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.tscn
@@ -1,7 +1,11 @@
-[gd_scene load_steps=4 format=3 uid="uid://7sq87rc5vdf5"]
+[gd_scene load_steps=8 format=3 uid="uid://7sq87rc5vdf5"]
 
 [ext_resource type="Shader" uid="uid://ccp2v6p310iom" path="res://theme/blurry_panel.gdshader" id="1_15atc"]
 [ext_resource type="Script" uid="uid://cpwk11ttug7vh" path="res://editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd" id="2_se7m7"]
+[ext_resource type="Texture2D" uid="uid://scba4r5vafpw" path="res://editor/icons/icon_valid_x16.svg" id="3_wnu0n"]
+[ext_resource type="Texture2D" uid="uid://cbig8nto6f3e5" path="res://editor/icons/icon_warning_x16.svg" id="4_uf8kl"]
+[ext_resource type="Texture2D" uid="uid://1whl1wqkpom3" path="res://editor/icons/icon_error_x16.svg" id="5_7is5h"]
+[ext_resource type="Texture2D" uid="uid://j7w8vi2xwru3" path="res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg" id="6_uf8kl"]
 
 [sub_resource type="ShaderMaterial" id="ShaderMaterial_tuc5w"]
 shader = ExtResource("1_15atc")
@@ -25,25 +29,58 @@ size_flags_horizontal = 3
 theme_type_variation = &"HeaderSmall"
 text = "Alerts"
 
+[node name="FixedMaskButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+focus_mode = 0
+theme_override_colors/icon_pressed_color = Color(1, 1, 1, 1)
+theme_override_colors/font_color = Color(0.396889, 0.396889, 0.396889, 1)
+theme_override_colors/font_pressed_color = Color(0.41171, 0.597145, 1, 1)
+toggle_mode = true
+button_pressed = true
+text = "3"
+icon = ExtResource("3_wnu0n")
+flat = true
+
 [node name="WarningsMaskButton" type="Button" parent="VBoxContainer/HBoxContainer"]
 unique_name_in_owner = true
 layout_mode = 2
 size_flags_horizontal = 4
+focus_mode = 0
+theme_override_colors/icon_pressed_color = Color(1, 1, 1, 1)
 theme_override_colors/font_color = Color(0.396889, 0.396889, 0.396889, 1)
 theme_override_colors/font_pressed_color = Color(0.41171, 0.597145, 1, 1)
 toggle_mode = true
 button_pressed = true
-text = " ⚠3"
+text = "3"
+icon = ExtResource("4_uf8kl")
 flat = true
 
 [node name="ErrorsMaskButton" type="Button" parent="VBoxContainer/HBoxContainer"]
 unique_name_in_owner = true
 layout_mode = 2
 size_flags_horizontal = 4
+focus_mode = 0
+theme_override_colors/icon_pressed_color = Color(1, 1, 1, 1)
+theme_override_colors/font_color = Color(0.396889, 0.396889, 0.396889, 1)
+theme_override_colors/font_pressed_color = Color(0.41171, 0.597145, 1, 1)
+toggle_mode = true
+text = "1"
+icon = ExtResource("5_7is5h")
+flat = true
+
+[node name="InvalidMaskButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+focus_mode = 0
+theme_override_colors/icon_pressed_color = Color(1, 1, 1, 1)
 theme_override_colors/font_color = Color(0.396889, 0.396889, 0.396889, 1)
 theme_override_colors/font_pressed_color = Color(0.41171, 0.597145, 1, 1)
 toggle_mode = true
-text = "⛔ 1"
+text = "1"
+icon = ExtResource("6_uf8kl")
 flat = true
 
 [node name="CloseButton" type="Button" parent="VBoxContainer/HBoxContainer"]
diff --git a/godot_project/editor/icons/icon_error_x16.svg b/godot_project/editor/icons/icon_error_x16.svg
new file mode 100644
index 00000000..9687683d
--- /dev/null
+++ b/godot_project/editor/icons/icon_error_x16.svg
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   version="1.1"
+   id="Layer_1"
+   x="0px"
+   y="0px"
+   width="128"
+   height="128"
+   viewBox="0 0 128 128"
+   enable-background="new 0 0 122.879 122.879"
+   xml:space="preserve"
+   sodipodi:docname="red-x-icon.svg"
+   inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs1" /><sodipodi:namedview
+     id="namedview1"
+     pagecolor="#999999"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="1.7069638"
+     inkscape:cx="164.61978"
+     inkscape:cy="90.511586"
+     inkscape:window-width="2560"
+     inkscape:window-height="1048"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="Layer_1" /><g
+     id="g2"><ellipse
+       style="fill:#ffffff;stroke-width:12;stroke-linecap:round;stroke-linejoin:round"
+       id="path2"
+       cx="64.622726"
+       cy="67.211777"
+       rx="44.531689"
+       ry="33.450546" /><g
+       id="g1"
+       transform="translate(2.5605,2.561)"><path
+         fill-rule="evenodd"
+         clip-rule="evenodd"
+         fill="#ff4141"
+         d="m 61.44,0 c 33.933,0 61.439,27.507 61.439,61.439 0,33.932 -27.506,61.439 -61.439,61.439 C 27.507,122.879 0,95.372 0,61.439 0,27.506 27.507,0 61.44,0 Z m 12.011,39.151 c 2.75,-2.793 7.221,-2.805 9.986,-0.027 2.764,2.776 2.775,7.292 0.027,10.083 L 71.4,61.445 83.476,73.694 c 2.729,2.77 2.689,7.257 -0.08,10.022 -2.773,2.765 -7.23,2.758 -9.955,-0.013 L 61.446,71.54 49.428,83.728 c -2.75,2.793 -7.22,2.805 -9.986,0.027 -2.763,-2.776 -2.776,-7.293 -0.027,-10.084 L 51.48,61.434 39.403,49.185 c -2.728,-2.769 -2.689,-7.256 0.082,-10.022 2.772,-2.765 7.229,-2.758 9.953,0.013 l 11.997,12.165 z"
+         id="path1" /></g></g></svg>
diff --git a/godot_project/editor/icons/icon_error_x16.svg.import b/godot_project/editor/icons/icon_error_x16.svg.import
new file mode 100644
index 00000000..368006b6
--- /dev/null
+++ b/godot_project/editor/icons/icon_error_x16.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://1whl1wqkpom3"
+path="res://.godot/imported/icon_error_x16.svg-613cc09094f1c62249ce4efd982d003d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://editor/icons/icon_error_x16.svg"
+dest_files=["res://.godot/imported/icon_error_x16.svg-613cc09094f1c62249ce4efd982d003d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=16
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/godot_project/editor/icons/icon_valid_x16.svg b/godot_project/editor/icons/icon_valid_x16.svg
new file mode 100644
index 00000000..ceea0e90
--- /dev/null
+++ b/godot_project/editor/icons/icon_valid_x16.svg
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   id="Layer_1"
+   data-name="Layer 1"
+   viewBox="0 0 128 128"
+   version="1.1"
+   sodipodi:docname="confirm-icon.svg"
+   width="128"
+   height="128"
+   inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:dc="http://purl.org/dc/elements/1.1/">
+  <sodipodi:namedview
+     id="namedview2"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="2"
+     inkscape:cx="91.5"
+     inkscape:cy="130.25"
+     inkscape:window-width="2560"
+     inkscape:window-height="1048"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="Layer_1" />
+  <defs
+     id="defs1">
+    <style
+       id="style1">.cls-1{fill:#00a912;}.cls-1,.cls-2{fill-rule:evenodd;}.cls-2{fill:#fff;}</style>
+  </defs>
+  <title
+     id="title1">confirm</title>
+  <g
+     id="g2"
+     transform="translate(2.56,2.56)">
+    <path
+       class="cls-1"
+       d="M 61.44,0 A 61.44,61.44 0 1 1 0,61.44 61.44,61.44 0 0 1 61.44,0 Z"
+       id="path1" />
+    <path
+       class="cls-2"
+       d="M 42.37,51.68 53.26,62 79,35.87 c 2.13,-2.16 3.47,-3.9 6.1,-1.19 l 8.53,8.74 c 2.8,2.77 2.66,4.4 0,7 L 58.14,85.34 c -5.58,5.46 -4.61,5.79 -10.26,0.19 L 28,65.77 c -1.18,-1.28 -1.05,-2.57 0.24,-3.84 l 9.9,-10.27 c 1.5,-1.58 2.7,-1.44 4.22,0 z"
+       id="path2" />
+  </g>
+  <metadata
+     id="metadata2">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:title>confirm</dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+</svg>
diff --git a/godot_project/editor/icons/icon_valid_x16.svg.import b/godot_project/editor/icons/icon_valid_x16.svg.import
new file mode 100644
index 00000000..dccdfd15
--- /dev/null
+++ b/godot_project/editor/icons/icon_valid_x16.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://scba4r5vafpw"
+path="res://.godot/imported/icon_valid_x16.svg-54bb7ac040a2cbfd308cb09d688272f0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://editor/icons/icon_valid_x16.svg"
+dest_files=["res://.godot/imported/icon_valid_x16.svg-54bb7ac040a2cbfd308cb09d688272f0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=16
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/godot_project/editor/icons/icon_warning_x16.svg b/godot_project/editor/icons/icon_warning_x16.svg
new file mode 100644
index 00000000..be3e39fd
--- /dev/null
+++ b/godot_project/editor/icons/icon_warning_x16.svg
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   id="Layer_1"
+   data-name="Layer 1"
+   viewBox="0 0 128 128"
+   version="1.1"
+   sodipodi:docname="risk-icon.svg"
+   width="128"
+   height="128"
+   inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:dc="http://purl.org/dc/elements/1.1/">
+  <sodipodi:namedview
+     id="namedview3"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="2.6665885"
+     inkscape:cx="-3.375099"
+     inkscape:cy="84.56498"
+     inkscape:window-width="2560"
+     inkscape:window-height="1048"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="Layer_1" />
+  <defs
+     id="defs1">
+    <style
+       id="style1">.cls-1{fill:#fec901;fill-rule:evenodd;}.cls-2{fill:#010101;}</style>
+  </defs>
+  <title
+     id="title1">risk</title>
+  <g
+     id="g3"
+     transform="translate(2.6300632,8.3747914)">
+    <path
+       d="m 2.5,85 43,-74.41 v 0 a 22.59,22.59 0 0 1 8,-8.35 15.72,15.72 0 0 1 16,0 22.52,22.52 0 0 1 7.93,8.38 l 0.23,0.44 42.08,73.07 a 20.91,20.91 0 0 1 3,10.84 16.44,16.44 0 0 1 -1.74,7.43 15.45,15.45 0 0 1 -5.74,6 21,21 0 0 1 -11.35,2.78 v 0 H 17.7 c -0.21,0 -0.43,0 -0.64,0 A 19,19 0 0 1 9.23,109.44 15.83,15.83 0 0 1 2.58,103.72 16.26,16.26 0 0 1 0,95.18 21.66,21.66 0 0 1 2.2,85.56 c 0.1,-0.2 0.2,-0.4 0.31,-0.59 z"
+       id="path1" />
+    <path
+       class="cls-1"
+       d="m 9.09,88.78 43,-74.38 c 5.22,-8.94 13.49,-9.2 18.81,0 l 42.32,73.49 c 4.12,6.79 2.41,15.9 -9.31,15.72 H 17.7 C 9.78,103.79 5,97.44 9.09,88.78 Z"
+       id="path2" />
+    <path
+       class="cls-2"
+       d="m 57.55,83.15 a 5.47,5.47 0 0 1 5.85,-1.22 5.65,5.65 0 0 1 2,1.3 5.49,5.49 0 0 1 1.6,3.54 5.12,5.12 0 0 1 -0.08,1.4 5.22,5.22 0 0 1 -0.42,1.34 5.51,5.51 0 0 1 -5.2,3.25 5.63,5.63 0 0 1 -2.26,-0.53 5.51,5.51 0 0 1 -2.81,-2.92 6,6 0 0 1 -0.33,-1.31 5.28,5.28 0 0 1 0,-1.31 v 0 a 6,6 0 0 1 0.56,-2 4.6,4.6 0 0 1 1.14,-1.56 z m 8.12,-10.21 c -0.19,4.78 -8.28,4.78 -8.46,0 -0.82,-8.19 -2.92,-27.63 -2.85,-35.32 0.07,-2.37 2,-3.78 4.55,-4.31 a 11.65,11.65 0 0 1 2.48,-0.25 12.54,12.54 0 0 1 2.5,0.25 c 2.59,0.56 4.63,2 4.63,4.43 V 38 l -2.84,35 z"
+       id="path3" />
+  </g>
+  <metadata
+     id="metadata3">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:title>risk</dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+</svg>
diff --git a/godot_project/editor/icons/icon_warning_x16.svg.import b/godot_project/editor/icons/icon_warning_x16.svg.import
new file mode 100644
index 00000000..7ad2c4bc
--- /dev/null
+++ b/godot_project/editor/icons/icon_warning_x16.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cbig8nto6f3e5"
+path="res://.godot/imported/icon_warning_x16.svg-8175938f419bc68965dc556dfd687d60.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://editor/icons/icon_warning_x16.svg"
+dest_files=["res://.godot/imported/icon_warning_x16.svg-8175938f419bc68965dc556dfd687d60.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=16
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/godot_project/project_workspace/workspace_context/workspace_context.gd b/godot_project/project_workspace/workspace_context/workspace_context.gd
index 7270e1e6..d318148a 100644
--- a/godot_project/project_workspace/workspace_context/workspace_context.gd
+++ b/godot_project/project_workspace/workspace_context/workspace_context.gd
@@ -371,7 +371,7 @@ func get_alerts_count() -> int:
 
 
 ## Returns the selected TreeItem of the Alerts panel, if any
-func get_alert_selected() -> TreeItem:
+func get_alert_selected() -> int:
 	return workspace_main_view.get_alerts_panel().get_alert_selected()
 
 
@@ -380,7 +380,7 @@ func get_alert_selected() -> TreeItem:
 func push_warning_alert(
 		in_message: String,
 		in_selected_callback: Callable = Callable(),
-		in_activated_callback: Callable = Callable()) -> TreeItem:
+		in_activated_callback: Callable = Callable()) -> int:
 	return workspace_main_view.get_alerts_panel().add_warning(in_message, in_selected_callback, in_activated_callback)
 
 
@@ -389,7 +389,7 @@ func push_warning_alert(
 func push_error_alert(
 		in_message: String,
 		in_selected_callback: Callable = Callable(),
-		in_activated_callback: Callable = Callable()) -> TreeItem:
+		in_activated_callback: Callable = Callable()) -> int:
 	return workspace_main_view.get_alerts_panel().add_error(in_message, in_selected_callback, in_activated_callback)
 
 
@@ -404,6 +404,14 @@ func clear_alerts() -> void:
 	workspace_main_view.get_alerts_panel().clear_and_close()
 
 
+func mark_alert_as_fixed(in_alert_id: int) -> void:
+	workspace_main_view.get_alerts_panel().mark_as_fixed(in_alert_id)
+
+
+func mark_alert_as_invalid(in_alert_id: int) -> void:
+	workspace_main_view.get_alerts_panel().mark_as_invalid(in_alert_id)
+
+
 func start_async_work(
 			in_with_message: String = "",
 			in_cancel_callback: Callable = Callable(),


Reviews:
user: login: MarianoGnu
body: Awesome work, please address the ⚠️ comments
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T13:38:10Z
user: login: HungryProton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:32:48Z
user: login: HungryProton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:42:17Z
user: login: HungryProton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:43:00Z
user: login: HungryProton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:43:06Z
user: login: MarianoGnu
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T15:35:54Z

Review comments:
diff_hunk: @@ -13,16 +13,9 @@ const MAX_SHAKE_ITERATIONS: int = 5
 
 
 var _thread: Thread
-var _overlaps: Dictionary = {
-#	overlap_data<OverlapData> = tree_item<TreeItem>
-}
-var _tree_items: Dictionary = {
-#	tree_item<TreeItem> = true<bool>
-}
-
-
+var _overlaps: Dictionary[OverlapData, int] = {} # Overlap data : Alert ID
+var _data: Dictionary[Metadata, int] = {} # Data : Alert ID
path: godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
user: login: MarianoGnu
body: nit: i am not super convinced with the name of this variable, the "values" contained in the dictionary is the `_tree_items` IDs as advertised before, Data is the key
created_at: 2025-05-29T13:08:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,29 +89,28 @@ func validate_atomic_model(in_selection_only: bool) -> void:
 	_thread = Thread.new()
 	_thread.start(_validate_bonds_in_thread.bind(_workspace_context.get_visible_structure_contexts(), promise, in_selection_only))
 	
-	_workspace_context.start_async_work(_workspace_context.tr("Validating bonds ..."))
+	_workspace_context.start_async_work(_workspace_context.tr("Validating model ..."))
 	await promise.wait_for_fulfill()
 	_thread.wait_to_finish()
 	_workspace_context.end_async_work()
 	assert(not promise.has_error())
 	
 	var validation_results: Array[Metadata] = promise.get_result()
 	_overlaps.clear()
-	_tree_items.clear()
-	_overlaps_are_fixed = false
+	_data.clear()
 	
 	for metadata: Metadata in validation_results:
-		var item: TreeItem
+		var alert_id: int
 		if metadata.alert_level == Metadata.AlertLevel.WARNING:
-			item = _workspace_context.push_warning_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
+			alert_id = _workspace_context.push_warning_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
 		else:
-			item = _workspace_context.push_error_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
-		_tree_items[item] = true
-		item.set_metadata(0, metadata)
+			alert_id = _workspace_context.push_error_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
+		_data[metadata] = alert_id
 		if metadata is OverlapData:
-			_overlaps[metadata] = item
+			_overlaps[metadata as OverlapData] = alert_id
 	
 	validation_finished.emit(not _overlaps.is_empty())
+	_workspace_context.snapshot_moment("Validate model")
path: godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
user: login: MarianoGnu
body: This is needed only to have and before and after in the snapshots, correct?
created_at: 2025-05-29T13:12:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,7 +119,7 @@ func _on_only_selected_checkbox_toggled(_enabled: bool) -> void:
 
 
 func _on_invisible_selection_label_meta_clicked(_meta: Variant) -> void:
-	var selected_alert: TreeItem = _workspace_context.get_alert_selected()
+	var selected_alert: int = _workspace_context.get_alert_selected()
 	if selected_alert:
path: godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd
user: login: MarianoGnu
body: ⚠️  judging by [this line](https://github.com/MSEP-one/msep.one/blob/36e4b9c74b85bc7033fdbb2bc8659433d5f8a285/godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd#L72), _workspace_context.get_alert_selected() will return `-1` if there's nothing selected, meaning this if condition is wrong because `-1` wil evaluate to `true`, ~~and also the first item in the tree with id `0` will be ignored~~ (this assumption was wrong, the lowest id you get is 1, not 0)
created_at: 2025-05-29T13:19:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,89 +1,159 @@
 class_name AlertsPanel extends PanelContainer
 
+
+const ICON_WARNING := preload("res://editor/icons/icon_warning_x16.svg")
+const ICON_ERROR := preload("res://editor/icons/icon_error_x16.svg")
+const ICON_FIXED := preload("res://editor/icons/icon_valid_x16.svg")
+const ICON_DELETED: Texture2D = preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg")
path: godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
user: login: MarianoGnu
body: nit: This is matter of taste, but this could be compressed in a single constant:
```
const ICONS: Dictionary[AlertLevel, Texture2D] = {
    AlertLevel.WARNING = preload("res://editor/icons/icon_warning_x16.svg"),
    AlertLevel.ERROR = preload("res://editor/icons/icon_error_x16.svg"),
    AlertLevel.FIXED = preload("res://editor/icons/icon_valid_x16.svg"),
    AlertLevel.DELETED = preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg"),
    AlertLevel.INVALID = null,
}
```
simplifying the code where is used
`_tree_item.set_icon(0, ICONS[level])`
created_at: 2025-05-29T13:24:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,89 +1,159 @@
 class_name AlertsPanel extends PanelContainer
 
+
+const ICON_WARNING := preload("res://editor/icons/icon_warning_x16.svg")
+const ICON_ERROR := preload("res://editor/icons/icon_error_x16.svg")
+const ICON_FIXED := preload("res://editor/icons/icon_valid_x16.svg")
+const ICON_DELETED: Texture2D = preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg")
+
+const COLOR_OUTDATED: Color = Color.WEB_GRAY
+
+enum AlertLevel {
+	WARNING,
+	ERROR,
+	FIXED,
+	INVALID,
+}
+
+var _fixed_mask_button: Button
 var _warnings_mask_button: Button
 var _errors_mask_button: Button
+var _invalid_mask_button: Button
 var _close_button: Button
 var _tree: Tree
-
-var _warnings_count: int = 0
-var _errors_count: int = 0
-var _callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
-var _activated_callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
+var _data: Dictionary[int, AlertData] = {} # AlertID : Data
+var _highest_id: int = 0
 
 
 func _notification(what: int) -> void:
 	if what == NOTIFICATION_SCENE_INSTANTIATED:
+		_fixed_mask_button = %FixedMaskButton as Button
 		_warnings_mask_button = %WarningsMaskButton as Button
 		_errors_mask_button = %ErrorsMaskButton as Button
+		_invalid_mask_button = %InvalidMaskButton as Button
 		_close_button = %CloseButton as Button
 		_tree = %Tree as Tree
+		_fixed_mask_button.toggled.connect(_on_fixed_mask_button_toggled)
 		_warnings_mask_button.toggled.connect(_on_warnings_mask_button_toggled)
 		_errors_mask_button.toggled.connect(_on_errors_mask_button_toggled)
+		_invalid_mask_button.toggled.connect(_on_invalid_mask_button_toggled)
 		_close_button.pressed.connect(_on_close_button_pressed)
 		_tree.item_selected.connect(_on_tree_item_selected)
 		_tree.item_activated.connect(_on_tree_item_activated)
 		clear_and_close()
+		hide()
+
+
+func initialize(workspace_context: WorkspaceContext) -> void:
+	workspace_context.register_snapshotable(self)
 
 
 ## Returns the number of alerts being displayed
 func get_alerts_count() -> int:
 	return _tree.get_root().get_child_count()
 
 
-## Returns the selected TreeItem, if any
-func get_alert_selected() -> TreeItem:
-	return _tree.get_selected()
+## Returns the selected alert id, if any
+func get_alert_selected() -> int:
+	var selected: TreeItem = _tree.get_selected()
+	if not selected:
+		return -1
path: godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
user: login: MarianoGnu
body: ⚠️  in a previous comment i mentioned this function returns `-1` wich evaluates to `true`, however seeing `_highest_id` is set to `0` on clear, the lowest id we get is `1`
This means you could return 0 here instead, and fix usages of `get_alert_selected()`
I am fine with any of both ways
created_at: 2025-05-29T13:29:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,29 +89,28 @@ func validate_atomic_model(in_selection_only: bool) -> void:
 	_thread = Thread.new()
 	_thread.start(_validate_bonds_in_thread.bind(_workspace_context.get_visible_structure_contexts(), promise, in_selection_only))
 	
-	_workspace_context.start_async_work(_workspace_context.tr("Validating bonds ..."))
+	_workspace_context.start_async_work(_workspace_context.tr("Validating model ..."))
 	await promise.wait_for_fulfill()
 	_thread.wait_to_finish()
 	_workspace_context.end_async_work()
 	assert(not promise.has_error())
 	
 	var validation_results: Array[Metadata] = promise.get_result()
 	_overlaps.clear()
-	_tree_items.clear()
-	_overlaps_are_fixed = false
+	_data.clear()
 	
 	for metadata: Metadata in validation_results:
-		var item: TreeItem
+		var alert_id: int
 		if metadata.alert_level == Metadata.AlertLevel.WARNING:
-			item = _workspace_context.push_warning_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
+			alert_id = _workspace_context.push_warning_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
 		else:
-			item = _workspace_context.push_error_alert(metadata.text, _on_tree_item_selected, _on_tree_item_selected.bind(true))
-		_tree_items[item] = true
-		item.set_metadata(0, metadata)
+			alert_id = _workspace_context.push_error_alert(metadata.text, _on_alert_selected, _on_alert_selected.bind(true))
+		_data[metadata] = alert_id
 		if metadata is OverlapData:
-			_overlaps[metadata] = item
+			_overlaps[metadata as OverlapData] = alert_id
 	
 	validation_finished.emit(not _overlaps.is_empty())
+	_workspace_context.snapshot_moment("Validate model")
path: godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/atomic_structure_model_validator.gd
user: login: HungryProton
body: Yes. This one is optional, I was testing with a without to see which one was the less confusing but I think we should keep the current behavior for now and check with Jonathan later if it needs to be changed.
created_at: 2025-05-29T14:32:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,7 +119,7 @@ func _on_only_selected_checkbox_toggled(_enabled: bool) -> void:
 
 
 func _on_invisible_selection_label_meta_clicked(_meta: Variant) -> void:
-	var selected_alert: TreeItem = _workspace_context.get_alert_selected()
+	var selected_alert: int = _workspace_context.get_alert_selected()
 	if selected_alert:
path: godot_project/editor/controls/dockers/workspace_docker/simulations_docker/simulations_docker_controls/validate_bonds_panel.gd
user: login: HungryProton
body: Fixed :+1: 
created_at: 2025-05-29T14:42:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,89 +1,159 @@
 class_name AlertsPanel extends PanelContainer
 
+
+const ICON_WARNING := preload("res://editor/icons/icon_warning_x16.svg")
+const ICON_ERROR := preload("res://editor/icons/icon_error_x16.svg")
+const ICON_FIXED := preload("res://editor/icons/icon_valid_x16.svg")
+const ICON_DELETED: Texture2D = preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg")
path: godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
user: login: HungryProton
body: Thanks for the suggestion!
created_at: 2025-05-29T14:43:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,89 +1,159 @@
 class_name AlertsPanel extends PanelContainer
 
+
+const ICON_WARNING := preload("res://editor/icons/icon_warning_x16.svg")
+const ICON_ERROR := preload("res://editor/icons/icon_error_x16.svg")
+const ICON_FIXED := preload("res://editor/icons/icon_valid_x16.svg")
+const ICON_DELETED: Texture2D = preload("res://editor/controls/menu_bar/menu_edit/icons/icon_delete.svg")
+
+const COLOR_OUTDATED: Color = Color.WEB_GRAY
+
+enum AlertLevel {
+	WARNING,
+	ERROR,
+	FIXED,
+	INVALID,
+}
+
+var _fixed_mask_button: Button
 var _warnings_mask_button: Button
 var _errors_mask_button: Button
+var _invalid_mask_button: Button
 var _close_button: Button
 var _tree: Tree
-
-var _warnings_count: int = 0
-var _errors_count: int = 0
-var _callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
-var _activated_callbacks: Dictionary = {
-#	item<TreeItem> = callback<Callable>
-}
+var _data: Dictionary[int, AlertData] = {} # AlertID : Data
+var _highest_id: int = 0
 
 
 func _notification(what: int) -> void:
 	if what == NOTIFICATION_SCENE_INSTANTIATED:
+		_fixed_mask_button = %FixedMaskButton as Button
 		_warnings_mask_button = %WarningsMaskButton as Button
 		_errors_mask_button = %ErrorsMaskButton as Button
+		_invalid_mask_button = %InvalidMaskButton as Button
 		_close_button = %CloseButton as Button
 		_tree = %Tree as Tree
+		_fixed_mask_button.toggled.connect(_on_fixed_mask_button_toggled)
 		_warnings_mask_button.toggled.connect(_on_warnings_mask_button_toggled)
 		_errors_mask_button.toggled.connect(_on_errors_mask_button_toggled)
+		_invalid_mask_button.toggled.connect(_on_invalid_mask_button_toggled)
 		_close_button.pressed.connect(_on_close_button_pressed)
 		_tree.item_selected.connect(_on_tree_item_selected)
 		_tree.item_activated.connect(_on_tree_item_activated)
 		clear_and_close()
+		hide()
+
+
+func initialize(workspace_context: WorkspaceContext) -> void:
+	workspace_context.register_snapshotable(self)
 
 
 ## Returns the number of alerts being displayed
 func get_alerts_count() -> int:
 	return _tree.get_root().get_child_count()
 
 
-## Returns the selected TreeItem, if any
-func get_alert_selected() -> TreeItem:
-	return _tree.get_selected()
+## Returns the selected alert id, if any
+func get_alert_selected() -> int:
+	var selected: TreeItem = _tree.get_selected()
+	if not selected:
+		return -1
path: godot_project/editor/controls/workspace_view/controls/alerts_panel/alerts_panel.gd
user: login: HungryProton
body: fixed
created_at: 2025-05-29T14:43:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: MarianoGnu
created_at: 2025-05-29T13:06:12Z
body: > The Fixed and Invalid filter buttons only appear if relevant alerts are present. If not, they're hidden to limit clutter.

To consider: Should **Warning** and **Error** have the same treatment?
author_association: CONTRIBUTOR
user: login: HungryProton
created_at: 2025-05-29T13:10:23Z
body: > > The Fixed and Invalid filter buttons only appear if relevant alerts are present. If not, they're hidden to limit clutter.
> 
> To consider: Should **Warning** and **Error** have the same treatment?

I think it's fine, but be could do it. However if there's only one filter button visible, maybe it should be disabled, otherwise if the user click it, they just end up with an empty alert panel, and I find it confusing. 
author_association: CONTRIBUTOR