fix: recherche worktrees, sessions externes masquées, rendu/historique terminal & plein écran grille

- Recherche : le watcher débouncé du singleton useWorktreeView est créé dans un effectScope
  détaché → ne gèle plus après une navigation ; recherche aussi par label de dépôt ; accès à
  la palette depuis le MoreSheet mobile (sidebar/⌘K indisponibles sur mobile).
- Sessions : sessionsForCwd exclut désormais toujours les sessions masquées ; les sessions
  externes (discovered) sont masquées par défaut côté UI, avec puce de révélation par carte et
  case globale dans le dashboard.
- Terminal : ACK découplé du callback de rendu (armé à la réception + flush au retour de
  visibilité) → plus de pause PTY quand l'onglet passe en arrière-plan ; repaint forcé sur
  perte de contexte WebGL / resize / retour en avant-plan. Historique élargi : REPLAY_TAIL_BYTES
  1 Mo, RING_CAPACITY 4 Mo, scrollback xterm 20k.
- Grille de groupe : bouton « plein écran » sur chaque cellule ouvrant /sessions/:id.
This commit is contained in:
2026-06-23 17:42:49 +02:00
parent 5ebe3a0f37
commit 663ae7ace1
15 changed files with 192 additions and 30 deletions

View File

@@ -108,6 +108,7 @@ export class WsClient {
private socket: WebSocket | null = null;
private ready = false;
private stopped = true;
private visibilityBound = false;
private backoffMs = BACKOFF_MIN_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
@@ -121,6 +122,14 @@ export class WsClient {
connect(): void {
this.stopped = false;
// Au retour en avant-plan, le rendu se débloque : on flushe les ACK pour ne pas attendre le
// débounce si le serveur s'était mis en pause pendant que l'onglet/PWA était caché.
if (!this.visibilityBound && typeof document !== 'undefined') {
this.visibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.flushAcks();
});
}
if (this.socket || this.reconnectTimer) return;
if (this.status.value === 'idle') this.status.value = 'connecting';
this.openSocket();
@@ -301,6 +310,9 @@ export class WsClient {
att.lastAckBytes = 0;
att.sink.reset();
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
// filet anti-stall : le replay ne passe PAS de callback de write → on arme quand même l'ACK
// traînant pour que le flux suivant ne puisse pas rester gelé (serveur en pause).
this.scheduleTrailingAck(att, att.epoch);
return;
}
if (frame.type !== BINARY_FRAME.OUTPUT) return;
@@ -315,18 +327,43 @@ export class WsClient {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé,
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = setTimeout(() => {
att.trailingAckTimer = null;
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}, 200);
this.scheduleTrailingAck(att, epoch);
});
// Filet anti-stall, INDÉPENDANT du callback de xterm.write : ce callback est throttlé quand
// l'onglet/PWA passe en arrière-plan (rAF + setTimeout ralentis) → sans cela l'ACK ne partait
// plus, le serveur dépassait HIGH_WATERMARK et mettait le PTY en pause, et la sortie « chargeait
// par à-coups » jusqu'à ce que l'utilisateur bouge. On arme donc l'ACK traînant dès la réception.
this.scheduleTrailingAck(att, epoch);
}
/**
* (Ré)arme l'ACK traînant débouncé : si rien d'autre n'arrive, il envoie le reliquat d'octets
* réellement traités par xterm (`processedBytes`) pour que le serveur sorte toujours de pause.
* Appelé à la fois dans le callback de write ET à la réception d'une frame (le callback peut être
* indéfiniment retardé quand le rendu est gelé). Sémantique inchangée : on n'ACK que du traité.
*/
private scheduleTrailingAck(att: Attachment, epoch: number): void {
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = setTimeout(() => {
att.trailingAckTimer = null;
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}, 200);
}
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
private flushAcks(): void {
for (const att of this.attachments) {
if (att.closed || att.channel < 0) continue;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}
}
private handleServerMessage(msg: ServerMessage): void {