fix(custom-cli): busy/progress/tools/chatbox bloqués après le premier appel — #245 (QA verte)
Backend : StructuredHumanTurnGuard pour partager la comptabilité busy/FIFO entre chat direct humain (agent_send, web chat.send) et asks inter-agents structurés, avec liveness mark_alive et retrait idempotent du tour. Web-server : tap d'événements sur chat.send pour le scrollback temps réel. Frontend : scoping des clés de progression par tour pour éviter la déduplication/ghosting des progress et outils entre appels.
This commit is contained in:
@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { AgentProfile, ReplyChunk } from "@/domain";
|
||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
@ -912,6 +913,158 @@ describe("CustomAgentChatView", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("project-1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Main",
|
||||
profileId: "structured",
|
||||
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
stopLiveAgent: vi.fn(async () => ({
|
||||
agentId: "agent-1",
|
||||
sessionId: "chat-session-1",
|
||||
kind: "structured" as const,
|
||||
})),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system,
|
||||
workState,
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Main"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
|
||||
workState._setProjectWorkState("project-1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Main",
|
||||
profileId: "structured",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
|
||||
|
||||
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the requester as waiting on the delegated target and keeps input locked", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("project-1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
profileId: "structured",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
{
|
||||
agentId: "architect",
|
||||
name: "Architect",
|
||||
profileId: "structured",
|
||||
busy: { state: "busy", ticket: "delegated-1", sinceMs: 1 },
|
||||
tickets: [
|
||||
{
|
||||
ticketId: "delegated-1",
|
||||
conversationId: "conversation-2",
|
||||
position: 0,
|
||||
status: "inProgress",
|
||||
source: { kind: "agent", agentId: "main" },
|
||||
requesterLabel: "Main",
|
||||
taskPreview: "Analyse l'architecture",
|
||||
taskLen: 22,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
stopLiveAgent: vi.fn(async () => ({
|
||||
agentId: "main",
|
||||
sessionId: "chat-session-1",
|
||||
kind: "structured" as const,
|
||||
})),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system,
|
||||
workState,
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="main"
|
||||
agentName="Main"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
@ -1142,6 +1295,87 @@ describe("CustomAgentChatView", () => {
|
||||
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders repeated legacy tool progress as fresh activity on each turn", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async (_sessionId: string, prompt: string, onChunk) => {
|
||||
onChunk({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
source: "ideaLocal",
|
||||
kind: "mcp",
|
||||
stage: "started",
|
||||
label: "idea_context_read",
|
||||
toolName: "idea_context_read",
|
||||
text: "Lecture du contexte",
|
||||
},
|
||||
});
|
||||
onChunk({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
source: "ideaLocal",
|
||||
kind: "mcp",
|
||||
stage: "completed",
|
||||
label: "idea_context_read",
|
||||
toolName: "idea_context_read",
|
||||
text: "Lecture du contexte",
|
||||
},
|
||||
});
|
||||
onChunk({ kind: "final", content: `done ${prompt}` });
|
||||
}),
|
||||
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: "first" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await screen.findByText("done first");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
target: { value: "second" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await screen.findByText("done second");
|
||||
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||||
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders available pre-MCP progress before the first MCP card", async () => {
|
||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||
const agent = {
|
||||
|
||||
@ -73,6 +73,7 @@ type ChatTurn =
|
||||
interface CachedChatView {
|
||||
turns: ChatTurn[];
|
||||
progressIndex: Map<string, ProgressIndexEntry>;
|
||||
progressScope: number;
|
||||
chunkCount: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
@ -129,7 +130,8 @@ interface ChatErrorState {
|
||||
}
|
||||
|
||||
interface RequesterDelegationState {
|
||||
active: boolean;
|
||||
currentAgentBusy: boolean;
|
||||
delegationTargetName: string | null;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -144,6 +146,10 @@ function describeOpenFailure(e: unknown): string {
|
||||
return message || "Cause technique non disponible.";
|
||||
}
|
||||
|
||||
function workAgentDisplayName(agent: { agentId: string; name: string }): string {
|
||||
return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function structuredSessionLabel(
|
||||
adapter: AgentProfile["structuredAdapter"],
|
||||
): string | null {
|
||||
@ -310,14 +316,14 @@ function progressKey(progress: ReplyProgress): string {
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function progressUpsertKey(progress: ReplyProgress): string {
|
||||
function progressUpsertKey(progress: ReplyProgress, scope: number): string {
|
||||
const progressId = progress.progressId?.trim();
|
||||
if (progressId) return progressId;
|
||||
return `legacy:${progressDisplayKey(progress)}`;
|
||||
return `legacy:${scope}:${progressDisplayKey(progress)}`;
|
||||
}
|
||||
|
||||
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
|
||||
return turn.progressKey ?? progressUpsertKey(turn.progress);
|
||||
return turn.progressKey ?? progressUpsertKey(turn.progress, 0);
|
||||
}
|
||||
|
||||
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
||||
@ -352,11 +358,11 @@ function progressDisplayKey(progress: ReplyProgress): string {
|
||||
return progressKey(progress);
|
||||
}
|
||||
|
||||
function legacyActiveProgressKey(progress: ReplyProgress): string | null {
|
||||
function legacyActiveProgressKey(progress: ReplyProgress, scope: number): string | null {
|
||||
const entity = progressEntityKey(progress);
|
||||
if (!entity) return null;
|
||||
if (progress.stage !== "started" && progress.stage !== "delta") return null;
|
||||
return `legacy:${entity}\u0000started`;
|
||||
return `legacy:${scope}:${entity}\u0000started`;
|
||||
}
|
||||
|
||||
function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
|
||||
@ -380,17 +386,19 @@ function findLastProgressIndex(
|
||||
function legacyCompletedOccurrenceKey(
|
||||
progress: ReplyProgress,
|
||||
entry: ProgressIndexEntry,
|
||||
scope: number,
|
||||
): string {
|
||||
return `legacy-completed:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
|
||||
return `legacy-completed:${scope}:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
|
||||
}
|
||||
|
||||
interface ChatViewState {
|
||||
turns: ChatTurn[];
|
||||
progressIndex: Map<string, ProgressIndexEntry>;
|
||||
progressScope: number;
|
||||
}
|
||||
|
||||
function emptyChatViewState(): ChatViewState {
|
||||
return { turns: [], progressIndex: new Map() };
|
||||
return { turns: [], progressIndex: new Map(), progressScope: 0 };
|
||||
}
|
||||
|
||||
function conversationCacheKey(
|
||||
@ -402,14 +410,14 @@ function conversationCacheKey(
|
||||
}
|
||||
|
||||
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||
let key = progressUpsertKey(progress);
|
||||
let key = progressUpsertKey(progress, state.progressScope);
|
||||
let existingIndex = state.turns.findIndex(
|
||||
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
|
||||
);
|
||||
|
||||
const activeKey =
|
||||
!progress.progressId && progress.stage === "completed"
|
||||
? legacyActiveProgressKey({ ...progress, stage: "started" })
|
||||
? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope)
|
||||
: null;
|
||||
if (activeKey) {
|
||||
const activeIndex = state.turns.findIndex(
|
||||
@ -425,7 +433,9 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
|
||||
if (completedDedupKey) {
|
||||
existingIndex = findLastProgressIndex(
|
||||
state.turns,
|
||||
(turn) => legacyCompletedDedupKey(turn.progress) === completedDedupKey,
|
||||
(turn) =>
|
||||
turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) &&
|
||||
legacyCompletedDedupKey(turn.progress) === completedDedupKey,
|
||||
);
|
||||
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
|
||||
if (existingTurn?.role === "progress") {
|
||||
@ -452,7 +462,7 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
|
||||
const nextEntry = { firstOccurredAtMs, sequence };
|
||||
let nextKey = key;
|
||||
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
|
||||
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry);
|
||||
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope);
|
||||
nextIndex.delete(activeKey);
|
||||
}
|
||||
nextIndex.set(nextKey, nextEntry);
|
||||
@ -460,7 +470,7 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
|
||||
if (existingIndex >= 0) {
|
||||
const nextTurns = [...state.turns];
|
||||
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
|
||||
return { turns: nextTurns, progressIndex: nextIndex };
|
||||
return { ...state, turns: nextTurns, progressIndex: nextIndex };
|
||||
}
|
||||
|
||||
const insertAt = state.turns.findIndex((turn) => {
|
||||
@ -474,9 +484,10 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
|
||||
});
|
||||
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
|
||||
if (insertAt < 0) {
|
||||
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
||||
return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
turns: [
|
||||
...state.turns.slice(0, insertAt),
|
||||
nextTurn,
|
||||
@ -549,8 +560,8 @@ function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||
stage: "completed" as const,
|
||||
};
|
||||
const nextKey = turn.progress.progressId
|
||||
? progressUpsertKey(completedProgress)
|
||||
: legacyCompletedOccurrenceKey(completedProgress, entry);
|
||||
? progressUpsertKey(completedProgress, state.progressScope)
|
||||
: legacyCompletedOccurrenceKey(completedProgress, entry, state.progressScope);
|
||||
if (previousKey !== nextKey) nextIndex.delete(previousKey);
|
||||
nextIndex.set(nextKey, entry);
|
||||
|
||||
@ -569,7 +580,7 @@ function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||
};
|
||||
});
|
||||
|
||||
return { turns, progressIndex: nextIndex };
|
||||
return { ...state, turns, progressIndex: nextIndex };
|
||||
}
|
||||
|
||||
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
||||
@ -751,8 +762,8 @@ function foldChunk(
|
||||
const { turns } = state;
|
||||
if (!isReplyRecord(raw)) {
|
||||
return {
|
||||
...state,
|
||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
const last = turns[turns.length - 1];
|
||||
@ -777,26 +788,26 @@ function foldChunk(
|
||||
switch (raw.kind) {
|
||||
case "textDelta":
|
||||
return {
|
||||
...state,
|
||||
turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
case "toolActivity":
|
||||
return {
|
||||
...state,
|
||||
turns: [
|
||||
...turns,
|
||||
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
|
||||
],
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
case "progress": {
|
||||
const progress = normalizeProgress(raw.progress);
|
||||
if (!progress) {
|
||||
return {
|
||||
...state,
|
||||
turns: [
|
||||
...turns,
|
||||
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
|
||||
],
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
return appendProgress(state, progress);
|
||||
@ -812,7 +823,7 @@ function foldChunk(
|
||||
text: content,
|
||||
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
|
||||
});
|
||||
return { turns: next, progressIndex: completedState.progressIndex };
|
||||
return { ...completedState, turns: next };
|
||||
}
|
||||
case "error": {
|
||||
const completedState = completeChatViewProgress(state);
|
||||
@ -824,23 +835,24 @@ function foldChunk(
|
||||
text: String(raw.message ?? "Erreur agent"),
|
||||
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
|
||||
});
|
||||
return { turns: next, progressIndex: completedState.progressIndex };
|
||||
return { ...completedState, turns: next };
|
||||
}
|
||||
case "userPrompt":
|
||||
case "UserPrompt":
|
||||
return {
|
||||
...state,
|
||||
turns: appendUserPrompt(
|
||||
turns,
|
||||
String(raw.text ?? raw.prompt ?? ""),
|
||||
attachmentLabels(raw.attachments),
|
||||
receivedAtMs,
|
||||
),
|
||||
progressIndex: state.progressIndex,
|
||||
progressScope: state.progressScope + 1,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...state,
|
||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -887,6 +899,14 @@ export function CustomAgentChatView({
|
||||
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
|
||||
: new Map(),
|
||||
);
|
||||
const [progressScope, setProgressScope] = useState<number>(
|
||||
() =>
|
||||
sessionId
|
||||
? (chatSessionViewCache.get(sessionId)?.progressScope ?? 0)
|
||||
: initialConversationCacheKey
|
||||
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressScope ?? 0)
|
||||
: 0,
|
||||
);
|
||||
const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
@ -909,6 +929,7 @@ export function CustomAgentChatView({
|
||||
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const turnsRef = useRef(turns);
|
||||
const progressIndexRef = useRef(progressIndex);
|
||||
const progressScopeRef = useRef(progressScope);
|
||||
const sessionRef = useRef<string | null>(sessionId);
|
||||
sessionRef.current = currentSession;
|
||||
const scrollbackChunkCountRef = useRef(
|
||||
@ -1001,8 +1022,10 @@ export function CustomAgentChatView({
|
||||
const applyChatViewState = useCallback((next: ChatViewState) => {
|
||||
turnsRef.current = next.turns;
|
||||
progressIndexRef.current = next.progressIndex;
|
||||
progressScopeRef.current = next.progressScope;
|
||||
setTurns(next.turns);
|
||||
setProgressIndex(next.progressIndex);
|
||||
setProgressScope(next.progressScope);
|
||||
}, []);
|
||||
|
||||
const updateChatViewState = useCallback(
|
||||
@ -1011,6 +1034,7 @@ export function CustomAgentChatView({
|
||||
updater({
|
||||
turns: turnsRef.current,
|
||||
progressIndex: progressIndexRef.current,
|
||||
progressScope: progressScopeRef.current,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@ -1037,15 +1061,32 @@ export function CustomAgentChatView({
|
||||
const displayTurns = turns;
|
||||
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
||||
const [requesterDelegation, setRequesterDelegation] =
|
||||
useState<RequesterDelegationState>({ active: false });
|
||||
useState<RequesterDelegationState>({
|
||||
currentAgentBusy: false,
|
||||
delegationTargetName: null,
|
||||
});
|
||||
const delegationActive = requesterDelegation.delegationTargetName !== null;
|
||||
const effectiveBusy =
|
||||
busy || requesterDelegation.currentAgentBusy || delegationActive;
|
||||
const delegationFallbackText = requesterDelegation.delegationTargetName
|
||||
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
||||
: "Délégation en cours.";
|
||||
|
||||
const refreshRequesterDelegation = useCallback(async () => {
|
||||
if (!workState) {
|
||||
setRequesterDelegation({ active: false });
|
||||
setRequesterDelegation({
|
||||
currentAgentBusy: false,
|
||||
delegationTargetName: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await workState.getProjectWorkState(projectId);
|
||||
const current = state.agents.find((row) => row.agentId === agentId);
|
||||
const currentAgentBusy = Boolean(
|
||||
current?.busy.state === "busy" ||
|
||||
current?.tickets.some((ticket) => ticket.status === "inProgress"),
|
||||
);
|
||||
const target = state.agents.find((row) =>
|
||||
row.tickets.some(
|
||||
(ticket) =>
|
||||
@ -1054,13 +1095,15 @@ export function CustomAgentChatView({
|
||||
ticket.source.agentId === agentId,
|
||||
),
|
||||
);
|
||||
setRequesterDelegation(
|
||||
target
|
||||
? { active: true }
|
||||
: { active: false },
|
||||
);
|
||||
setRequesterDelegation({
|
||||
currentAgentBusy,
|
||||
delegationTargetName: target ? workAgentDisplayName(target) : null,
|
||||
});
|
||||
} catch {
|
||||
setRequesterDelegation({ active: false });
|
||||
setRequesterDelegation({
|
||||
currentAgentBusy: false,
|
||||
delegationTargetName: null,
|
||||
});
|
||||
}
|
||||
}, [agentId, projectId, workState]);
|
||||
|
||||
@ -1101,6 +1144,17 @@ export function CustomAgentChatView({
|
||||
event.type === "agentInboxChanged" ||
|
||||
event.type === "agentWakeChanged"
|
||||
) {
|
||||
if (
|
||||
event.type === "agentBusyChanged" &&
|
||||
event.agentId === agentId &&
|
||||
!event.busy
|
||||
) {
|
||||
if (activeInteractionIdRef.current) {
|
||||
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
||||
activeInteractionIdRef.current = null;
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
void refreshRequesterDelegation();
|
||||
}
|
||||
})
|
||||
@ -1112,7 +1166,7 @@ export function CustomAgentChatView({
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refreshRequesterDelegation, system]);
|
||||
}, [agentId, projectId, refreshRequesterDelegation, system]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@ -1165,6 +1219,7 @@ export function CustomAgentChatView({
|
||||
chatSessionViewCache.set(currentSession, {
|
||||
turns,
|
||||
progressIndex,
|
||||
progressScope,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
sessionId: currentSession,
|
||||
});
|
||||
@ -1176,12 +1231,13 @@ export function CustomAgentChatView({
|
||||
chatConversationViewCache.set(currentConversationCacheKey, {
|
||||
turns,
|
||||
progressIndex,
|
||||
progressScope,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentConversationCacheKey, currentSession, progressIndex, turns]);
|
||||
}, [currentConversationCacheKey, currentSession, progressIndex, progressScope, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
draftSaveSeqRef.current += 1;
|
||||
@ -1261,7 +1317,7 @@ export function CustomAgentChatView({
|
||||
!supported ||
|
||||
!agent.listSlashCommands ||
|
||||
slashPrefix === null ||
|
||||
busy ||
|
||||
effectiveBusy ||
|
||||
opening
|
||||
) {
|
||||
setSlashMenuOpen(false);
|
||||
@ -1286,7 +1342,7 @@ export function CustomAgentChatView({
|
||||
setSlashMenuOpen(false);
|
||||
setSlashActiveIndex(0);
|
||||
});
|
||||
}, [agent, busy, opening, slashPrefix, supported]);
|
||||
}, [agent, effectiveBusy, opening, slashPrefix, supported]);
|
||||
|
||||
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
||||
if (!isSlashCommandAvailable(command)) return;
|
||||
@ -1492,6 +1548,7 @@ export function CustomAgentChatView({
|
||||
applyChatViewState({
|
||||
turns: cached.turns,
|
||||
progressIndex: cached.progressIndex ?? new Map(),
|
||||
progressScope: cached.progressScope ?? 0,
|
||||
});
|
||||
}
|
||||
try {
|
||||
@ -1526,6 +1583,7 @@ export function CustomAgentChatView({
|
||||
applyChatViewState({
|
||||
turns: cached.turns,
|
||||
progressIndex: cached.progressIndex ?? new Map(),
|
||||
progressScope: cached.progressScope ?? 0,
|
||||
});
|
||||
if (cached.sessionId) {
|
||||
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
|
||||
@ -1588,9 +1646,9 @@ export function CustomAgentChatView({
|
||||
() =>
|
||||
supported &&
|
||||
(Boolean(draft.trim()) || attachments.length > 0) &&
|
||||
!busy &&
|
||||
!effectiveBusy &&
|
||||
!opening,
|
||||
[supported, draft, attachments.length, busy, opening],
|
||||
[supported, draft, attachments.length, effectiveBusy, opening],
|
||||
);
|
||||
|
||||
async function openProfileDialog() {
|
||||
@ -1670,6 +1728,7 @@ export function CustomAgentChatView({
|
||||
},
|
||||
],
|
||||
progressIndex: new Map(),
|
||||
progressScope: 0,
|
||||
});
|
||||
setProfileDialog(null);
|
||||
} catch (e) {
|
||||
@ -1706,8 +1765,8 @@ export function CustomAgentChatView({
|
||||
if (result.effect.kind === "help") {
|
||||
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1726,6 +1785,7 @@ export function CustomAgentChatView({
|
||||
...result.effect.arguments,
|
||||
);
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [
|
||||
...prev.turns,
|
||||
{
|
||||
@ -1734,7 +1794,6 @@ export function CustomAgentChatView({
|
||||
receivedAtMs: Date.now(),
|
||||
},
|
||||
],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1742,8 +1801,8 @@ export function CustomAgentChatView({
|
||||
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
||||
setError({ message, openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1859,6 +1918,7 @@ export function CustomAgentChatView({
|
||||
setError(null);
|
||||
const sentAtMs = Date.now();
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [
|
||||
...prev.turns,
|
||||
{
|
||||
@ -1868,7 +1928,7 @@ export function CustomAgentChatView({
|
||||
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||
},
|
||||
],
|
||||
progressIndex: prev.progressIndex,
|
||||
progressScope: prev.progressScope + 1,
|
||||
}));
|
||||
const sendTurn = (sid: string) =>
|
||||
attachmentInputs.length > 0
|
||||
@ -1900,11 +1960,11 @@ export function CustomAgentChatView({
|
||||
setBusy(false);
|
||||
setError({ message: describe(recoveryError), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [
|
||||
...prev.turns,
|
||||
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
|
||||
],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1913,8 +1973,8 @@ export function CustomAgentChatView({
|
||||
setBusy(false);
|
||||
setError({ message: describe(e), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1937,6 +1997,7 @@ export function CustomAgentChatView({
|
||||
updateChatViewState((prev) => {
|
||||
const completed = completeChatViewProgress(prev);
|
||||
return {
|
||||
...completed,
|
||||
turns: [
|
||||
...completed.turns,
|
||||
{
|
||||
@ -1948,7 +2009,6 @@ export function CustomAgentChatView({
|
||||
receivedAtMs: Date.now(),
|
||||
},
|
||||
],
|
||||
progressIndex: completed.progressIndex,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
@ -1964,17 +2024,18 @@ export function CustomAgentChatView({
|
||||
updateChatViewState((prev) => {
|
||||
const completed = completeChatViewProgress(prev);
|
||||
return {
|
||||
...completed,
|
||||
turns: [
|
||||
...completed.turns,
|
||||
{ role: "tool", label: "Session déjà arrêtée.", receivedAtMs: Date.now() },
|
||||
],
|
||||
progressIndex: completed.progressIndex,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError({ message: describe(e), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
...prev,
|
||||
turns: [
|
||||
...prev.turns,
|
||||
{
|
||||
@ -1983,7 +2044,6 @@ export function CustomAgentChatView({
|
||||
receivedAtMs: Date.now(),
|
||||
},
|
||||
],
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
} finally {
|
||||
setStopping(false);
|
||||
@ -2159,8 +2219,8 @@ export function CustomAgentChatView({
|
||||
<div className="shrink-0">
|
||||
<AnnouncementsPreview
|
||||
requester={agentId}
|
||||
fallbackActive={requesterDelegation.active}
|
||||
fallbackText="Délégation en cours."
|
||||
fallbackActive={delegationActive}
|
||||
fallbackText={delegationFallbackText}
|
||||
/>
|
||||
</div>
|
||||
{opening && displayTurns.length === 0 ? (
|
||||
@ -2286,7 +2346,7 @@ export function CustomAgentChatView({
|
||||
)}
|
||||
rows={2}
|
||||
value={draft}
|
||||
disabled={!supported || opening || busy}
|
||||
disabled={!supported || opening || effectiveBusy}
|
||||
placeholder="Message à l'agent…"
|
||||
style={{ height: clampComposerHeight(composerHeightPx) }}
|
||||
onChange={(e) => editDraft(e.target.value)}
|
||||
@ -2343,24 +2403,24 @@ export function CustomAgentChatView({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
disabled={!supported || opening || busy}
|
||||
disabled={!supported || opening || effectiveBusy}
|
||||
onClick={() => void pickAttachment()}
|
||||
>
|
||||
Joindre
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={busy ? "danger" : undefined}
|
||||
variant={effectiveBusy ? "danger" : undefined}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
disabled={
|
||||
busy
|
||||
effectiveBusy
|
||||
? !supported || !currentSession || !agent.stopLiveAgent || stopping
|
||||
: !canSend
|
||||
}
|
||||
loading={stopping}
|
||||
onClick={() => (busy ? void stopAgent() : void send())}
|
||||
onClick={() => (effectiveBusy ? void stopAgent() : void send())}
|
||||
>
|
||||
{stopping ? "Arrêt en cours..." : busy ? "Stop" : "Envoyer"}
|
||||
{stopping ? "Arrêt en cours..." : effectiveBusy ? "Stop" : "Envoyer"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user