feat(frontend): queue custom CLI messages while agent is busy and add Final/latest-message navigation — #352 (QA verte)
Custom CLI composer stays active during busy turns: submitting while the agent works enqueues the message instead of sending it. Local FIFO queue scoped per cell (projectId+agentId+layoutId+nodeId), entries carry id, text, attachments snapshot and createdAtMs; single in-flight transmission, entry removed at send time, auto-drain only on final chunk, queue preserved on error/Stop with 'Relancer l'agent' action on stopped cells. 'En attente · N' panel anchored above the composer: order, 2-line preview, per-entry edit/delete with transient undo, clear all with confirmation; main button switches Send / 'Mettre en attente' with live announcement; layout contract updated (CLI visible while busy, textarea enabled, Stop available). Chat navigation: round up-arrow under the agent bar jumps to previous Final, round down-arrow above the composer appears when not at bottom to jump to latest message, auto-scroll suspended while reading history, prefers-reduced-motion and 44px a11y targets respected. Ports unchanged (AgentGateway send/cancel/reattach, restartCellAgentRuntime). QA verte reconfirmée par Git: targeted suites 115/115 (CustomAgentChatView/LayoutGrid/LayoutGrid.chat), git diff --check; QA: npm run typecheck OK, npm run build OK. Réserves documentées: queue renderer-local (non persistée après restart complet), validation visuelle cellules très étroites restante. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -23,6 +23,7 @@ import type { ChatDraftScope, Gateways } from "@/ports";
|
||||
import {
|
||||
CustomAgentChatView,
|
||||
__clearCustomAgentChatViewCacheForTests,
|
||||
customAgentChatQueueCount,
|
||||
} from "./CustomAgentChatView";
|
||||
|
||||
const profile: AgentProfile = {
|
||||
@ -1366,7 +1367,7 @@ describe("CustomAgentChatView", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => {
|
||||
it("keeps the composer active after a busy reattach replay settles (#190/#352)", async () => {
|
||||
let scrollback: ReplyChunk[] = [
|
||||
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||
{ kind: "final" as const, content: "Initial reply" },
|
||||
@ -1444,7 +1445,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
||||
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => {
|
||||
@ -1854,7 +1855,7 @@ describe("CustomAgentChatView", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
|
||||
it("keeps the composer active from canonical work-state busy and exposes queueing", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("project-1", {
|
||||
@ -1909,7 +1910,8 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy());
|
||||
expect(composer).toHaveProperty("disabled", false);
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
|
||||
workState._setProjectWorkState("project-1", {
|
||||
@ -1930,7 +1932,7 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the requester as waiting on the delegated target and keeps input locked", async () => {
|
||||
it("shows the requester as waiting on the delegated target while keeping input active", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("project-1", {
|
||||
@ -2003,7 +2005,8 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", true);
|
||||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", false);
|
||||
expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
|
||||
@ -5978,6 +5981,208 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.queryByText(/not found: structured session/)).toBeNull();
|
||||
});
|
||||
|
||||
it("queues busy composer submissions and drains them one at a time in FIFO order after Final", async () => {
|
||||
const harness = createStreamingAgentHarness();
|
||||
render(
|
||||
<DIProvider gateways={{ agent: harness.agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
layoutId="layout-1"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy());
|
||||
fireEvent.change(composer, { target: { value: "active request" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(composer).toHaveProperty("disabled", false);
|
||||
fireEvent.change(composer, { target: { value: "second queued" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
|
||||
expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy();
|
||||
expect((composer as HTMLTextAreaElement).value).toBe("");
|
||||
fireEvent.change(composer, { target: { value: "third queued" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
|
||||
|
||||
const queueButton = screen.getByRole("button", { name: "En attente · 2" });
|
||||
expect(customAgentChatQueueCount("project-1", "agent-1", "layout-1", "node-1")).toBe(2);
|
||||
fireEvent.click(queueButton);
|
||||
const queue = screen.getByRole("region", { name: "File d’attente des messages" });
|
||||
expect(within(queue).getByText("second queued")).toBeTruthy();
|
||||
expect(within(queue).getByText("third queued")).toBeTruthy();
|
||||
expect(within(queue).getByText("Prochain")).toBeTruthy();
|
||||
expect(within(queue).getByText("2e")).toBeTruthy();
|
||||
|
||||
act(() => harness.emit(0, { kind: "final", content: "first final" }));
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2));
|
||||
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"chat-session-1",
|
||||
"second queued",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
|
||||
expect(customAgentChatQueueCount("project-1", "agent-1", "layout-1", "node-1")).toBe(1);
|
||||
|
||||
act(() => harness.emit(1, { kind: "final", content: "second final" }));
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(3));
|
||||
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"chat-session-1",
|
||||
"third queued",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("edits, deletes, restores and clears queued messages while Stop preserves the queue", async () => {
|
||||
const harness = createStreamingAgentHarness();
|
||||
const cancelAgentChat = vi.fn(async () => ({
|
||||
sessionId: "chat-session-1",
|
||||
outcome: "cancelled" as const,
|
||||
}));
|
||||
const agent = { ...harness.agent, cancelAgentChat };
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
layoutId="layout-1"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy());
|
||||
fireEvent.change(composer, { target: { value: "active" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||
for (const text of ["edit me", "keep me"]) {
|
||||
fireEvent.change(composer, { target: { value: text } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "En attente · 2" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Modifier" })[0]);
|
||||
expect((composer as HTMLTextAreaElement).value).toBe("edit me");
|
||||
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
await waitFor(() => expect(cancelAgentChat).toHaveBeenCalledOnce());
|
||||
expect(harness.sendAgentChat).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
|
||||
expect(screen.getByRole("button", { name: "Annuler" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Annuler" }));
|
||||
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "En attente · 1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tout supprimer" }));
|
||||
expect(screen.getByRole("button", { name: "Confirmer" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirmer" }));
|
||||
expect(screen.queryByRole("button", { name: /En attente/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remaining queued messages blocked after a terminal error", async () => {
|
||||
const harness = createStreamingAgentHarness();
|
||||
render(
|
||||
<DIProvider gateways={{ agent: harness.agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
layoutId="layout-1"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy());
|
||||
fireEvent.change(composer, { target: { value: "active" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||
fireEvent.change(composer, { target: { value: "must remain queued" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
|
||||
|
||||
act(() => harness.emit(0, { kind: "error", message: "runtime failed" }));
|
||||
await act(async () => Promise.resolve());
|
||||
expect(harness.sendAgentChat).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
|
||||
expect(screen.getByText("runtime failed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Final/latest navigation without forcing scroll while reading history", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [
|
||||
{ kind: "userPrompt" as const, text: "first" },
|
||||
{ kind: "final" as const, content: "first final" },
|
||||
{ kind: "userPrompt" as const, text: "second" },
|
||||
{ kind: "final" as const, content: "second final" },
|
||||
],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Aller au Final précédent" })).toBeTruthy();
|
||||
const scroll = screen.getByTestId("custom-agent-chat-scroll");
|
||||
Object.defineProperty(scroll, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(scroll, "clientHeight", { value: 300, configurable: true });
|
||||
scroll.scrollTop = 400;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(scroll, "scrollTo", { value: scrollTo, configurable: true });
|
||||
fireEvent.scroll(scroll);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aller au dernier message" }));
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aller au Final précédent" }));
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
|
||||
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user