@@ -56,12 +56,16 @@ def control(self) -> "SingleClickDataTable":
5656 return self .data_table
5757
5858 async def _on_click (self , event : events .Click ) -> None : # type: ignore[override]
59- """Forward to parent, then post RowClicked for single-click detection."""
59+ """Forward to parent, then post RowClicked on every mouse click.
60+
61+ The hub table is handled exclusively via RowClicked. RowSelected is
62+ intentionally NOT used for the hub table to avoid double-dispatch.
63+ """
6064 await super ()._on_click (event )
6165 meta = event .style .meta
62- if "row" in meta and self .cursor_type == "row" :
63- row_index : int = meta ["row" ]
64- if row_index >= 0 : # skip header row
66+ if meta and "row" in meta and self .cursor_type == "row" :
67+ row_index : int = int ( meta ["row" ])
68+ if row_index >= 0 :
6569 self .post_message (SingleClickDataTable .RowClicked (self , row_index ))
6670
6771
@@ -371,11 +375,14 @@ def _refresh_hub(self) -> None:
371375 self ._hub_rows .append ((name , image , hub_name , is_ready ))
372376
373377 def on_data_table_row_selected (self , event : DataTable .RowSelected ) -> None :
374- """Handle Enter-key row selection on the agents table."""
378+ """Handle Enter-key row selection (agents table only).
379+
380+ Hub table uses RowClicked exclusively — wiring it to RowSelected too
381+ would cause a double push on every click since Textual 8 fires
382+ RowSelected on ALL clicks, not just second-click-on-same-row.
383+ """
375384 if event .data_table .id == "agents-table" :
376385 self ._handle_agent_row (event .cursor_row )
377- elif event .data_table .id == "hub-table" :
378- self ._handle_hub_row (event .cursor_row )
379386
380387 def on_single_click_data_table_row_clicked (
381388 self , event : SingleClickDataTable .RowClicked
@@ -408,6 +415,10 @@ def _handle_agent_row(self, idx: int) -> None:
408415
409416 def _handle_hub_row (self , idx : int ) -> None :
410417 """Handle a click on a hub table row."""
418+ # Guard: never push two build dialogs at once (double-click protection)
419+ if getattr (self , "_build_dialog_open" , False ):
420+ return
421+
411422 if idx < 0 or idx >= len (self ._hub_rows ):
412423 return
413424 row_data = self ._hub_rows [idx ]
@@ -419,7 +430,11 @@ def _handle_hub_row(self, idx: int) -> None:
419430 # If a build is already running, open the live log viewer
420431 if image in self ._active_builds :
421432 from fuzzforge_cli .tui .screens .build_log import BuildLogScreen
422- self .push_screen (BuildLogScreen (image ))
433+ self ._build_dialog_open = True
434+ self .push_screen (
435+ BuildLogScreen (image ),
436+ callback = lambda _ : setattr (self , "_build_dialog_open" , False ),
437+ )
423438 return
424439
425440 if is_ready :
@@ -432,10 +447,15 @@ def _handle_hub_row(self, idx: int) -> None:
432447
433448 from fuzzforge_cli .tui .screens .build_image import BuildImageScreen
434449
450+ self ._build_dialog_open = True
451+
452+ def _on_build_dialog_done (confirmed : bool , sn : str = server_name , im : str = image , hn : str = hub_name ) -> None :
453+ self ._build_dialog_open = False
454+ self ._on_build_confirmed (confirmed , sn , im , hn )
455+
435456 self .push_screen (
436457 BuildImageScreen (server_name , image , hub_name ),
437- callback = lambda confirmed , sn = server_name , im = image , hn = hub_name :
438- self ._on_build_confirmed (confirmed , sn , im , hn ),
458+ callback = _on_build_dialog_done ,
439459 )
440460
441461 def _on_build_confirmed (self , confirmed : bool , server_name : str , image : str , hub_name : str ) -> None :
0 commit comments