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:
2026-09-13 22:58:27 +02:00
parent 4ec196b988
commit 76d91c3487
6 changed files with 702 additions and 45 deletions

View File

@ -286,9 +286,11 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(restoredA.conversationId).toBe(persistedA.conversationId);
});
it("keeps custom CLI visible while busy and locks only the composer (#247)", async () => {
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const agent = gateways.agent as MockAgentGateway;
const sendAgentChat = vi.spyOn(agent, "sendAgentChat");
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
@ -328,12 +330,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.queryByRole("button", {
name: `cancel current turn ${leaf.id}`,
})).toBeNull();
await waitFor(() =>
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty(
"disabled",
true,
),
);
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
const queueAction = screen.getByRole("button", { name: "Mettre en attente" });
fireEvent.change(composer, { target: { value: "Question suivante" } });
fireEvent.click(queueAction);
expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy();
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
expect((composer as HTMLTextAreaElement).value).toBe("");
expect(sendAgentChat).not.toHaveBeenCalled();
});
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => {