feat(web): parité layout AppImage desktop + switch TUI naïf/CLI custom — #208

This commit is contained in:
2026-08-15 09:08:42 +02:00
parent 780f91a211
commit 7cdb97ae89
7 changed files with 914 additions and 661 deletions

View File

@ -211,4 +211,119 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
}));
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("routes structured chat through chat.* frames and HTTP close/cancel commands", async () => {
const calls: { url: string; init: unknown }[] = [];
const socket = new FakeSocket();
const ws = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
queueMicrotask(() => socket.onopen?.());
return socket;
},
});
const gw = new HttpAgentGateway(
new HttpInvoker({
baseUrl: "https://host",
fetchImpl: async (url, init) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
json: async () => undefined,
text: async () => "",
};
},
}),
ws,
);
const launched = gw.launchAgentChat("proj-1", "agent-1", {
cwd: "/srv/app",
nodeId: "node-1",
rows: 24,
cols: 80,
conversationId: "resume-9",
});
const launch = await replyToLast(socket, 0, (id) => ({
kind: "chat.attached",
replyTo: id,
payload: {
sessionId: "chat-1",
cellKind: "chat",
assignedConversationId: "conv-1",
},
}));
await expect(launched).resolves.toEqual({
sessionId: "chat-1",
cellKind: "chat",
assignedConversationId: "conv-1",
});
expect(launch.kind).toBe("agent.launch");
expect(launch.payload).toEqual({
projectId: "proj-1",
agentId: "agent-1",
nodeId: "node-1",
rows: 24,
cols: 80,
cellKind: "chat",
conversationId: "resume-9",
freshStart: false,
});
const reattached = gw.reattachAgentChat("chat-1", () => {});
const attach = await replyToLast(socket, 1, (id) => ({
kind: "chat.attached",
replyTo: id,
payload: {
sessionId: "chat-1",
scrollback: [{ kind: "final", content: "Recovered reply" }],
},
}));
await expect(reattached).resolves.toEqual({
sessionId: "chat-1",
scrollback: [{ kind: "final", content: "Recovered reply" }],
});
expect(attach.kind).toBe("chat.attach");
expect(attach.payload).toEqual({ sessionId: "chat-1" });
const chunks: unknown[] = [];
const sent = gw.sendAgentChat("chat-1", "Hello", (chunk) => chunks.push(chunk), {
attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }],
});
const chatSend = await replyToLast(socket, 2, (id) => ({
kind: "chat.output",
replyTo: id,
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "Hello back" },
},
}));
await sent;
expect(chatSend.kind).toBe("chat.send");
expect(chatSend.payload).toEqual({
sessionId: "chat-1",
prompt: "Hello",
attachments: [{ path: "/tmp/note.txt", filename: "note.txt" }],
});
expect(chunks).toEqual([{ kind: "final", content: "Hello back" }]);
await gw.cancelAgentChat("chat-1");
await gw.closeAgentChat("chat-1");
await gw.detachAgentChat("chat-1");
expect(calls).toHaveLength(2);
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
command: "cancel_agent_chat",
args: { sessionId: "chat-1" },
});
expect(JSON.parse((calls[1].init as { body: string }).body)).toEqual({
command: "close_agent_session",
args: { sessionId: "chat-1" },
});
expect(JSON.parse(socket.sent[3])).toMatchObject({
kind: "chat.detach",
payload: { sessionId: "chat-1" },
});
});
});

View File

@ -14,6 +14,8 @@
* survives JSON.
*/
import type { ReplyChunk } from "@/domain";
// ---------------------------------------------------------------------------
// Base64 helpers (binary PTY bytes over JSON frames)
// ---------------------------------------------------------------------------
@ -137,5 +139,18 @@ export interface StatusPayload {
/** Payload of a `chat.output` frame (structured assistant stream). */
export interface ChatOutputPayload {
sessionId: string;
chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller.
chunk: ReplyChunk;
}
/** Payload of a `chat.attached` acknowledgement. */
export interface ChatAttachedPayload {
sessionId?: string;
session?: {
sessionId: string;
projectId?: string;
nodeId?: string | null;
};
cellKind?: "chat" | "pty";
assignedConversationId?: string;
scrollback?: ReplyChunk[];
}

View File

@ -25,9 +25,11 @@ import type {
AppExitWorkGuardState,
DomainEvent,
EffortSelection,
ExecuteSlashCommandResult,
HealthReport,
ReplyChunk,
ResumableAgent,
SlashCommand,
Sprint,
TerminalSession,
Ticket,
@ -40,6 +42,7 @@ import type {
Unsubscribe,
} from "@/domain";
import type {
AgentChatHandle,
AgentGateway,
ChatDraftScope,
ChatDraftSnapshot,
@ -48,7 +51,9 @@ import type {
CreateTicketInput,
LiveAgent,
OpenTerminalOptions,
ReattachAgentChatResult,
ReattachResult,
SendAgentChatOptions,
StoppedLiveAgent,
SystemGateway,
TerminalGateway,
@ -59,7 +64,7 @@ import type {
} from "@/ports";
import type { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { type ChatOutputPayload } from "./frames";
import { type ChatAttachedPayload, type ChatOutputPayload } from "./frames";
import { unsupportedOnWeb } from "./unsupported";
/**
@ -96,6 +101,14 @@ export function makeWsTerminalHandle(
};
}
function chatSessionId(payload: ChatAttachedPayload): string {
const sessionId = payload.sessionId ?? payload.session?.sessionId;
if (!sessionId) {
throw { code: "INVALID_CHAT_ACK", message: "chat acknowledgement omitted sessionId" };
}
return sessionId;
}
export class HttpSystemGateway implements SystemGateway {
constructor(
private readonly http: HttpInvoker,
@ -306,6 +319,114 @@ export class HttpAgentGateway implements AgentGateway {
scrollback: res.scrollback,
};
}
async launchAgentChat(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle> {
const ack = await this.ws.send("agent.launch", {
projectId,
agentId,
nodeId: options.nodeId ?? null,
rows: options.rows,
cols: options.cols,
cellKind: "chat",
conversationId: options.conversationId ?? null,
freshStart: options.freshStart ?? false,
});
if (ack.kind !== "chat.attached") {
throw {
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
message: `custom CLI launch returned ${ack.kind}; expected chat.attached`,
};
}
const payload = ack.payload as unknown as ChatAttachedPayload;
if (payload.cellKind && payload.cellKind !== "chat") {
throw {
code: "STRUCTURED_ROUTED_TO_NON_CHAT",
message: `custom CLI launch returned cellKind=${payload.cellKind}; expected chat`,
};
}
const sessionId = chatSessionId(payload);
return {
sessionId,
cellKind: "chat",
...(payload.assignedConversationId
? { assignedConversationId: payload.assignedConversationId }
: {}),
};
}
async reattachAgentChat(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult> {
const ack = await this.ws.send("chat.attach", { sessionId });
if (ack.kind !== "chat.attached") {
throw {
code: "CHAT_ATTACH_FAILED",
message: `chat attach returned ${ack.kind}; expected chat.attached`,
};
}
const payload = ack.payload as unknown as ChatAttachedPayload;
const attachedSessionId = chatSessionId(payload);
this.ws.setChatSink(attachedSessionId, onChunk);
return {
sessionId: attachedSessionId,
scrollback: payload.scrollback ?? [],
};
}
async detachAgentChat(sessionId: string): Promise<void> {
this.ws.removeChatSink(sessionId);
await this.ws.sendFireAndForget("chat.detach", { sessionId });
}
async sendAgentChat(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
options: SendAgentChatOptions = {},
): Promise<void> {
this.ws.setChatSink(sessionId, onChunk);
const ack = await this.ws.send("chat.send", {
sessionId,
prompt,
attachments: options.attachments ?? [],
});
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
if (payload?.chunk) onChunk(payload.chunk);
}
listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
const request = prefix === undefined ? {} : { prefix };
return this.http
.invoke<{ commands: SlashCommand[] }>("list_slash_commands", { request })
.then((res) => res.commands);
}
executeSlashCommand(
name: string,
options: { sessionId?: string | null; arguments?: unknown[] } = {},
): Promise<ExecuteSlashCommandResult> {
return this.http.invoke<ExecuteSlashCommandResult>("execute_slash_command", {
request: {
name,
sessionId: options.sessionId ?? null,
arguments: options.arguments ?? [],
},
});
}
async closeAgentChat(sessionId: string): Promise<void> {
this.ws.removeChatSink(sessionId);
await this.http.invoke("close_agent_session", { sessionId });
}
async cancelAgentChat(sessionId: string): Promise<void> {
await this.http.invoke("cancel_agent_chat", { sessionId });
}
}
export class HttpTicketGateway implements TicketGateway {

View File

@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import type { WebSocketLike } from "./wsLiveClient";
import { WsLiveClient } from "./wsLiveClient";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
receive(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
}
function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
const socket = new FakeSocket();
const client = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
queueMicrotask(() => socket.onopen?.());
return socket;
},
});
return { client, socket };
}
describe("WsLiveClient structured chat routing", () => {
it("routes chat.output to the per-session chat sink", async () => {
const { client, socket } = connectedClient();
await client.ensureConnected();
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "done" },
},
});
expect(received).toEqual([{ kind: "final", content: "done" }]);
client.removeChatSink("chat-1");
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "ignored" },
},
});
expect(received).toEqual([{ kind: "final", content: "done" }]);
});
it("clears structured chat sinks on disconnect", async () => {
const { client, socket } = connectedClient();
await client.ensureConnected();
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
client.disconnect();
expect(client.getConnectionState()).toBe("closed");
socket.receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: { kind: "final", content: "dropped" },
},
});
expect(received).toEqual([]);
});
});

View File

@ -30,11 +30,12 @@
* output sink (a notice line), so no component needs to know about this client.
*/
import type { DomainEvent, Unsubscribe } from "@/domain";
import type { DomainEvent, ReplyChunk, Unsubscribe } from "@/domain";
import {
base64ToBytes,
bytesToBase64,
type AttachedPayload,
type ChatOutputPayload,
type ClientFrame,
type ClientFrameKind,
type OutputPayload,
@ -167,6 +168,8 @@ export class WsLiveClient {
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
/** Per-session structured chat sinks (sessionId → onChunk). */
private readonly chatSinks = new Map<string, (chunk: ReplyChunk) => void>();
/** Live terminal subscriptions tracked for reconnection replay + status. */
private readonly terminals = new Map<string, TerminalSubscription>();
/** Pending command acknowledgements, keyed by client frame id. */
@ -236,6 +239,12 @@ export class WsLiveClient {
removeOutputSink(sessionId: string): void {
this.outputSinks.delete(sessionId);
}
setChatSink(sessionId: string, onChunk: (chunk: ReplyChunk) => void): void {
this.chatSinks.set(sessionId, onChunk);
}
removeChatSink(sessionId: string): void {
this.chatSinks.delete(sessionId);
}
// -------------------------------------------------------------------------
// Connection lifecycle
@ -518,6 +527,7 @@ export class WsLiveClient {
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
this.outputSinks.clear();
this.chatSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
const socket = this.socket;
@ -544,6 +554,7 @@ export class WsLiveClient {
}
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
this.outputSinks.clear();
this.chatSinks.clear();
this.terminals.clear();
this.domainEventHandler = null;
this.socket?.close();
@ -565,27 +576,36 @@ export class WsLiveClient {
return; // Ignore malformed frames.
}
// Route unsolicited streams first (no replyTo).
switch (frame.kind) {
case "terminal.output": {
const payload = frame.payload as unknown as OutputPayload;
const sub = this.terminals.get(payload.sessionId);
if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq;
const sink = this.outputSinks.get(payload.sessionId);
if (sink) sink(base64ToBytes(payload.bytesBase64));
return;
// Route unsolicited streams first. Frames carrying `replyTo` are command
// acknowledgements and must fall through to the pending waiter below; in
// particular `chat.send` may acknowledge with a `chat.output` frame.
if (!frame.replyTo) {
switch (frame.kind) {
case "terminal.output": {
const payload = frame.payload as unknown as OutputPayload;
const sub = this.terminals.get(payload.sessionId);
if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq;
const sink = this.outputSinks.get(payload.sessionId);
if (sink) sink(base64ToBytes(payload.bytesBase64));
return;
}
case "terminal.status": {
const payload = frame.payload as unknown as StatusPayload;
this.handleStatus(payload);
return;
}
case "event.domain": {
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
return;
}
case "chat.output": {
const payload = frame.payload as unknown as ChatOutputPayload;
this.chatSinks.get(payload.sessionId)?.(payload.chunk);
return;
}
default:
break;
}
case "terminal.status": {
const payload = frame.payload as unknown as StatusPayload;
this.handleStatus(payload);
return;
}
case "event.domain": {
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
return;
}
default:
break;
}
// Otherwise it is a reply/ack correlated by `replyTo`.

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock";
import { WebSession } from "@/adapters/http";
import type { FetchLike } from "@/adapters/http/httpInvoker";
import type { FlagStore } from "@/adapters/http/webSession";
import { WebApp } from "./WebApp";
function memStore(): FlagStore {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
const okFetch: FetchLike = async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
async function seededGateways(): Promise<Gateways> {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Demo", "/srv/demo");
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, {
agents: [
{ agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] },
],
conversations: [],
});
return gateways;
}
function renderPairedWebApp(gateways: Gateways) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
beforeEach(() => {
window.localStorage.clear();
window.innerWidth = 1280;
window.dispatchEvent(new Event("resize"));
});
describe("WebWorkspace desktop layout", () => {
it("mounts the desktop AppImage-style shell with LayoutTabs/LayoutGrid and no detached action", async () => {
renderPairedWebApp(await seededGateways());
expect(
(await screen.findByTestId("web-workspace")).getAttribute("data-shell"),
).toBe(
"desktop-layout",
);
expect(screen.queryByTestId("web-mobile-task-canvas")).toBeNull();
fireEvent.click(await screen.findByText("Demo"));
expect(await screen.findByTestId("layout-tabs")).toBeTruthy();
expect(await screen.findByTestId("layout-grid")).toBeTruthy();
expect(screen.getByLabelText("right dock")).toBeTruthy();
expect(screen.queryByRole("button", { name: /detach/i })).toBeNull();
expect(screen.queryByText(/Fenêtre/)).toBeNull();
});
it("opens panels as floating or docked from the desktop menu bar without a detached web mode", async () => {
renderPairedWebApp(await seededGateways());
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("layout-grid");
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
const menu = await screen.findByRole("menu", { name: "Panneaux" });
fireEvent.click(within(menu).getByRole("button", { name: "Tickets" }));
const submenu = await screen.findByRole("menu", { name: "Tickets" });
fireEvent.click(within(submenu).getByRole("button", { name: "Flottant" }));
expect(await screen.findByRole("dialog", { name: "Tickets" })).toBeTruthy();
expect(screen.queryByRole("button", { name: /detached/i })).toBeNull();
});
it("can dock a panel to the left from the menu bar while keeping the grid in the main area", async () => {
renderPairedWebApp(await seededGateways());
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("layout-grid");
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
const menu = await screen.findByRole("menu", { name: "Panneaux" });
fireEvent.click(within(menu).getByRole("button", { name: "Project context" }));
const submenu = await screen.findByRole("menu", { name: "Project context" });
fireEvent.click(within(submenu).getByRole("button", { name: "Ancré à gauche" }));
expect(await screen.findByLabelText("left dock")).toBeTruthy();
expect(await screen.findByLabelText("Project context panel")).toBeTruthy();
expect(screen.getByTestId("layout-grid")).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff