fix(chat): déduplication des progress intermédiaires dans la timeline (#192)
Les progress « started » d'une même entité s'empilaient en plusieurs turns et n'étaient dédupliqués qu'a posteriori par scan linéaire du tableau de turns, d'où des doublons visibles et un affichage retardé (re-scan par chunk). appendProgress opérait directement sur turns[] (chaque « started » y était poussé puis retrouvé/remplacé par recherche séquentielle). Désormais: - ChatViewState (turns + entityProgressMap): la Map indexée par « entité\0bucket » (started/completed) est l'unique source de vérité pour les progress en cours; - les progress « started » ne deviennent des turns qu'au flush (completeChatViewProgress sur final/error), supprimant la duplication et rendant l'affichage immédiat; - « completed » merge depuis l'entrée running de la Map puis la retire, au lieu de scanner le tableau; - turnsIndicateBusy s'appuie sur la taille de la Map (état vivant) en plus du scan des turns. Tests: déduplication des progress par entité (started répétés → une seule entrée, flush au final), fusion « completed » depuis la Map, état busy reflété par la Map, et rendu non dupliqué sur l'enchaînement started/completed/final.
This commit is contained in:
@ -851,6 +851,322 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deduplicates identical entity progress chunks", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "read ticket" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
const chunk = {
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "ideaLocal" as const,
|
||||||
|
kind: "mcp" as const,
|
||||||
|
stage: "started" as const,
|
||||||
|
label: "idea_ticket_read",
|
||||||
|
toolName: "idea_ticket_read",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.(chunk);
|
||||||
|
emitChunk?.(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates entity progress deltas in place", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "download model" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
const progress = (stage: "started" | "delta", text: string) => ({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "providerNative" as const,
|
||||||
|
kind: "tool" as const,
|
||||||
|
stage,
|
||||||
|
label: "download_model",
|
||||||
|
toolName: "download_model",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.(progress("started", "Downloading 10%"));
|
||||||
|
emitChunk?.(progress("delta", "Downloading 75%"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
|
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
||||||
|
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders progress immediately when the chunk callback fires", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "stream progress" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "providerNative" as const,
|
||||||
|
kind: "tool" as const,
|
||||||
|
stage: "started" as const,
|
||||||
|
label: "stream_repo",
|
||||||
|
toolName: "stream_repo",
|
||||||
|
text: "Chunk received",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Chunk received")).toBeTruthy();
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves completed entity progress from active map to turns", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "call tool" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
const progress = (stage: "started" | "completed") => ({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
source: "providerNative" as const,
|
||||||
|
kind: "tool" as const,
|
||||||
|
stage,
|
||||||
|
label: "search_repo",
|
||||||
|
toolName: "search_repo",
|
||||||
|
text: stage === "started" ? "Searching..." : "Found results",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.(progress("started"));
|
||||||
|
emitChunk?.(progress("completed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
|
expect(screen.queryByText("Searching...")).toBeNull();
|
||||||
|
expect(screen.getByText("Found results")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("done");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.({ kind: "final", content: "done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("done");
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
|
expect(screen.getByText("Found results")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders running progress without spinner and marks it done on final", async () => {
|
it("renders running progress without spinner and marks it done on final", async () => {
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -55,6 +55,7 @@ type ChatTurn =
|
|||||||
|
|
||||||
interface CachedChatView {
|
interface CachedChatView {
|
||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
|
entityProgressMap: Map<string, ReplyProgress>;
|
||||||
chunkCount: number;
|
chunkCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,42 +263,57 @@ function progressDisplayKey(progress: ReplyProgress): string {
|
|||||||
return progressKey(progress);
|
return progressKey(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] {
|
interface ChatViewState {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
entityProgressMap: Map<string, ReplyProgress>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyChatViewState(): ChatViewState {
|
||||||
|
return { turns: [], entityProgressMap: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||||
const entity = progressEntityKey(progress);
|
const entity = progressEntityKey(progress);
|
||||||
if (entity && progress.stage === "completed") {
|
if (entity) {
|
||||||
let runningIndex = -1;
|
const bucket = progressStageBucket(progress);
|
||||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
const key = `${entity}\u0000${bucket}`;
|
||||||
const turn = turns[index];
|
if (bucket === "started") {
|
||||||
if (
|
const nextMap = new Map(state.entityProgressMap);
|
||||||
turn.role === "progress" &&
|
nextMap.set(key, progress);
|
||||||
progressEntityKey(turn.progress) === entity &&
|
return { turns: state.turns, entityProgressMap: nextMap };
|
||||||
(turn.progress.stage === "started" || turn.progress.stage === "delta")
|
|
||||||
) {
|
|
||||||
runningIndex = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (runningIndex >= 0) {
|
if (bucket === "completed") {
|
||||||
const next = [...turns];
|
const runningKey = `${entity}\u0000started`;
|
||||||
const current = next[runningIndex];
|
const running = state.entityProgressMap.get(runningKey);
|
||||||
if (current.role === "progress") {
|
const nextMap = new Map(state.entityProgressMap);
|
||||||
next[runningIndex] = {
|
nextMap.delete(runningKey);
|
||||||
role: "progress",
|
if (
|
||||||
progress: mergeCompletedProgress(current.progress, progress),
|
!running &&
|
||||||
};
|
state.turns.some(
|
||||||
|
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { turns: state.turns, entityProgressMap: nextMap };
|
||||||
}
|
}
|
||||||
return next;
|
const completed = running ? mergeCompletedProgress(running, progress) : progress;
|
||||||
|
return {
|
||||||
|
turns: [...state.turns, { role: "progress", progress: completed }],
|
||||||
|
entityProgressMap: nextMap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const key = progressDisplayKey(progress);
|
const key = progressDisplayKey(progress);
|
||||||
if (
|
if (
|
||||||
turns.some(
|
state.turns.some(
|
||||||
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return turns;
|
return state;
|
||||||
}
|
}
|
||||||
return [...turns, { role: "progress", progress }];
|
return {
|
||||||
|
turns: [...state.turns, { role: "progress", progress }],
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCompletedProgress(
|
function mergeCompletedProgress(
|
||||||
@ -352,13 +368,25 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||||
|
const activeTurns = Array.from(state.entityProgressMap.values()).map((progress) => ({
|
||||||
|
role: "progress" as const,
|
||||||
|
progress,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
turns: completeRunningProgress([...state.turns, ...activeTurns]),
|
||||||
|
entityProgressMap: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
||||||
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
|
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function turnsIndicateBusy(turns: ChatTurn[]): boolean {
|
function turnsIndicateBusy(state: ChatViewState): boolean {
|
||||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
if (state.entityProgressMap.size > 0) return true;
|
||||||
const turn = turns[index];
|
for (let index = state.turns.length - 1; index >= 0; index -= 1) {
|
||||||
|
const turn = state.turns[index];
|
||||||
if (turn.role === "final" || turn.role === "error") return false;
|
if (turn.role === "final" || turn.role === "error") return false;
|
||||||
if (turn.role === "agent" && turn.pending) return true;
|
if (turn.role === "agent" && turn.pending) return true;
|
||||||
if (
|
if (
|
||||||
@ -490,9 +518,13 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||||
|
const { turns } = state;
|
||||||
if (!isReplyRecord(raw)) {
|
if (!isReplyRecord(raw)) {
|
||||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
return {
|
||||||
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const last = turns[turns.length - 1];
|
const last = turns[turns.length - 1];
|
||||||
if (
|
if (
|
||||||
@ -501,44 +533,63 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
|||||||
raw.kind !== "UserPrompt"
|
raw.kind !== "UserPrompt"
|
||||||
) {
|
) {
|
||||||
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
|
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
|
||||||
return foldChunk(turns.slice(0, -1), raw);
|
return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw);
|
||||||
}
|
}
|
||||||
return turns;
|
return state;
|
||||||
}
|
}
|
||||||
switch (raw.kind) {
|
switch (raw.kind) {
|
||||||
case "textDelta":
|
case "textDelta":
|
||||||
return appendAgentDelta(turns, String(raw.text ?? ""));
|
return {
|
||||||
|
turns: appendAgentDelta(turns, String(raw.text ?? "")),
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
case "toolActivity":
|
case "toolActivity":
|
||||||
return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }];
|
return {
|
||||||
|
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }],
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
case "progress": {
|
case "progress": {
|
||||||
const progress = normalizeProgress(raw.progress);
|
const progress = normalizeProgress(raw.progress);
|
||||||
if (!progress) return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
if (!progress) {
|
||||||
return appendProgress(turns, progress);
|
return {
|
||||||
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return appendProgress(state, progress);
|
||||||
}
|
}
|
||||||
case "final": {
|
case "final": {
|
||||||
const content = String(raw.content ?? "");
|
const content = String(raw.content ?? "");
|
||||||
const next = completeRunningProgress(turns);
|
const completedState = completeChatViewProgress(state);
|
||||||
|
const next = [...completedState.turns];
|
||||||
const last = next[next.length - 1];
|
const last = next[next.length - 1];
|
||||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||||
next.push({ role: "final", text: content });
|
next.push({ role: "final", text: content });
|
||||||
return next;
|
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
||||||
}
|
}
|
||||||
case "error": {
|
case "error": {
|
||||||
const next = completeRunningProgress(turns);
|
const completedState = completeChatViewProgress(state);
|
||||||
|
const next = [...completedState.turns];
|
||||||
const last = next[next.length - 1];
|
const last = next[next.length - 1];
|
||||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||||
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
||||||
return next;
|
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
||||||
}
|
}
|
||||||
case "userPrompt":
|
case "userPrompt":
|
||||||
case "UserPrompt":
|
case "UserPrompt":
|
||||||
return appendUserPrompt(
|
return {
|
||||||
turns,
|
turns: appendUserPrompt(
|
||||||
String(raw.text ?? raw.prompt ?? ""),
|
turns,
|
||||||
attachmentLabels(raw.attachments),
|
String(raw.text ?? raw.prompt ?? ""),
|
||||||
);
|
attachmentLabels(raw.attachments),
|
||||||
|
),
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
return {
|
||||||
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
|
entityProgressMap: state.entityProgressMap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,6 +611,7 @@ export function CustomAgentChatView({
|
|||||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||||
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
||||||
);
|
);
|
||||||
|
const [entityProgressMap, setEntityProgressMap] = useState<Map<string, ReplyProgress>>(new Map());
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@ -575,6 +627,8 @@ export function CustomAgentChatView({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const turnsRef = useRef(turns);
|
||||||
|
const entityProgressMapRef = useRef(entityProgressMap);
|
||||||
const sessionRef = useRef<string | null>(sessionId);
|
const sessionRef = useRef<string | null>(sessionId);
|
||||||
sessionRef.current = currentSession;
|
sessionRef.current = currentSession;
|
||||||
const scrollbackChunkCountRef = useRef(
|
const scrollbackChunkCountRef = useRef(
|
||||||
@ -597,6 +651,25 @@ export function CustomAgentChatView({
|
|||||||
const onConversationIdRef = useRef(onConversationId);
|
const onConversationIdRef = useRef(onConversationId);
|
||||||
onConversationIdRef.current = onConversationId;
|
onConversationIdRef.current = onConversationId;
|
||||||
|
|
||||||
|
const applyChatViewState = useCallback((next: ChatViewState) => {
|
||||||
|
turnsRef.current = next.turns;
|
||||||
|
entityProgressMapRef.current = next.entityProgressMap;
|
||||||
|
setTurns(next.turns);
|
||||||
|
setEntityProgressMap(next.entityProgressMap);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateChatViewState = useCallback(
|
||||||
|
(updater: (prev: ChatViewState) => ChatViewState) => {
|
||||||
|
applyChatViewState(
|
||||||
|
updater({
|
||||||
|
turns: turnsRef.current,
|
||||||
|
entityProgressMap: entityProgressMapRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[applyChatViewState],
|
||||||
|
);
|
||||||
|
|
||||||
const supported = Boolean(
|
const supported = Boolean(
|
||||||
profile.structuredAdapter &&
|
profile.structuredAdapter &&
|
||||||
agent.launchAgentChat &&
|
agent.launchAgentChat &&
|
||||||
@ -610,6 +683,16 @@ export function CustomAgentChatView({
|
|||||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
||||||
[layoutId, nodeId, projectId],
|
[layoutId, nodeId, projectId],
|
||||||
);
|
);
|
||||||
|
const displayTurns = useMemo<ChatTurn[]>(
|
||||||
|
() => [
|
||||||
|
...turns,
|
||||||
|
...Array.from(entityProgressMap.values()).map((progress) => ({
|
||||||
|
role: "progress" as const,
|
||||||
|
progress,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[entityProgressMap, turns],
|
||||||
|
);
|
||||||
|
|
||||||
const clearPersistedDraft = useCallback(async () => {
|
const clearPersistedDraft = useCallback(async () => {
|
||||||
if (!draftScope) return;
|
if (!draftScope) return;
|
||||||
@ -645,19 +728,23 @@ export function CustomAgentChatView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const receive = useCallback((chunk: ReplyChunk) => {
|
const receive = useCallback(
|
||||||
scrollbackChunkCountRef.current += 1;
|
(chunk: ReplyChunk) => {
|
||||||
setTurns((prev) => foldChunk(prev, chunk));
|
scrollbackChunkCountRef.current += 1;
|
||||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
updateChatViewState((prev) => foldChunk(prev, chunk));
|
||||||
}, []);
|
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
||||||
|
},
|
||||||
|
[updateChatViewState],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSession) return;
|
if (!currentSession) return;
|
||||||
chatSessionViewCache.set(currentSession, {
|
chatSessionViewCache.set(currentSession, {
|
||||||
turns,
|
turns,
|
||||||
|
entityProgressMap,
|
||||||
chunkCount: scrollbackChunkCountRef.current,
|
chunkCount: scrollbackChunkCountRef.current,
|
||||||
});
|
});
|
||||||
}, [currentSession, turns]);
|
}, [currentSession, entityProgressMap, turns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draftSaveSeqRef.current += 1;
|
draftSaveSeqRef.current += 1;
|
||||||
@ -807,13 +894,13 @@ export function CustomAgentChatView({
|
|||||||
setCurrentSession(reattached.sessionId);
|
setCurrentSession(reattached.sessionId);
|
||||||
if (options.applyScrollback) {
|
if (options.applyScrollback) {
|
||||||
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
||||||
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
|
const replayed = reattached.scrollback.reduce(foldChunk, emptyChatViewState());
|
||||||
setTurns(replayed);
|
applyChatViewState(replayed);
|
||||||
setBusy(turnsIndicateBusy(replayed));
|
setBusy(turnsIndicateBusy(replayed));
|
||||||
}
|
}
|
||||||
return reattached.sessionId;
|
return reattached.sessionId;
|
||||||
},
|
},
|
||||||
[agent, receive],
|
[agent, applyChatViewState, receive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const recoverStructuredSession = useCallback(
|
const recoverStructuredSession = useCallback(
|
||||||
@ -920,7 +1007,7 @@ export function CustomAgentChatView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [turns]);
|
}, [displayTurns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selfEmittedSessionIdRef.current === sessionId) {
|
if (selfEmittedSessionIdRef.current === sessionId) {
|
||||||
@ -953,7 +1040,10 @@ export function CustomAgentChatView({
|
|||||||
const cached = chatSessionViewCache.get(externalSessionId);
|
const cached = chatSessionViewCache.get(externalSessionId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||||
setTurns(cached.turns);
|
applyChatViewState({
|
||||||
|
turns: cached.turns,
|
||||||
|
entityProgressMap: cached.entityProgressMap ?? new Map(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
|
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
|
||||||
@ -1014,6 +1104,7 @@ export function CustomAgentChatView({
|
|||||||
reattachStructuredSession,
|
reattachStructuredSession,
|
||||||
recoverStructuredSession,
|
recoverStructuredSession,
|
||||||
publishSessionId,
|
publishSessionId,
|
||||||
|
applyChatViewState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canSend = useMemo(
|
const canSend = useMemo(
|
||||||
@ -1066,7 +1157,7 @@ export function CustomAgentChatView({
|
|||||||
24,
|
24,
|
||||||
80,
|
80,
|
||||||
);
|
);
|
||||||
setTurns([]);
|
applyChatViewState(emptyChatViewState());
|
||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
publishSessionId(null);
|
publishSessionId(null);
|
||||||
onConversationIdRef.current(null);
|
onConversationIdRef.current(null);
|
||||||
@ -1093,7 +1184,10 @@ export function CustomAgentChatView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]);
|
applyChatViewState({
|
||||||
|
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }],
|
||||||
|
entityProgressMap: new Map(),
|
||||||
|
});
|
||||||
setProfileDialog(null);
|
setProfileDialog(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setProfileDialog((prev) =>
|
setProfileDialog((prev) =>
|
||||||
@ -1125,14 +1219,14 @@ export function CustomAgentChatView({
|
|||||||
});
|
});
|
||||||
if (result.effect.kind === "help") {
|
if (result.effect.kind === "help") {
|
||||||
const helpText = formatSlashCommandHelp(result.effect.commands);
|
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
turns: [...prev.turns, { role: "agent", text: helpText }],
|
||||||
{ role: "agent", text: helpText },
|
entityProgressMap: prev.entityProgressMap,
|
||||||
]);
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.effect.kind === "cleanConversation") {
|
if (result.effect.kind === "cleanConversation") {
|
||||||
setTurns([]);
|
applyChatViewState(emptyChatViewState());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.effect.kind === "profileSwitch") {
|
if (result.effect.kind === "profileSwitch") {
|
||||||
@ -1145,16 +1239,22 @@ export function CustomAgentChatView({
|
|||||||
result.effect.commandId,
|
result.effect.commandId,
|
||||||
...result.effect.arguments,
|
...result.effect.arguments,
|
||||||
);
|
);
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
turns: [
|
||||||
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
...prev.turns,
|
||||||
]);
|
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
||||||
|
],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
||||||
setError(message);
|
setError(message);
|
||||||
setTurns((prev) => [...prev, { role: "error", text: message }]);
|
updateChatViewState((prev) => ({
|
||||||
|
turns: [...prev.turns, { role: "error", text: message }],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1233,14 +1333,17 @@ export function CustomAgentChatView({
|
|||||||
void clearPersistedDraft();
|
void clearPersistedDraft();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
turns: [
|
||||||
{
|
...prev.turns,
|
||||||
role: "user",
|
{
|
||||||
text: displayText,
|
role: "user",
|
||||||
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
text: displayText,
|
||||||
},
|
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||||
]);
|
},
|
||||||
|
],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
const sendTurn = (sid: string) =>
|
const sendTurn = (sid: string) =>
|
||||||
attachmentInputs.length > 0
|
attachmentInputs.length > 0
|
||||||
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
|
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
|
||||||
@ -1267,13 +1370,19 @@ export function CustomAgentChatView({
|
|||||||
} catch (recoveryError) {
|
} catch (recoveryError) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setError(describe(recoveryError));
|
setError(describe(recoveryError));
|
||||||
setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]);
|
updateChatViewState((prev) => ({
|
||||||
|
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
|
updateChatViewState((prev) => ({
|
||||||
|
turns: [...prev.turns, { role: "error", text: describe(e) }],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1288,33 +1397,48 @@ export function CustomAgentChatView({
|
|||||||
setOpening(false);
|
setOpening(false);
|
||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
publishSessionId(null);
|
publishSessionId(null);
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => {
|
||||||
...completeRunningProgress(prev),
|
const completed = completeChatViewProgress(prev);
|
||||||
{
|
return {
|
||||||
role: "tool",
|
turns: [
|
||||||
label:
|
...completed.turns,
|
||||||
stopped.kind === "structured"
|
{
|
||||||
? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés."
|
role: "tool",
|
||||||
: "Agent arrêté.",
|
label:
|
||||||
},
|
stopped.kind === "structured"
|
||||||
]);
|
? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés."
|
||||||
|
: "Agent arrêté.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entityProgressMap: completed.entityProgressMap,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isNotFound(e)) {
|
if (isNotFound(e)) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
publishSessionId(null);
|
publishSessionId(null);
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => {
|
||||||
...completeRunningProgress(prev),
|
const completed = completeChatViewProgress(prev);
|
||||||
{ role: "tool", label: "Session déjà arrêtée." },
|
return {
|
||||||
]);
|
turns: [
|
||||||
|
...completed.turns,
|
||||||
|
{ role: "tool", label: "Session déjà arrêtée." },
|
||||||
|
],
|
||||||
|
entityProgressMap: completed.entityProgressMap,
|
||||||
|
};
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
setTurns((prev) => [
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
turns: [
|
||||||
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
...prev.turns,
|
||||||
]);
|
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
||||||
|
],
|
||||||
|
entityProgressMap: prev.entityProgressMap,
|
||||||
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setStopping(false);
|
setStopping(false);
|
||||||
}
|
}
|
||||||
@ -1449,19 +1573,19 @@ export function CustomAgentChatView({
|
|||||||
data-testid="custom-agent-chat-scroll"
|
data-testid="custom-agent-chat-scroll"
|
||||||
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
||||||
>
|
>
|
||||||
{opening && turns.length === 0 ? (
|
{opening && displayTurns.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted">
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
<Spinner size={14} />
|
<Spinner size={14} />
|
||||||
<span>
|
<span>
|
||||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : turns.length === 0 ? (
|
) : displayTurns.length === 0 ? (
|
||||||
<p className="m-auto text-xs text-muted">
|
<p className="m-auto text-xs text-muted">
|
||||||
Envoyez un message pour démarrer la conversation structurée.
|
Envoyez un message pour démarrer la conversation structurée.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
turns.map((turn, index) => (
|
displayTurns.map((turn, index) => (
|
||||||
<ChatBubble key={index} turn={turn} requesterName={agentName} />
|
<ChatBubble key={index} turn={turn} requesterName={agentName} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user