feat(agent_v1): toast paused supervisée Tkinter + Plan B + threshold FIND-TEXT 0.75
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-07 22:03:51 +02:00
parent 40440f1ca0
commit 7847a0e829
8 changed files with 551 additions and 3 deletions

View File

@@ -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("<Button-1>", _close)
for child in (outer, title_frame, body_frame, footer):
try:
child.bind("<Button-1>", _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"]