diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html
index 4b9ee3a..64a8d08 100644
--- a/internal/jean/ui/index.html
+++ b/internal/jean/ui/index.html
@@ -895,11 +895,30 @@ function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorizatio
// Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
+// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
+// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
+// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS :
+// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
+// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
+// et ne sont donc jamais concernés.
+const API_TIMEOUT_MS = 30000;
async function jfetch(u, opts){
opts = opts || {};
opts.headers = authHeaders(opts.headers);
if(u.charAt(0) === '/') u = API_BASE + u;
- let r = await fetch(u, opts);
+ let timer=null;
+ if(!opts.signal){
+ const ac=new AbortController();
+ opts.signal=ac.signal;
+ timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS);
+ }
+ let r;
+ try{ r = await fetch(u, opts); }
+ catch(e){
+ if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); }
+ throw e;
+ }
+ finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
@@ -1861,8 +1880,25 @@ function handleDelta(d){
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
+// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé
+// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
+// au-delà, toute requête (journal, installation du moteur…) reste en file
+// d'attente sans jamais partir ni échouer — un blocage silencieux très
+// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
+// lastSeq rattrape tout ce qui s'est passé entre-temps.
+let streamPaused=false;
+document.addEventListener('visibilitychange', ()=>{
+ if(document.hidden){
+ streamPaused=true;
+ if(streamAbort) try{ streamAbort.abort(); }catch(e){}
+ } else if(streamPaused){
+ streamPaused=false;
+ }
+});
async function connectStream(){
while(true){
+ // Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
+ while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
diff --git a/internal/jean/ui/src/js/04-api.js b/internal/jean/ui/src/js/04-api.js
index 74796a2..46b8893 100644
--- a/internal/jean/ui/src/js/04-api.js
+++ b/internal/jean/ui/src/js/04-api.js
@@ -5,11 +5,30 @@ function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorizatio
// Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
+// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
+// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
+// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS :
+// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
+// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
+// et ne sont donc jamais concernés.
+const API_TIMEOUT_MS = 30000;
async function jfetch(u, opts){
opts = opts || {};
opts.headers = authHeaders(opts.headers);
if(u.charAt(0) === '/') u = API_BASE + u;
- let r = await fetch(u, opts);
+ let timer=null;
+ if(!opts.signal){
+ const ac=new AbortController();
+ opts.signal=ac.signal;
+ timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS);
+ }
+ let r;
+ try{ r = await fetch(u, opts); }
+ catch(e){
+ if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); }
+ throw e;
+ }
+ finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js
index 2181d67..7831639 100644
--- a/internal/jean/ui/src/js/09-stream.js
+++ b/internal/jean/ui/src/js/09-stream.js
@@ -96,8 +96,25 @@ function handleDelta(d){
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
+// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé
+// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
+// au-delà, toute requête (journal, installation du moteur…) reste en file
+// d'attente sans jamais partir ni échouer — un blocage silencieux très
+// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
+// lastSeq rattrape tout ce qui s'est passé entre-temps.
+let streamPaused=false;
+document.addEventListener('visibilitychange', ()=>{
+ if(document.hidden){
+ streamPaused=true;
+ if(streamAbort) try{ streamAbort.abort(); }catch(e){}
+ } else if(streamPaused){
+ streamPaused=false;
+ }
+});
async function connectStream(){
while(true){
+ // Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
+ while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});