feat(frontend): xterm.js sur WebSocket avec reconnexion et scrollback (#13)

Lot F3 du chantier server/client mode : le client web branche xterm.js sur
le terminal distant via WebSocket, en face de l'endpoint PTY B5.

- wsLiveClient.ts : transport WebSocket du terminal avec reconnexion.
- streamGateways.ts : gateway terminal (open/attach/data/resize/close).
- frames.ts : frames PTY alignées sur le contrat serveur B5.
- Tests : terminalGateway.test.ts, wsLiveClientReconnect.test.ts.

Validé : frontend 743 tests verts (F3 ciblé 12), cohérence des frames
B5↔F3 confirmée, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:09:25 +02:00
parent 917be995a4
commit 7487902ddb
5 changed files with 685 additions and 61 deletions

View File

@ -93,12 +93,11 @@ export function makeWsTerminalHandle(
detach(): void {
// Stop delivering output locally and tell the server the view is gone. The
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
ws.removeOutputSink(sessionId);
void ws.sendFireAndForget("terminal.detach", { sessionId });
// Untracking also disables reconnection re-attach for this session.
void ws.detachTerminal(sessionId);
},
async close(): Promise<void> {
ws.removeOutputSink(sessionId);
await ws.send("terminal.close", { sessionId });
await ws.closeTerminalSession(sessionId);
},
};
}
@ -137,37 +136,33 @@ export class HttpTerminalGateway implements TerminalGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
const ack = await this.ws.send("terminal.open", {
projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract.
nodeId: options.nodeId ?? null,
// B5: `terminal.open` carries no projectId; `cwd` is a server-validated path.
// A fresh open has empty scrollback (the view does not repaint on open).
const res = await this.ws.openTerminal({
cwd: options.cwd,
rows: options.rows,
cols: options.cols,
nodeId: options.nodeId ?? null,
onData,
});
const payload = ack.payload as unknown as AttachedPayload;
const sessionId = payload.session.sessionId;
this.ws.setOutputSink(sessionId, onData);
const scrollback = attachedToScrollback(payload);
if (scrollback.length > 0) onData(scrollback);
return makeWsTerminalHandle(sessionId, this.ws);
return makeWsTerminalHandle(res.sessionId, this.ws);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
const payload = ack.payload as unknown as AttachedPayload;
this.ws.setOutputSink(sessionId, onData);
// The view repaints the returned scrollback itself (TerminalView contract);
// the client does not also push it through `onData` on the initial attach.
const res = await this.ws.attachTerminal({ sessionId, onData });
return {
handle: makeWsTerminalHandle(sessionId, this.ws),
scrollback: attachedToScrollback(payload),
handle: makeWsTerminalHandle(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
async closeTerminal(sessionId: string): Promise<void> {
this.ws.removeOutputSink(sessionId);
await this.ws.send("terminal.close", { sessionId });
await this.ws.closeTerminalSession(sessionId);
}
}