From 7847a0e829c82ac82542d895325a74fa408ce354 Mon Sep 17 00:00:00 2001 From: Dom Date: Thu, 7 May 2026 22:03:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent=5Fv1):=20toast=20paused=20supervis?= =?UTF-8?q?=C3=A9e=20Tkinter=20+=20Plan=20B=20+=20threshold=20FIND-TEXT=20?= =?UTF-8?q?0.75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Démo GHT 8 mai 2026 — Dom utilise UNIQUEMENT Léa V1 sur Windows pendant la démo (pas le frontend VWB Linux), donc les pause_message du serveur doivent être visuellement évidents sur l'écran Windows. Modifications client validées par Dom + redéployées via SCP (procédure 2026-04-28). 1. ui/paused_toast.py (NEW) — Toast Tkinter custom autonome : Toplevel topmost overrideredirect, fond bleu Léa (#2563EB), 380px, haut-droite, auto-close 15s, click-to-close. Re-pin -topmost à 100/500/2000 ms (Windows démet le flag quand le focus part). Rate limit 3s sur message identique. Aucune dépendance externe (tkinter stdlib uniquement). Thread-safe : root.after si Tk root existe, sinon Tk dédié dans un daemon thread. Remplace plyer qui s'avère silencieux sur Windows 11 (Focus Assist + manque app-id COM). 2. ui/chat_window.py — _add_paused_bubble force la visibilité : La fenêtre Léa démarrait avec root.withdraw() — la bulle paused était bien rendue mais invisible. Ajout deiconify+lift+focus_force avant render, plus appel à show_paused_toast en complément. 3. ui/notifications.py — niveau BLOCAGE déclenche aussi le toast : Quand notify_message reçoit un MessageUtilisateur.BLOCAGE (cible non trouvée, mode apprentissage, fenêtre incorrecte), appelle show_paused_toast en plus de plyer. Couvre la branche supervision client (executor.py:1012) qui ne passe pas par Plan B serveur. 4. core/executor.py — Plan B replay_paused (lignes 1812-1850) : Intercepte data["replay_paused"]=True dans la réponse /replay/next, appelle chat_window._add_paused_bubble si _chat_window_ref défini, sinon fallback notifier.notify. Idempotence via _last_pause_msg_shown pour ne pas spammer (1 toast par (replay_id, message) unique). Threshold FIND-TEXT _find_text_on_screen : 0.50 → 0.75 pour rejeter les faux positifs (placeholders italiques, tabs voisins) et tomber en mode apprentissage humain plutôt qu'un clic au pif. 5. main.py — Wiring ChatWindow → Executor pour Plan B. 6. tools/test_lea_toast.py + ui/_test_paused_toast.py (NEW) — Scripts de test isolé pour validation visuelle rapide sans relancer un replay complet (commande dans les docstrings). Validé visuellement sur DESKTOP-58D5CAC. Toasts apparaissent en haut- droite, fond bleu, auto-close 15s. Test isolé Dom : 3 toasts successifs visibles sans accroc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- agent_v0/agent_v1/core/executor.py | 66 ++++- agent_v0/agent_v1/main.py | 8 + agent_v0/agent_v1/tools/__init__.py | 0 agent_v0/agent_v1/tools/test_lea_toast.py | 87 +++++++ agent_v0/agent_v1/ui/_test_paused_toast.py | 53 ++++ agent_v0/agent_v1/ui/chat_window.py | 32 ++- agent_v0/agent_v1/ui/notifications.py | 18 ++ agent_v0/agent_v1/ui/paused_toast.py | 290 +++++++++++++++++++++ 8 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 agent_v0/agent_v1/tools/__init__.py create mode 100644 agent_v0/agent_v1/tools/test_lea_toast.py create mode 100644 agent_v0/agent_v1/ui/_test_paused_toast.py create mode 100644 agent_v0/agent_v1/ui/paused_toast.py diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py index 6105b85c9..147275f49 100644 --- a/agent_v0/agent_v1/core/executor.py +++ b/agent_v0/agent_v1/core/executor.py @@ -94,6 +94,11 @@ class ActionExecutorV1: # pause supervisée au serveur (`paused_need_help`). # Cf. core/system_dialog_guard.py self._system_dialog_pause: Optional[Dict[str, Any]] = None + # Référence à la ChatWindow Léa V1 (Tkinter) pour afficher les bulles + # paused interactives quand le serveur signale une pause supervisée. + # Câblée depuis main.py après instanciation des deux objets. + # Si None (mode headless / tests), fallback sur self.notifier. + self._chat_window_ref = None # Log de la resolution physique pour le diagnostic DPI self._log_screen_info() @@ -1796,6 +1801,65 @@ Example: x_pct=0.50, y_pct=0.30""" self._last_conn_error_logged = False data = resp.json() + + # Plan B (8 mai 2026 — démo GHT) : si le serveur signale une pause + # supervisée, afficher le pause_message dans la ChatWindow Léa V1 + # (Tkinter, déjà ouverte sur Windows) sous forme de bulle interactive + # avec boutons Continuer / Annuler. Permet à l'utilisateur Windows de + # voir physiquement ce que Léa attend (pause_for_human ou échec + # résolution). Fallback notifier.notify si la ChatWindow n'est pas + # câblée (mode headless / tests). + if data.get("replay_paused"): + pause_msg = data.get("pause_message") or "Léa a besoin de votre aide" + replay_id = data.get("replay_id") or "" + pause_key = (replay_id, pause_msg) + if getattr(self, "_last_pause_msg_shown", None) != pause_key: + self._last_pause_msg_shown = pause_key + completed = data.get("current_action_index", 0) + total = data.get("total_actions", "?") + payload = { + "replay_id": replay_id, + "workflow": "Replay en cours", + "reason": pause_msg, + "completed": completed, + "total": total, + } + # Toast Tkinter custom topmost — visible même si la + # ChatWindow est withdraw()-cachée par défaut. Sans dépendance + # plyer (Focus Assist Windows 11 filtre les balloons système). + try: + from ..ui.paused_toast import show_paused_toast + show_paused_toast( + title="Léa a besoin de votre aide", + message=pause_msg[:300], + ) + except Exception: + logger.debug("paused_toast launch silenced", exc_info=True) + + chat_window = getattr(self, "_chat_window_ref", None) + if chat_window is not None: + try: + # _add_paused_bubble est thread-safe (utilise root.after) + # et force l'affichage de la fenêtre + toast topmost + chat_window._add_paused_bubble(payload) + except Exception: + logger.debug( + "chat_window._add_paused_bubble pause silenced", + exc_info=True, + ) + else: + # Fallback notifier (tests headless / chat fermé) + try: + self.notifier.notify( + title="Léa — j'ai besoin de vous", + message=pause_msg[:300], + timeout=15, + bypass_rate_limit=True, + ) + except Exception: + logger.debug("notifier.notify pause silenced", exc_info=True) + return False + action = data.get("action") if action is None: return False @@ -2297,7 +2361,7 @@ Example: x_pct=0.50, y_pct=0.30""" best_match = None best_val = 0.0 - threshold = 0.50 # Seuil équilibré + threshold = 0.75 # Démo GHT 8 mai — éviter faux positifs (placeholders italiques, tabs voisins). En dessous, mieux vaut tomber en mode apprentissage humain qu'un clic au pif. # Essayer plusieurs tailles de police pour couvrir différentes résolutions for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]: diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index ef743aa5d..55ef5391b 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -116,6 +116,14 @@ class AgentV1: # Executeur pour le replay (doit exister avant le poll) self._executor = ActionExecutorV1() + # Wiring ChatWindow → Executor pour Plan B (pause_message → bulle interactive) + # Permet à l'executor d'afficher une bulle paused dans la fenêtre Léa V1 + # quand le serveur signale replay_paused=True via /replay/next. + try: + self._executor._chat_window_ref = self._chat_window + except Exception: + logger.debug("Wiring chat_window→executor échoué (non bloquant)", exc_info=True) + # Boucles permanentes (pas besoin de session active) self.running = True self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background")) diff --git a/agent_v0/agent_v1/tools/__init__.py b/agent_v0/agent_v1/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/tools/test_lea_toast.py b/agent_v0/agent_v1/tools/test_lea_toast.py new file mode 100644 index 000000000..706f818d7 --- /dev/null +++ b/agent_v0/agent_v1/tools/test_lea_toast.py @@ -0,0 +1,87 @@ +# agent_v1/tools/test_lea_toast.py +""" +Test visuel rapide du toast Léa (démo GHT 8 mai 2026). + +Lance trois scénarios de toast successifs pour valider l'affichage Windows : + 1. Toast simple « pause supervisée » + 2. Toast avec message long (vérifier wraplength) + 3. Toast type BLOCAGE (= ce que voit l'utilisateur quand Léa est perdue) + +Usage Windows : + C:\\rpa_vision\\.venv\\Scripts\\python.exe C:\\rpa_vision\\agent_v1\\tools\\test_lea_toast.py + +Le script s'attend à voir trois toasts successifs en haut-droite de l'écran +principal, espacés de ~6 s, fond bleu Léa, autodismiss après 15 s ou clic. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + + +def _bootstrap_path() -> None: + """Autoriser l'exécution directe sans -m : ajouter C:\\rpa_vision au sys.path.""" + here = Path(__file__).resolve() + # On remonte : tools -> agent_v1 -> rpa_vision (parent du package agent_v1) + rpa_root = here.parent.parent.parent + if str(rpa_root) not in sys.path: + sys.path.insert(0, str(rpa_root)) + + +def main() -> int: + _bootstrap_path() + + # Import après ajout du path (les deux variantes fonctionnent) + try: + from agent_v1.ui.paused_toast import show_paused_toast + except Exception as e: # pragma: no cover (debug only) + print(f"[TEST] ERREUR import agent_v1.ui.paused_toast : {e}") + return 1 + + scenarios = [ + ( + "Toast 1/3 : pause simple", + "Léa a besoin de votre aide", + "Test 1/3 — Pause supervisée. Cliquez sur 'Continuer' dans la chat.", + ), + ( + "Toast 2/3 : message long", + "Léa — j'attends votre validation", + ( + "Test 2/3 — J'ai trouvé 11 dossiers correspondant à vos critères " + "(UHCD, Forfait 1, PE2). Je vais traiter le dossier de M. DUPONT " + "Jean en premier. Pouvez-vous valider que c'est le bon ordre " + "avant que je continue ?" + ), + ), + ( + "Toast 3/3 : blocage cible non trouvée", + "Léa — je ne vois pas l'élément", + ( + "Test 3/3 — Je n'arrive pas à trouver « Examens cliniques » à " + "l'écran. Pouvez-vous me montrer où cliquer ?" + ), + ), + ] + + for label, title, message in scenarios: + print(f"[TEST] {label}") + ok = show_paused_toast(title=title, message=message) + print(f" show_paused_toast() = {ok}") + if not ok: + print(f" ECHEC : {label}") + # Espacer pour que Dom voit chaque toast distinctement + # (rate limit interne = 3s pour message identique, mais ici les + # messages diffèrent, le rate limit ne s'applique pas) + time.sleep(6) + + print("[TEST] Attente 12s supplémentaires pour laisser le dernier toast vivre...") + time.sleep(12) + print("[TEST] OK — fin du test. Si vous avez vu 3 toasts bleus en haut-droite,") + print(" le mécanisme Léa pause est validé.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_v0/agent_v1/ui/_test_paused_toast.py b/agent_v0/agent_v1/ui/_test_paused_toast.py new file mode 100644 index 000000000..e6936e8ba --- /dev/null +++ b/agent_v0/agent_v1/ui/_test_paused_toast.py @@ -0,0 +1,53 @@ +# agent_v1/ui/_test_paused_toast.py +""" +Test isolé du toast paused — à exécuter directement sur Windows. + +Usage (sur Windows, depuis C:\\rpa_vision\\agent_v1) : + python -m agent_v1.ui._test_paused_toast + +OU plus simple : + python C:\\rpa_vision\\agent_v1\\ui\\_test_paused_toast.py + +Le toast doit s'afficher en haut à droite de l'écran principal pendant ~15s. +""" +from __future__ import annotations + +import sys +import time + + +def main() -> int: + print("[TEST] Lancement du toast paused...") + + try: + # Import flexible : essai relatif puis absolu + try: + from .paused_toast import show_paused_toast + except ImportError: + from paused_toast import show_paused_toast + except Exception as e: + print(f"[TEST] ERREUR import : {e}") + return 1 + + ok = show_paused_toast( + title="Léa a besoin de votre aide", + message=( + "Test isolé — démo GHT 8 mai 2026.\n" + "Si vous voyez ce toast, le mécanisme de pause supervisée " + "fonctionne correctement." + ), + ) + print(f"[TEST] show_paused_toast() retour = {ok}") + + if not ok: + print("[TEST] ÉCHEC : toast non déclenché.") + return 2 + + print("[TEST] Toast déclenché. Attente de 18s pour le voir s'afficher puis se fermer...") + time.sleep(18) + print("[TEST] OK — fin du test.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_v0/agent_v1/ui/chat_window.py b/agent_v0/agent_v1/ui/chat_window.py index 3df9cbe58..59e6a7cc3 100644 --- a/agent_v0/agent_v1/ui/chat_window.py +++ b/agent_v0/agent_v1/ui/chat_window.py @@ -838,10 +838,38 @@ class ChatWindow: # ------------------------------------------------------------------ def _add_paused_bubble(self, payload: Dict[str, Any]) -> None: - """Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide).""" + """Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide). + + IMPORTANT (8 mai 2026, démo GHT) : par défaut la fenêtre démarre cachée + (`root.withdraw()`). Il FAUT la rendre visible et la forcer au premier + plan, sinon Dom ne voit jamais la bulle. On exécute dans le thread + tkinter via `root.after(0, ...)`. + """ if self._root is None: return - self._root.after(0, lambda: self._render_paused_bubble(payload)) + + def _show_and_render(): + try: + self._do_show() + # Re-pin topmost pour passer devant les apps actives + self._root.attributes("-topmost", True) + self._root.lift() + # Toast topmost en complément (visible même si la chat est + # masquée par une fenêtre d'app) + try: + from .paused_toast import show_paused_toast + reason = payload.get("reason") or "Action en attente." + show_paused_toast( + title="Léa a besoin de votre aide", + message=str(reason)[:300], + ) + except Exception: + logger.debug("paused_toast launch silenced", exc_info=True) + except Exception: + logger.debug("force-show chat_window silenced", exc_info=True) + self._render_paused_bubble(payload) + + self._root.after(0, _show_and_render) def _render_paused_bubble(self, payload: Dict[str, Any]) -> None: tk = self._tk diff --git a/agent_v0/agent_v1/ui/notifications.py b/agent_v0/agent_v1/ui/notifications.py index c6014e126..f7b3ece4d 100644 --- a/agent_v0/agent_v1/ui/notifications.py +++ b/agent_v0/agent_v1/ui/notifications.py @@ -139,10 +139,28 @@ class NotificationManager: Les messages BLOCAGE bypass le rate limit pour garantir que l'utilisateur voit qu'on a besoin de lui. + + Démo GHT 8 mai 2026 : pour les BLOCAGE, on déclenche en complément + un toast Tkinter custom topmost (paused_toast). Plyer est silencieux + sur Windows 11 quand Focus Assist / Quiet Hours / app-id manquante + bloquent les balloons. Le toast custom est 100 % autonome et garantit + que Dom voit le message en démo. """ bypass = msg.niveau == NiveauMessage.BLOCAGE # Log aussi pour tracer dans les logs fichiers self._log_message(msg) + + # Toast Tkinter custom — uniquement BLOCAGE pour ne pas spammer + if msg.niveau == NiveauMessage.BLOCAGE: + try: + from .paused_toast import show_paused_toast + show_paused_toast( + title=str(msg.titre)[:80] or "Léa a besoin de votre aide", + message=str(msg.corps)[:300], + ) + except Exception: + logger.debug("paused_toast (BLOCAGE) silenced", exc_info=True) + return self.notify( title=msg.titre, message=msg.corps, diff --git a/agent_v0/agent_v1/ui/paused_toast.py b/agent_v0/agent_v1/ui/paused_toast.py new file mode 100644 index 000000000..6469bc730 --- /dev/null +++ b/agent_v0/agent_v1/ui/paused_toast.py @@ -0,0 +1,290 @@ +# agent_v1/ui/paused_toast.py +""" +Toast Tkinter custom pour la pause supervisée (« Léa a besoin de votre aide »). + +Démo GHT 8 mai 2026 — Fallback robuste 100 % autonome quand : + - plyer.notification est silencieux sous Windows 11 (Focus Assist, balloon tips + bloqués par la stratégie système), + - la ChatWindow Léa V1 est `withdraw()`-cachée par défaut (Dom ne la voit pas), + - aucune autre UI ne peut garantir que Dom verra physiquement le message. + +Stratégie : + - Toplevel topmost overrideredirect en haut à droite de l'écran principal, + - fond bleu Léa, titre + message, auto-close après TOAST_DURATION_S, + - thread-safe : peut être appelé depuis n'importe quel thread (le polling + replay tourne dans un daemon thread, pas le thread principal), + - aucune dépendance externe (juste tkinter stdlib), + - rate limit interne pour éviter le flood (1 toast / 3s minimum). + +Si un Tk root existe déjà dans le process (ChatWindow), on attache le Toplevel +à ce root via `root.after(0, ...)` — c'est l'idiome thread-safe officiel de +tkinter. Sinon on crée un Tk() dédié dans un daemon thread. +""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# Couleurs cohérentes avec le thème Léa (cf. chat_window.py) +TOAST_BG = "#2563EB" # Bleu Léa (HEADER_BG) +TOAST_FG = "#FFFFFF" +TOAST_TITLE_BG = "#1E40AF" # Bleu plus foncé pour le bandeau titre +TOAST_BORDER = "#1E3A8A" + +TOAST_WIDTH = 380 +TOAST_PAD_X = 18 +TOAST_PAD_Y = 14 +TOAST_DURATION_MS = 15000 +TOAST_RATE_LIMIT_S = 3.0 + +_lock = threading.Lock() +_last_shown_at: float = 0.0 +_last_message: str = "" + + +def _resolve_existing_root() -> Optional[Any]: + """Tente de récupérer le Tk root déjà créé par la ChatWindow. + + On évite tk._default_root (deprecated) et on remonte plutôt via les + threads existants : la ChatWindow garde une référence dans son instance + mais n'expose rien de global. On se rabat donc sur la création d'un Tk + indépendant si on n'a rien — c'est sûr, tkinter supporte plusieurs Tk() + concurrents tant qu'ils sont chacun dans leur propre thread. + """ + try: + import tkinter as tk + # tk._default_root est interne mais c'est le moyen le plus simple + # de partager un mainloop existant. Si ChatWindow tourne, ce sera + # son root. + root = getattr(tk, "_default_root", None) + if root is not None: + # Vérifier qu'il est encore vivant + try: + root.winfo_exists() + return root + except Exception: + return None + return None + except Exception: + return None + + +def _build_toast(parent: Any, title: str, message: str) -> Any: + """Construit le Toplevel toast (appelé dans le thread tkinter).""" + import tkinter as tk + + top = tk.Toplevel(parent) + top.withdraw() # éviter le flash pendant la construction + top.overrideredirect(True) # pas de barre de titre + top.attributes("-topmost", True) + try: + # Petit boost de visibilité Windows : alpha légèrement transparent + top.attributes("-alpha", 0.97) + except Exception: + pass + + # Bordure visuelle (cadre extérieur foncé) + outer = tk.Frame(top, bg=TOAST_BORDER, padx=2, pady=2) + outer.pack(fill="both", expand=True) + + # Bandeau titre + title_frame = tk.Frame(outer, bg=TOAST_TITLE_BG) + title_frame.pack(fill="x") + tk.Label( + title_frame, + text=f" ⏸ {title}", + bg=TOAST_TITLE_BG, + fg=TOAST_FG, + font=("Segoe UI", 12, "bold"), + anchor="w", + padx=10, + pady=8, + ).pack(fill="x") + + # Corps du message + body_frame = tk.Frame(outer, bg=TOAST_BG) + body_frame.pack(fill="both", expand=True) + tk.Label( + body_frame, + text=message, + bg=TOAST_BG, + fg=TOAST_FG, + font=("Segoe UI", 11), + wraplength=TOAST_WIDTH - 40, + justify="left", + anchor="w", + padx=TOAST_PAD_X, + pady=TOAST_PAD_Y, + ).pack(fill="both", expand=True) + + # Pied de page : "Cliquez pour fermer" + footer = tk.Label( + outer, + text="Cliquez pour fermer", + bg=TOAST_BG, + fg="#BFDBFE", + font=("Segoe UI", 9, "italic"), + anchor="e", + padx=10, + pady=4, + ) + footer.pack(fill="x", side="bottom") + + # Position : haut-droite de l'écran principal + top.update_idletasks() + height = top.winfo_reqheight() + screen_w = top.winfo_screenwidth() + x = screen_w - TOAST_WIDTH - 16 + y = 16 + top.geometry(f"{TOAST_WIDTH}x{height}+{x}+{y}") + + # Click anywhere to close + def _close(_=None): + try: + top.destroy() + except Exception: + pass + + top.bind("", _close) + for child in (outer, title_frame, body_frame, footer): + try: + child.bind("", _close) + except Exception: + pass + + # Afficher + boost focus brut pour passer devant Focus Assist + top.deiconify() + top.lift() + try: + top.focus_force() + except Exception: + pass + + # Re-pin topmost après 100 ms (Windows désactive parfois -topmost + # quand le focus est pris par une autre app) + def _repin(): + try: + top.attributes("-topmost", True) + top.lift() + except Exception: + pass + + try: + top.after(100, _repin) + top.after(500, _repin) + top.after(2000, _repin) + except Exception: + pass + + # Auto-close + try: + top.after(TOAST_DURATION_MS, _close) + except Exception: + pass + + return top + + +def _show_in_dedicated_thread(title: str, message: str) -> None: + """Crée un Tk() indépendant dans un daemon thread. + + Utilisé en fallback quand aucun Tk root n'existe. Le thread vit le + temps du toast (~15s) puis se termine proprement. + """ + def _run(): + try: + # DPI awareness (Windows haute résolution) + try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + + import tkinter as tk + + root = tk.Tk() + root.withdraw() + try: + dpi = root.winfo_fpixels("1i") + root.tk.call("tk", "scaling", dpi / 72.0) + except Exception: + pass + + top = _build_toast(root, title, message) + + # Quitter mainloop quand le toast est détruit + def _watch(): + try: + if not top.winfo_exists(): + root.quit() + return + except Exception: + root.quit() + return + root.after(200, _watch) + + root.after(200, _watch) + root.mainloop() + try: + root.destroy() + except Exception: + pass + except Exception: + logger.debug("paused_toast dedicated thread failed", exc_info=True) + + t = threading.Thread(target=_run, daemon=True, name="paused-toast-tk") + t.start() + + +def show_paused_toast( + title: str = "Léa a besoin de votre aide", + message: str = "", +) -> bool: + """Affiche un toast paused topmost. + + Thread-safe, rate-limité, sans dépendance externe. Retourne True si le + toast a été déclenché, False s'il a été ignoré (rate limit ou erreur). + """ + global _last_shown_at, _last_message + + if not message: + message = "Action en attente de votre validation." + + # Rate limit basique : éviter qu'un poll en boucle ouvre 50 toasts + now = time.monotonic() + with _lock: + same_message = (message == _last_message) + elapsed = now - _last_shown_at + if same_message and elapsed < TOAST_RATE_LIMIT_S: + logger.debug( + "paused_toast rate-limited (%.1fs since last identical)", elapsed + ) + return False + _last_shown_at = now + _last_message = message + + # Tentative 1 : utiliser le Tk root existant (ChatWindow) via after() + root = _resolve_existing_root() + if root is not None: + try: + root.after(0, lambda: _build_toast(root, title, message)) + logger.info("paused_toast scheduled on existing Tk root") + return True + except Exception: + logger.debug("paused_toast existing-root path failed", exc_info=True) + + # Tentative 2 : créer un Tk() dans un daemon thread + try: + _show_in_dedicated_thread(title, message) + logger.info("paused_toast scheduled in dedicated thread") + return True + except Exception: + logger.error("paused_toast dedicated-thread path failed", exc_info=True) + return False + + +__all__ = ["show_paused_toast"]