P3-C: dashboard worktree-first + acceptance P3 (MVP complet)

Vue racine consolidée : repos → worktrees avec état git ET état de session
corrélé. Complète le MVP (P2 découverte/reprise + P3-A worktrees + P3-B états fins).

- web/lib/ws-client: abonnement multi-topics (sessions + worktrees), subscribeWorktrees.
- web/stores/worktrees: repos + worktrees, CRUD, temps réel (repo_update/worktree_*).
- web/views/DashboardView (route racine /), components RepoSection + WorktreeCard ;
  SessionStateBadge réutilisé pour l'état des sessions corrélées.
- router: / = dashboard, /sessions = liste à plat (sessions hors worktree), nav croisée.
- i18n EN/FR (dashboard/repos/worktrees).
- fix: @xterm/headless est CommonJS → chargé via createRequire (l'import nommé ESM
  échouait sous Node natif, masqué par esbuild en test) ; détecté par l'acceptation.
- scripts/acceptance-p3.mjs: repo git tmp → enregistrement, worktree + hook, broadcast
  WS worktree_update, corrélation session bash, suppression (409 sans force, 200 avec).

Vérifs : typecheck, 159 tests, build (vue-tsc), acceptations P1/P2/P3 ALL GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:46:45 +02:00
parent 01c6e617f2
commit e5b0b77986
13 changed files with 643 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ export interface TerminalSink {
}
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
export interface AttachOptions {
sessionId: string;
@@ -104,6 +105,7 @@ export class WsClient {
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
private awaitingAttached: Attachment[] = [];
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
connect(): void {
this.stopped = false;
@@ -143,13 +145,35 @@ export class WsClient {
return promise;
}
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
private activeTopics(): Array<'sessions' | 'worktrees'> {
const t: Array<'sessions' | 'worktrees'> = [];
if (this.sessionListeners.size > 0) t.push('sessions');
if (this.worktreeListeners.size > 0) t.push('worktrees');
return t;
}
private sendSub(): void {
this.sendControl({ type: 'sub', topics: this.activeTopics() });
}
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
this.sessionListeners.add(listener);
this.connect();
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
this.sendSub();
return () => {
this.sessionListeners.delete(listener);
if (this.sessionListeners.size === 0) this.sendControl({ type: 'sub', topics: [] });
this.sendSub();
};
}
subscribeWorktrees(listener: (e: WorktreeEvent) => void): () => void {
this.worktreeListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.worktreeListeners.delete(listener);
this.sendSub();
};
}
@@ -288,7 +312,7 @@ export class WsClient {
this.ready = true;
this.backoffMs = BACKOFF_MIN_MS;
this.status.value = 'open';
if (this.sessionListeners.size > 0) this.sendControl({ type: 'sub', topics: ['sessions'] });
this.sendSub();
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
for (const att of this.attachments) {
if (!att.closed) this.sendAttach(att);
@@ -339,6 +363,13 @@ export class WsClient {
for (const cb of this.sessionListeners) cb(msg);
return;
}
case 'repo_update':
case 'repo_removed':
case 'worktree_update':
case 'worktree_removed': {
for (const cb of this.worktreeListeners) cb(msg);
return;
}
case 'error': {
if (msg.channel !== undefined) {
console.warn(`[ws] channel ${msg.channel}: ${msg.code}${msg.message}`);