merge(frontend): feature #352 — file d'attente des messages CLI custom pendant busy et navigation Final précédent / dernier message (QA verte)
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 {
|
import {
|
||||||
CustomAgentChatView,
|
CustomAgentChatView,
|
||||||
__clearCustomAgentChatViewCacheForTests,
|
__clearCustomAgentChatViewCacheForTests,
|
||||||
|
customAgentChatQueueCount,
|
||||||
} from "./CustomAgentChatView";
|
} from "./CustomAgentChatView";
|
||||||
|
|
||||||
const profile: AgentProfile = {
|
const profile: AgentProfile = {
|
||||||
@ -1366,7 +1367,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
).toBeNull();
|
).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[] = [
|
let scrollback: ReplyChunk[] = [
|
||||||
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||||
{ kind: "final" as const, content: "Initial reply" },
|
{ kind: "final" as const, content: "Initial reply" },
|
||||||
@ -1444,7 +1445,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
||||||
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
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 () => {
|
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 system = new MockSystemGateway();
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState("project-1", {
|
workState._setProjectWorkState("project-1", {
|
||||||
@ -1909,7 +1910,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
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();
|
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||||
|
|
||||||
workState._setProjectWorkState("project-1", {
|
workState._setProjectWorkState("project-1", {
|
||||||
@ -1930,7 +1932,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
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 system = new MockSystemGateway();
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState("project-1", {
|
workState._setProjectWorkState("project-1", {
|
||||||
@ -2003,7 +2005,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
|
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 () => {
|
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();
|
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 () => {
|
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
|
|||||||
@ -99,7 +99,9 @@ interface CachedChatView {
|
|||||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||||
const chatConversationViewCache = new Map<string, CachedChatView>();
|
const chatConversationViewCache = new Map<string, CachedChatView>();
|
||||||
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
|
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
|
||||||
|
const chatMessageQueueCache = new Map<string, QueuedChatMessage[]>();
|
||||||
const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
|
const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
|
||||||
|
const CHAT_MESSAGE_QUEUE_CACHE_LIMIT = 24;
|
||||||
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||||
const COMPOSER_RESIZE_STEP = 12;
|
const COMPOSER_RESIZE_STEP = 12;
|
||||||
const FALLBACK_COMPOSER_HEIGHT = 64;
|
const FALLBACK_COMPOSER_HEIGHT = 64;
|
||||||
@ -109,6 +111,7 @@ export function __clearCustomAgentChatViewCacheForTests() {
|
|||||||
chatSessionViewCache.clear();
|
chatSessionViewCache.clear();
|
||||||
chatConversationViewCache.clear();
|
chatConversationViewCache.clear();
|
||||||
chatAttachmentDraftCache.clear();
|
chatAttachmentDraftCache.clear();
|
||||||
|
chatMessageQueueCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttachmentDraft {
|
interface AttachmentDraft {
|
||||||
@ -120,6 +123,43 @@ interface AttachmentDraft {
|
|||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueuedChatMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
attachments: AttachmentDraft[];
|
||||||
|
createdAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageQueueCacheKey(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
layoutId?: string,
|
||||||
|
nodeId?: string,
|
||||||
|
): string {
|
||||||
|
return `${projectId}\u0000${agentId}\u0000${layoutId ?? ""}\u0000${nodeId ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheMessageQueue(key: string, messages: QueuedChatMessage[]): void {
|
||||||
|
chatMessageQueueCache.delete(key);
|
||||||
|
if (messages.length > 0) chatMessageQueueCache.set(key, messages);
|
||||||
|
while (chatMessageQueueCache.size > CHAT_MESSAGE_QUEUE_CACHE_LIMIT) {
|
||||||
|
const oldest = chatMessageQueueCache.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
chatMessageQueueCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customAgentChatQueueCount(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
layoutId?: string,
|
||||||
|
nodeId?: string,
|
||||||
|
): number {
|
||||||
|
return chatMessageQueueCache.get(
|
||||||
|
messageQueueCacheKey(projectId, agentId, layoutId, nodeId),
|
||||||
|
)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null {
|
function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null {
|
||||||
return scope
|
return scope
|
||||||
? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}`
|
? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}`
|
||||||
@ -997,12 +1037,30 @@ export function CustomAgentChatView({
|
|||||||
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
|
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
|
||||||
);
|
);
|
||||||
const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope);
|
const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope);
|
||||||
|
const initialMessageQueueKey = messageQueueCacheKey(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
layoutId,
|
||||||
|
nodeId,
|
||||||
|
);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
|
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
|
||||||
initialAttachmentCacheKey
|
initialAttachmentCacheKey
|
||||||
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
|
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
|
||||||
|
() => chatMessageQueueCache.get(initialMessageQueueKey) ?? [],
|
||||||
|
);
|
||||||
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [clearQueueConfirmation, setClearQueueConfirmation] = useState(false);
|
||||||
|
const [deletedQueueMessage, setDeletedQueueMessage] = useState<{
|
||||||
|
message: QueuedChatMessage;
|
||||||
|
index: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [queueAnnouncement, setQueueAnnouncement] = useState("");
|
||||||
|
const [queueDrainReady, setQueueDrainReady] = useState(false);
|
||||||
|
const [showLatestButton, setShowLatestButton] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
||||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||||
@ -1014,6 +1072,7 @@ export function CustomAgentChatView({
|
|||||||
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [transmissionActive, setTransmissionActive] = useState(false);
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
|
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
|
||||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||||
@ -1040,6 +1099,7 @@ export function CustomAgentChatView({
|
|||||||
const draftSaveSeqRef = useRef(0);
|
const draftSaveSeqRef = useRef(0);
|
||||||
const interactionSeqRef = useRef(0);
|
const interactionSeqRef = useRef(0);
|
||||||
const activeInteractionIdRef = useRef<string | null>(null);
|
const activeInteractionIdRef = useRef<string | null>(null);
|
||||||
|
const transmissionInFlightRef = useRef(false);
|
||||||
const cancelledInteractionIdsRef = useRef(new Set<string>());
|
const cancelledInteractionIdsRef = useRef(new Set<string>());
|
||||||
const ignoreUnscopedChunksAfterStopRef = useRef(false);
|
const ignoreUnscopedChunksAfterStopRef = useRef(false);
|
||||||
const stopInFlightRef = useRef(false);
|
const stopInFlightRef = useRef(false);
|
||||||
@ -1055,6 +1115,10 @@ export function CustomAgentChatView({
|
|||||||
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
||||||
attachmentsRef.current = attachments;
|
attachmentsRef.current = attachments;
|
||||||
const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey);
|
const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey);
|
||||||
|
const queuedMessagesRef = useRef(queuedMessages);
|
||||||
|
queuedMessagesRef.current = queuedMessages;
|
||||||
|
const messageQueueKeyRef = useRef(initialMessageQueueKey);
|
||||||
|
const followsLatestRef = useRef(true);
|
||||||
const imageTokenSeqRef = useRef(0);
|
const imageTokenSeqRef = useRef(0);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const openOrAttachCountRef = useRef(0);
|
const openOrAttachCountRef = useRef(0);
|
||||||
@ -1182,6 +1246,10 @@ export function CustomAgentChatView({
|
|||||||
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
|
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
|
||||||
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
||||||
const attachmentCacheKey = attachmentDraftCacheKey(draftScope);
|
const attachmentCacheKey = attachmentDraftCacheKey(draftScope);
|
||||||
|
const messageQueueKey = useMemo(
|
||||||
|
() => messageQueueCacheKey(projectId, agentId, layoutId, nodeId),
|
||||||
|
[agentId, layoutId, nodeId, projectId],
|
||||||
|
);
|
||||||
const currentConversationCacheKey = useMemo(
|
const currentConversationCacheKey = useMemo(
|
||||||
() => conversationCacheKey(projectId, agentId, conversationId),
|
() => conversationCacheKey(projectId, agentId, conversationId),
|
||||||
[agentId, conversationId, projectId],
|
[agentId, conversationId, projectId],
|
||||||
@ -1198,6 +1266,7 @@ export function CustomAgentChatView({
|
|||||||
const [composerHistoryIndex, setComposerHistoryIndex] =
|
const [composerHistoryIndex, setComposerHistoryIndex] =
|
||||||
useState<number | null>(null);
|
useState<number | null>(null);
|
||||||
const displayTurns = turns;
|
const displayTurns = turns;
|
||||||
|
const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final");
|
||||||
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
||||||
const [requesterDelegation, setRequesterDelegation] =
|
const [requesterDelegation, setRequesterDelegation] =
|
||||||
useState<RequesterDelegationState>({
|
useState<RequesterDelegationState>({
|
||||||
@ -1285,6 +1354,26 @@ export function CustomAgentChatView({
|
|||||||
setDraft(text);
|
setDraft(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateQueuedMessages = useCallback(
|
||||||
|
(
|
||||||
|
updater:
|
||||||
|
| QueuedChatMessage[]
|
||||||
|
| ((current: QueuedChatMessage[]) => QueuedChatMessage[]),
|
||||||
|
) => {
|
||||||
|
const next = typeof updater === "function"
|
||||||
|
? updater(queuedMessagesRef.current)
|
||||||
|
: updater;
|
||||||
|
queuedMessagesRef.current = next;
|
||||||
|
cacheMessageQueue(messageQueueKeyRef.current, next);
|
||||||
|
setQueuedMessages(next);
|
||||||
|
if (next.length === 0) {
|
||||||
|
setQueueOpen(false);
|
||||||
|
setClearQueueConfirmation(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const recallComposerHistory = useCallback(
|
const recallComposerHistory = useCallback(
|
||||||
(direction: "previous" | "next") => {
|
(direction: "previous" | "next") => {
|
||||||
if (composerHistory.length === 0) return;
|
if (composerHistory.length === 0) return;
|
||||||
@ -1388,6 +1477,19 @@ export function CustomAgentChatView({
|
|||||||
);
|
);
|
||||||
}, [attachmentCacheKey]);
|
}, [attachmentCacheKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousKey = messageQueueKeyRef.current;
|
||||||
|
cacheMessageQueue(previousKey, queuedMessagesRef.current);
|
||||||
|
messageQueueKeyRef.current = messageQueueKey;
|
||||||
|
const restored = chatMessageQueueCache.get(messageQueueKey) ?? [];
|
||||||
|
queuedMessagesRef.current = restored;
|
||||||
|
setQueuedMessages(restored);
|
||||||
|
setQueueOpen(false);
|
||||||
|
setClearQueueConfirmation(false);
|
||||||
|
setDeletedQueueMessage(null);
|
||||||
|
setQueueDrainReady(false);
|
||||||
|
}, [messageQueueKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshRequesterDelegation();
|
void refreshRequesterDelegation();
|
||||||
}, [refreshRequesterDelegation]);
|
}, [refreshRequesterDelegation]);
|
||||||
@ -1448,6 +1550,7 @@ export function CustomAgentChatView({
|
|||||||
});
|
});
|
||||||
} else if (event.type === "agentRestartCompleted") {
|
} else if (event.type === "agentRestartCompleted") {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
setQueueDrainReady(true);
|
||||||
nextTurns.push({
|
nextTurns.push({
|
||||||
role: "restart",
|
role: "restart",
|
||||||
key,
|
key,
|
||||||
@ -1518,6 +1621,7 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
updateChatViewState((prev) => foldChunk(prev, chunk));
|
updateChatViewState((prev) => foldChunk(prev, chunk));
|
||||||
if (chunk.kind === "final" || chunk.kind === "error") {
|
if (chunk.kind === "final" || chunk.kind === "error") {
|
||||||
|
setQueueDrainReady(chunk.kind === "final");
|
||||||
if (activeInteractionIdRef.current) {
|
if (activeInteractionIdRef.current) {
|
||||||
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
||||||
activeInteractionIdRef.current = null;
|
activeInteractionIdRef.current = null;
|
||||||
@ -1880,9 +1984,65 @@ export function CustomAgentChatView({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const prefersReducedMotion = useCallback(
|
||||||
|
() => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToLatest = useCallback(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const top = element.scrollHeight;
|
||||||
|
if (element.scrollTo) {
|
||||||
|
element.scrollTo({
|
||||||
|
top,
|
||||||
|
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
element.scrollTop = top;
|
||||||
|
}
|
||||||
|
followsLatestRef.current = true;
|
||||||
|
setShowLatestButton(false);
|
||||||
|
}, [prefersReducedMotion]);
|
||||||
|
|
||||||
|
const updateLatestVisibility = useCallback(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const atBottom =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight <= 24;
|
||||||
|
followsLatestRef.current = atBottom;
|
||||||
|
setShowLatestButton(!atBottom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToPreviousFinal = useCallback(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const finals = Array.from(
|
||||||
|
element.querySelectorAll<HTMLElement>("[data-chat-final]"),
|
||||||
|
);
|
||||||
|
if (finals.length === 0) return;
|
||||||
|
const currentTop = element.scrollTop;
|
||||||
|
const earlier = finals.filter((final) => final.offsetTop < currentTop - 8);
|
||||||
|
const target = earlier.at(-1) ?? finals.at(-1);
|
||||||
|
if (!target) return;
|
||||||
|
const top = Math.max(0, target.offsetTop - 8);
|
||||||
|
if (element.scrollTo) {
|
||||||
|
element.scrollTo({
|
||||||
|
top,
|
||||||
|
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
element.scrollTop = top;
|
||||||
|
}
|
||||||
|
followsLatestRef.current = false;
|
||||||
|
setShowLatestButton(true);
|
||||||
|
}, [prefersReducedMotion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (!element || !followsLatestRef.current) return;
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
setShowLatestButton(false);
|
||||||
}, [displayTurns]);
|
}, [displayTurns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -2044,13 +2204,12 @@ export function CustomAgentChatView({
|
|||||||
openRetryNonce,
|
openRetryNonce,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canSend = useMemo(
|
const canSubmit = useMemo(
|
||||||
() =>
|
() =>
|
||||||
supported &&
|
supported &&
|
||||||
(Boolean(draft.trim()) || attachments.length > 0) &&
|
(Boolean(draft.trim()) || attachments.length > 0) &&
|
||||||
!effectiveBusy &&
|
|
||||||
!opening,
|
!opening,
|
||||||
[supported, draft, attachments.length, effectiveBusy, opening],
|
[supported, draft, attachments.length, opening],
|
||||||
);
|
);
|
||||||
const unavailableLinkedTranscript =
|
const unavailableLinkedTranscript =
|
||||||
displayTurns.length === 0 &&
|
displayTurns.length === 0 &&
|
||||||
@ -2317,9 +2476,7 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send() {
|
function rememberComposerText(text: string) {
|
||||||
const text = draft.trim();
|
|
||||||
if (!canSend || !agent.sendAgentChat) return;
|
|
||||||
if (text) {
|
if (text) {
|
||||||
const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text);
|
const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text);
|
||||||
composerHistoryRef.current = nextHistory;
|
composerHistoryRef.current = nextHistory;
|
||||||
@ -2331,12 +2488,42 @@ export function CustomAgentChatView({
|
|||||||
setComposerHistoryIndex(null);
|
setComposerHistoryIndex(null);
|
||||||
draftBeforeHistoryNavigationRef.current = "";
|
draftBeforeHistoryNavigationRef.current = "";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearComposerAfterSubmit() {
|
||||||
|
setDraft("");
|
||||||
|
setAttachments([]);
|
||||||
|
imageTokenSeqRef.current = 0;
|
||||||
|
void clearPersistedDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueComposerMessage(text: string) {
|
||||||
|
const message: QueuedChatMessage = {
|
||||||
|
id: `${agentId}:queued:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||||
|
text,
|
||||||
|
attachments: [...attachments],
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
updateQueuedMessages((current) => [...current, message]);
|
||||||
|
clearComposerAfterSubmit();
|
||||||
|
setDeletedQueueMessage(null);
|
||||||
|
setQueueAnnouncement(
|
||||||
|
tr("Message ajouté à la file d’attente", "Message added to the queue"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transmitMessage(message: QueuedChatMessage) {
|
||||||
|
const text = message.text;
|
||||||
|
if (!agent.sendAgentChat || transmissionInFlightRef.current) return;
|
||||||
|
setQueueDrainReady(false);
|
||||||
const slashInvocation = slashCommandInvocation(text);
|
const slashInvocation = slashCommandInvocation(text);
|
||||||
if (slashInvocation) {
|
if (slashInvocation && message.id.endsWith(":composer")) {
|
||||||
await executeSlashCommand(slashInvocation);
|
await executeSlashCommand(slashInvocation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const outgoingAttachments = attachments;
|
transmissionInFlightRef.current = true;
|
||||||
|
setTransmissionActive(true);
|
||||||
|
const outgoingAttachments = message.attachments;
|
||||||
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||||
const displayText = text || "Pièce jointe";
|
const displayText = text || "Pièce jointe";
|
||||||
@ -2350,10 +2537,6 @@ export function CustomAgentChatView({
|
|||||||
markProjectCliInteractionFinished(projectId, interactionId);
|
markProjectCliInteractionFinished(projectId, interactionId);
|
||||||
activeInteractionIdRef.current = null;
|
activeInteractionIdRef.current = null;
|
||||||
};
|
};
|
||||||
setDraft("");
|
|
||||||
setAttachments([]);
|
|
||||||
imageTokenSeqRef.current = 0;
|
|
||||||
void clearPersistedDraft();
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
@ -2401,6 +2584,7 @@ export function CustomAgentChatView({
|
|||||||
} catch (recoveryError) {
|
} catch (recoveryError) {
|
||||||
finishInteraction();
|
finishInteraction();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setQueueDrainReady(false);
|
||||||
setError({ message: describe(recoveryError), openFailure: false });
|
setError({ message: describe(recoveryError), openFailure: false });
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -2414,14 +2598,36 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
finishInteraction();
|
finishInteraction();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setQueueDrainReady(false);
|
||||||
setError({ message: describe(e), openFailure: false });
|
setError({ message: describe(e), openFailure: false });
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }],
|
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }],
|
||||||
}));
|
}));
|
||||||
|
} finally {
|
||||||
|
transmissionInFlightRef.current = false;
|
||||||
|
setTransmissionActive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!canSubmit || !agent.sendAgentChat) return;
|
||||||
|
rememberComposerText(text);
|
||||||
|
if (effectiveBusy || transmissionInFlightRef.current) {
|
||||||
|
enqueueComposerMessage(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message: QueuedChatMessage = {
|
||||||
|
id: `${agentId}:${++interactionSeqRef.current}:composer`,
|
||||||
|
text,
|
||||||
|
attachments: [...attachments],
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
clearComposerAfterSubmit();
|
||||||
|
await transmitMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function stopCurrentRequest() {
|
async function stopCurrentRequest() {
|
||||||
const sid = sessionRef.current;
|
const sid = sessionRef.current;
|
||||||
if (
|
if (
|
||||||
@ -2434,6 +2640,7 @@ export function CustomAgentChatView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopInFlightRef.current = true;
|
stopInFlightRef.current = true;
|
||||||
|
setQueueDrainReady(false);
|
||||||
ignoreUnscopedChunksAfterStopRef.current = true;
|
ignoreUnscopedChunksAfterStopRef.current = true;
|
||||||
const interactionBeingCancelled = activeInteractionIdRef.current;
|
const interactionBeingCancelled = activeInteractionIdRef.current;
|
||||||
if (interactionBeingCancelled) {
|
if (interactionBeingCancelled) {
|
||||||
@ -2503,6 +2710,77 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editQueuedMessage(message: QueuedChatMessage) {
|
||||||
|
updateQueuedMessages((current) =>
|
||||||
|
current.filter((candidate) => candidate.id !== message.id),
|
||||||
|
);
|
||||||
|
editDraft(message.text);
|
||||||
|
setAttachments(message.attachments);
|
||||||
|
setDeletedQueueMessage(null);
|
||||||
|
setQueueAnnouncement(
|
||||||
|
tr("Message remis dans le champ de saisie", "Message returned to the composer"),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => composerRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteQueuedMessage(message: QueuedChatMessage) {
|
||||||
|
const index = queuedMessagesRef.current.findIndex(
|
||||||
|
(candidate) => candidate.id === message.id,
|
||||||
|
);
|
||||||
|
if (index < 0) return;
|
||||||
|
updateQueuedMessages((current) =>
|
||||||
|
current.filter((candidate) => candidate.id !== message.id),
|
||||||
|
);
|
||||||
|
setDeletedQueueMessage({ message, index });
|
||||||
|
setQueueAnnouncement(tr("Message supprimé", "Message deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoDeleteQueuedMessage() {
|
||||||
|
if (!deletedQueueMessage) return;
|
||||||
|
updateQueuedMessages((current) => {
|
||||||
|
const next = [...current];
|
||||||
|
next.splice(
|
||||||
|
Math.min(deletedQueueMessage.index, next.length),
|
||||||
|
0,
|
||||||
|
deletedQueueMessage.message,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDeletedQueueMessage(null);
|
||||||
|
setQueueAnnouncement(tr("Suppression annulée", "Deletion undone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueuedMessages() {
|
||||||
|
updateQueuedMessages([]);
|
||||||
|
setDeletedQueueMessage(null);
|
||||||
|
setQueueAnnouncement(
|
||||||
|
tr("File d’attente supprimée", "Queue cleared"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!queueDrainReady ||
|
||||||
|
effectiveBusy ||
|
||||||
|
opening ||
|
||||||
|
!supported ||
|
||||||
|
transmissionActive ||
|
||||||
|
queuedMessages.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = queuedMessages[0];
|
||||||
|
updateQueuedMessages((current) =>
|
||||||
|
current[0]?.id === next.id
|
||||||
|
? current.slice(1)
|
||||||
|
: current.filter((candidate) => candidate.id !== next.id),
|
||||||
|
);
|
||||||
|
setQueueAnnouncement(
|
||||||
|
tr("Envoi du prochain message en attente", "Sending the next queued message"),
|
||||||
|
);
|
||||||
|
void transmitMessage(next);
|
||||||
|
}, [effectiveBusy, opening, queueDrainReady, queuedMessages, supported, transmissionActive]);
|
||||||
|
|
||||||
const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId;
|
const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -2545,6 +2823,16 @@ export function CustomAgentChatView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasPreviousFinal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={tr("Aller au Final précédent", "Go to previous Final")}
|
||||||
|
className="absolute left-1/2 top-12 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
|
||||||
|
onClick={scrollToPreviousFinal}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!supported && (
|
{!supported && (
|
||||||
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
||||||
@ -2694,6 +2982,7 @@ export function CustomAgentChatView({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
data-testid="custom-agent-chat-scroll"
|
data-testid="custom-agent-chat-scroll"
|
||||||
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
||||||
|
onScroll={updateLatestVisibility}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<AnnouncementsPreview
|
<AnnouncementsPreview
|
||||||
@ -2768,6 +3057,118 @@ export function CustomAgentChatView({
|
|||||||
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{showLatestButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={tr("Aller au dernier message", "Go to latest message")}
|
||||||
|
className="absolute -top-12 left-1/2 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
|
||||||
|
onClick={scrollToLatest}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||||||
|
{queueAnnouncement}
|
||||||
|
</div>
|
||||||
|
{(queuedMessages.length > 0 || deletedQueueMessage) && (
|
||||||
|
<div className="relative flex min-w-0 items-center gap-2">
|
||||||
|
{queuedMessages.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-11 items-center rounded-md border border-border bg-surface px-3 text-xs font-semibold text-content hover:border-border-strong"
|
||||||
|
aria-expanded={queueOpen}
|
||||||
|
aria-controls={`message-queue-${domId}`}
|
||||||
|
onClick={() => setQueueOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{tr("En attente", "Queued")} · {queuedMessages.length}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{deletedQueueMessage && (
|
||||||
|
<div role="status" className="flex min-h-11 min-w-0 items-center gap-2 text-xs text-muted">
|
||||||
|
<span>{tr("Message supprimé", "Message deleted")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 font-semibold text-primary"
|
||||||
|
onClick={undoDeleteQueuedMessage}
|
||||||
|
>
|
||||||
|
{tr("Annuler", "Undo")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{queueOpen && queuedMessages.length > 0 && (
|
||||||
|
<div
|
||||||
|
id={`message-queue-${domId}`}
|
||||||
|
role="region"
|
||||||
|
aria-label={tr("File d’attente des messages", "Message queue")}
|
||||||
|
className="absolute bottom-full left-0 z-30 mb-2 flex max-h-[min(22rem,60vh)] w-[min(34rem,calc(100vw-2rem))] max-w-full flex-col overflow-hidden rounded-md border border-border bg-raised shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex min-h-11 items-center justify-between gap-2 border-b border-border px-3">
|
||||||
|
<strong className="text-xs">
|
||||||
|
{tr("Messages en attente", "Queued messages")} · {queuedMessages.length}
|
||||||
|
</strong>
|
||||||
|
{clearQueueConfirmation ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 text-xs text-muted"
|
||||||
|
onClick={() => setClearQueueConfirmation(false)}
|
||||||
|
>
|
||||||
|
{tr("Annuler", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 text-xs font-semibold text-danger"
|
||||||
|
onClick={clearQueuedMessages}
|
||||||
|
>
|
||||||
|
{tr("Confirmer", "Confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 text-xs text-danger"
|
||||||
|
onClick={() => setClearQueueConfirmation(true)}
|
||||||
|
>
|
||||||
|
{tr("Tout supprimer", "Clear all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ol className="min-h-0 overflow-y-auto p-1">
|
||||||
|
{queuedMessages.map((message, index) => (
|
||||||
|
<li key={message.id} className="flex min-w-0 gap-2 rounded px-2 py-2 hover:bg-surface">
|
||||||
|
<span className="w-14 shrink-0 pt-1 text-[11px] font-semibold text-primary">
|
||||||
|
{index === 0
|
||||||
|
? tr("Prochain", "Next")
|
||||||
|
: tr(`${index + 1}e`, `#${index + 1}`)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-content">
|
||||||
|
{message.text || tr("Pièce jointe", "Attachment")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 text-xs font-semibold text-primary"
|
||||||
|
onClick={() => editQueuedMessage(message)}
|
||||||
|
>
|
||||||
|
{tr("Modifier", "Edit")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 px-2 text-xs text-danger"
|
||||||
|
onClick={() => deleteQueuedMessage(message)}
|
||||||
|
>
|
||||||
|
{tr("Supprimer", "Delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex min-w-0 flex-wrap gap-2">
|
<div className="flex min-w-0 flex-wrap gap-2">
|
||||||
{attachments.map((item) => (
|
{attachments.map((item) => (
|
||||||
@ -2854,7 +3255,7 @@ export function CustomAgentChatView({
|
|||||||
)}
|
)}
|
||||||
rows={2}
|
rows={2}
|
||||||
value={draft}
|
value={draft}
|
||||||
disabled={!supported || opening || effectiveBusy}
|
disabled={!supported || opening}
|
||||||
placeholder={t("custom.message")}
|
placeholder={t("custom.message")}
|
||||||
style={{ height: clampComposerHeight(composerHeightPx) }}
|
style={{ height: clampComposerHeight(composerHeightPx) }}
|
||||||
onChange={(e) => editDraft(e.target.value)}
|
onChange={(e) => editDraft(e.target.value)}
|
||||||
@ -2912,26 +3313,34 @@ export function CustomAgentChatView({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="shrink-0 whitespace-nowrap"
|
className="shrink-0 whitespace-nowrap"
|
||||||
disabled={!supported || opening || effectiveBusy}
|
disabled={!supported || opening}
|
||||||
onClick={() => void pickAttachment()}
|
onClick={() => void pickAttachment()}
|
||||||
>
|
>
|
||||||
{t("custom.attach")}
|
{t("custom.attach")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{effectiveBusy && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
className="min-h-11 shrink-0 whitespace-nowrap"
|
||||||
|
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
|
||||||
|
loading={stopping}
|
||||||
|
title={t("custom.stop")}
|
||||||
|
aria-label={t("custom.stop")}
|
||||||
|
onClick={() => void stopCurrentRequest()}
|
||||||
|
>
|
||||||
|
{stopping ? t("custom.stopping") : t("custom.stop")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={effectiveBusy ? "danger" : undefined}
|
className="min-h-11 shrink-0 whitespace-nowrap"
|
||||||
className="shrink-0 whitespace-nowrap"
|
disabled={!canSubmit}
|
||||||
disabled={
|
onClick={() => void send()}
|
||||||
effectiveBusy
|
|
||||||
? !supported || !currentSession || !agent.cancelAgentChat || stopping
|
|
||||||
: !canSend
|
|
||||||
}
|
|
||||||
loading={stopping}
|
|
||||||
title={effectiveBusy ? t("custom.stop") : undefined}
|
|
||||||
aria-label={effectiveBusy ? t("custom.stop") : undefined}
|
|
||||||
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
|
|
||||||
>
|
>
|
||||||
{stopping ? t("custom.stopping") : effectiveBusy ? t("custom.stop") : t("custom.send")}
|
{effectiveBusy
|
||||||
|
? tr("Mettre en attente", "Queue message")
|
||||||
|
: t("custom.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2994,7 +3403,7 @@ function ChatBubble({
|
|||||||
}
|
}
|
||||||
if (turn.role === "final") {
|
if (turn.role === "final") {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 justify-end">
|
<div data-chat-final="true" className="flex min-w-0 justify-end">
|
||||||
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
||||||
<ChatMessageMeta
|
<ChatMessageMeta
|
||||||
label={tr("Terminé", "Completed")}
|
label={tr("Terminé", "Completed")}
|
||||||
|
|||||||
@ -23,6 +23,9 @@ export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
|||||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||||
export { useResumeProject } from "./useResumeProject";
|
export { useResumeProject } from "./useResumeProject";
|
||||||
export type { ResumeProjectViewModel } from "./useResumeProject";
|
export type { ResumeProjectViewModel } from "./useResumeProject";
|
||||||
export { CustomAgentChatView } from "./CustomAgentChatView";
|
export {
|
||||||
|
CustomAgentChatView,
|
||||||
|
customAgentChatQueueCount,
|
||||||
|
} from "./CustomAgentChatView";
|
||||||
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
|
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
|
||||||
export { isCustomAgentCliAvailable } from "./customCliEligibility";
|
export { isCustomAgentCliAvailable } from "./customCliEligibility";
|
||||||
|
|||||||
@ -286,9 +286,11 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(restoredA.conversationId).toBe(persistedA.conversationId);
|
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 gateways = await seeded(structuredProfile);
|
||||||
const layout = gateways.layout as MockLayoutGateway;
|
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 workState = gateways.workState as MockWorkStateGateway;
|
||||||
const tree = await layout.loadLayout("p1");
|
const tree = await layout.loadLayout("p1");
|
||||||
const leaf = leaves(tree)[0];
|
const leaf = leaves(tree)[0];
|
||||||
@ -328,12 +330,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.queryByRole("button", {
|
expect(screen.queryByRole("button", {
|
||||||
name: `cancel current turn ${leaf.id}`,
|
name: `cancel current turn ${leaf.id}`,
|
||||||
})).toBeNull();
|
})).toBeNull();
|
||||||
await waitFor(() =>
|
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||||||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty(
|
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
|
||||||
"disabled",
|
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||||
true,
|
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 () => {
|
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => {
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import {
|
|||||||
import { PluginLayoutCellView } from "@/features/plugins";
|
import { PluginLayoutCellView } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
CustomAgentChatView,
|
CustomAgentChatView,
|
||||||
|
customAgentChatQueueCount,
|
||||||
isCustomAgentCliAvailable,
|
isCustomAgentCliAvailable,
|
||||||
modelServerOverlayText,
|
modelServerOverlayText,
|
||||||
describeModelServerDownload,
|
describeModelServerDownload,
|
||||||
@ -338,12 +339,14 @@ function goToCell(nodeId: string): void {
|
|||||||
|
|
||||||
function StoppedAgentCellPanel({
|
function StoppedAgentCellPanel({
|
||||||
agentName,
|
agentName,
|
||||||
|
queuedMessageCount,
|
||||||
restarting,
|
restarting,
|
||||||
error,
|
error,
|
||||||
onRestart,
|
onRestart,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
|
queuedMessageCount: number;
|
||||||
restarting: boolean;
|
restarting: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
@ -360,6 +363,14 @@ function StoppedAgentCellPanel({
|
|||||||
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
|
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stopped-agent-cell__content">
|
<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">
|
<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.")}
|
{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}
|
||||||
</p>
|
</p>
|
||||||
@ -376,11 +387,15 @@ function StoppedAgentCellPanel({
|
|||||||
<div className="stopped-agent-cell__actions">
|
<div className="stopped-agent-cell__actions">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onRestart}
|
||||||
disabled={restarting}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1658,6 +1673,12 @@ function LeafView({
|
|||||||
) : agentId && stoppedAgentCell ? (
|
) : agentId && stoppedAgentCell ? (
|
||||||
<StoppedAgentCellPanel
|
<StoppedAgentCellPanel
|
||||||
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
|
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
|
||||||
|
queuedMessageCount={customAgentChatQueueCount(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
layoutId,
|
||||||
|
id,
|
||||||
|
)}
|
||||||
restarting={restartPending}
|
restarting={restartPending}
|
||||||
error={restartError}
|
error={restartError}
|
||||||
onRestart={() => void restartStoppedAgent()}
|
onRestart={() => void restartStoppedAgent()}
|
||||||
|
|||||||
@ -204,6 +204,14 @@
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stopped-agent-cell__queued {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.stopped-agent-cell__help {
|
.stopped-agent-cell__help {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
@ -249,6 +257,10 @@
|
|||||||
background: var(--color-primary-hover);
|
background: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stopped-agent-cell__button--queued {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.stopped-agent-cell {
|
.stopped-agent-cell {
|
||||||
padding: 34px 10px 10px;
|
padding: 34px 10px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user