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:
@ -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 () => {
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
import { PluginLayoutCellView } from "@/features/plugins";
|
||||
import {
|
||||
CustomAgentChatView,
|
||||
customAgentChatQueueCount,
|
||||
isCustomAgentCliAvailable,
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
@ -338,12 +339,14 @@ function goToCell(nodeId: string): void {
|
||||
|
||||
function StoppedAgentCellPanel({
|
||||
agentName,
|
||||
queuedMessageCount,
|
||||
restarting,
|
||||
error,
|
||||
onRestart,
|
||||
onRefresh,
|
||||
}: {
|
||||
agentName: string;
|
||||
queuedMessageCount: number;
|
||||
restarting: boolean;
|
||||
error: boolean;
|
||||
onRestart: () => void;
|
||||
@ -360,6 +363,14 @@ function StoppedAgentCellPanel({
|
||||
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
|
||||
</div>
|
||||
<div className="stopped-agent-cell__content">
|
||||
{queuedMessageCount > 0 && (
|
||||
<p role="status" className="stopped-agent-cell__queued">
|
||||
{tr(
|
||||
"Envoi en attente — l’agent est arrêté",
|
||||
"Message pending — the agent is stopped",
|
||||
)} · {queuedMessageCount}
|
||||
</p>
|
||||
)}
|
||||
<p className="stopped-agent-cell__message">
|
||||
{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}
|
||||
</p>
|
||||
@ -376,11 +387,15 @@ function StoppedAgentCellPanel({
|
||||
<div className="stopped-agent-cell__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="stopped-agent-cell__button stopped-agent-cell__button--primary"
|
||||
className={`stopped-agent-cell__button stopped-agent-cell__button--primary${queuedMessageCount > 0 ? " stopped-agent-cell__button--queued" : ""}`}
|
||||
onClick={onRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? tr("Relance…", "Restarting…") : tr("Relancer", "Restart")}
|
||||
{restarting
|
||||
? tr("Relance…", "Restarting…")
|
||||
: queuedMessageCount > 0
|
||||
? tr("Relancer l’agent", "Restart agent")
|
||||
: tr("Relancer", "Restart")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -1658,6 +1673,12 @@ function LeafView({
|
||||
) : agentId && stoppedAgentCell ? (
|
||||
<StoppedAgentCellPanel
|
||||
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
|
||||
queuedMessageCount={customAgentChatQueueCount(
|
||||
projectId,
|
||||
agentId,
|
||||
layoutId,
|
||||
id,
|
||||
)}
|
||||
restarting={restartPending}
|
||||
error={restartError}
|
||||
onRestart={() => void restartStoppedAgent()}
|
||||
|
||||
Reference in New Issue
Block a user