feat(web): parité layout AppImage desktop + switch TUI naïf/CLI custom — #208
This commit is contained in:
@ -211,4 +211,119 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
|||||||
}));
|
}));
|
||||||
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
* survives JSON.
|
* survives JSON.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReplyChunk } from "@/domain";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Base64 helpers (binary PTY bytes over JSON frames)
|
// Base64 helpers (binary PTY bytes over JSON frames)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -137,5 +139,18 @@ export interface StatusPayload {
|
|||||||
/** Payload of a `chat.output` frame (structured assistant stream). */
|
/** Payload of a `chat.output` frame (structured assistant stream). */
|
||||||
export interface ChatOutputPayload {
|
export interface ChatOutputPayload {
|
||||||
sessionId: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,9 +25,11 @@ import type {
|
|||||||
AppExitWorkGuardState,
|
AppExitWorkGuardState,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EffortSelection,
|
EffortSelection,
|
||||||
|
ExecuteSlashCommandResult,
|
||||||
HealthReport,
|
HealthReport,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
|
SlashCommand,
|
||||||
Sprint,
|
Sprint,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
@ -40,6 +42,7 @@ import type {
|
|||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
|
AgentChatHandle,
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
ChatDraftScope,
|
ChatDraftScope,
|
||||||
ChatDraftSnapshot,
|
ChatDraftSnapshot,
|
||||||
@ -48,7 +51,9 @@ import type {
|
|||||||
CreateTicketInput,
|
CreateTicketInput,
|
||||||
LiveAgent,
|
LiveAgent,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
|
ReattachAgentChatResult,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
|
SendAgentChatOptions,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
@ -59,7 +64,7 @@ import type {
|
|||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import type { HttpInvoker } from "./httpInvoker";
|
import type { HttpInvoker } from "./httpInvoker";
|
||||||
import { WsLiveClient } from "./wsLiveClient";
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
import { type ChatOutputPayload } from "./frames";
|
import { type ChatAttachedPayload, type ChatOutputPayload } from "./frames";
|
||||||
import { unsupportedOnWeb } from "./unsupported";
|
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 {
|
export class HttpSystemGateway implements SystemGateway {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly http: HttpInvoker,
|
private readonly http: HttpInvoker,
|
||||||
@ -306,6 +319,114 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
scrollback: res.scrollback,
|
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 {
|
export class HttpTicketGateway implements TicketGateway {
|
||||||
|
|||||||
83
frontend/src/adapters/http/wsLiveClient.chat.test.ts
Normal file
83
frontend/src/adapters/http/wsLiveClient.chat.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -30,11 +30,12 @@
|
|||||||
* output sink (a notice line), so no component needs to know about this client.
|
* 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 {
|
import {
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
type AttachedPayload,
|
type AttachedPayload,
|
||||||
|
type ChatOutputPayload,
|
||||||
type ClientFrame,
|
type ClientFrame,
|
||||||
type ClientFrameKind,
|
type ClientFrameKind,
|
||||||
type OutputPayload,
|
type OutputPayload,
|
||||||
@ -167,6 +168,8 @@ export class WsLiveClient {
|
|||||||
|
|
||||||
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
|
/** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */
|
||||||
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
|
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. */
|
/** Live terminal subscriptions tracked for reconnection replay + status. */
|
||||||
private readonly terminals = new Map<string, TerminalSubscription>();
|
private readonly terminals = new Map<string, TerminalSubscription>();
|
||||||
/** Pending command acknowledgements, keyed by client frame id. */
|
/** Pending command acknowledgements, keyed by client frame id. */
|
||||||
@ -236,6 +239,12 @@ export class WsLiveClient {
|
|||||||
removeOutputSink(sessionId: string): void {
|
removeOutputSink(sessionId: string): void {
|
||||||
this.outputSinks.delete(sessionId);
|
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
|
// Connection lifecycle
|
||||||
@ -518,6 +527,7 @@ export class WsLiveClient {
|
|||||||
}
|
}
|
||||||
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
|
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
|
||||||
this.outputSinks.clear();
|
this.outputSinks.clear();
|
||||||
|
this.chatSinks.clear();
|
||||||
this.terminals.clear();
|
this.terminals.clear();
|
||||||
this.domainEventHandler = null;
|
this.domainEventHandler = null;
|
||||||
const socket = this.socket;
|
const socket = this.socket;
|
||||||
@ -544,6 +554,7 @@ export class WsLiveClient {
|
|||||||
}
|
}
|
||||||
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
|
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
|
||||||
this.outputSinks.clear();
|
this.outputSinks.clear();
|
||||||
|
this.chatSinks.clear();
|
||||||
this.terminals.clear();
|
this.terminals.clear();
|
||||||
this.domainEventHandler = null;
|
this.domainEventHandler = null;
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
@ -565,27 +576,36 @@ export class WsLiveClient {
|
|||||||
return; // Ignore malformed frames.
|
return; // Ignore malformed frames.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route unsolicited streams first (no replyTo).
|
// Route unsolicited streams first. Frames carrying `replyTo` are command
|
||||||
switch (frame.kind) {
|
// acknowledgements and must fall through to the pending waiter below; in
|
||||||
case "terminal.output": {
|
// particular `chat.send` may acknowledge with a `chat.output` frame.
|
||||||
const payload = frame.payload as unknown as OutputPayload;
|
if (!frame.replyTo) {
|
||||||
const sub = this.terminals.get(payload.sessionId);
|
switch (frame.kind) {
|
||||||
if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq;
|
case "terminal.output": {
|
||||||
const sink = this.outputSinks.get(payload.sessionId);
|
const payload = frame.payload as unknown as OutputPayload;
|
||||||
if (sink) sink(base64ToBytes(payload.bytesBase64));
|
const sub = this.terminals.get(payload.sessionId);
|
||||||
return;
|
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`.
|
// Otherwise it is a reply/ack correlated by `replyTo`.
|
||||||
|
|||||||
107
frontend/src/features/web/WebDesktopWorkspace.test.tsx
Normal file
107
frontend/src/features/web/WebDesktopWorkspace.test.tsx
Normal 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
Reference in New Issue
Block a user