fix(cli): préserve l'affichage des progress legacy sans progressId via clés d'occurrence dédiées — #236

This commit is contained in:
2026-08-21 17:47:05 +02:00
parent fe45b93633
commit aaf628f1ff
2 changed files with 229 additions and 41 deletions

View File

@ -64,7 +64,7 @@ type ChatTurn =
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
| { role: "tool"; label: string; receivedAtMs?: number }
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
| { role: "final"; text: string; receivedAtMs?: number }
| { role: "error"; text: string; receivedAtMs?: number }
| { role: "unknown"; text: string; receivedAtMs?: number };
@ -291,11 +291,13 @@ function progressKey(progress: ReplyProgress): string {
function progressUpsertKey(progress: ReplyProgress): string {
const progressId = progress.progressId?.trim();
if (progressId) return progressId;
const entity = progressEntityKey(progress);
if (entity) return `legacy-entity:${entity}`;
return `legacy:${progressDisplayKey(progress)}`;
}
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
return turn.progressKey ?? progressUpsertKey(turn.progress);
}
function normalizedProgressToolName(progress: ReplyProgress): string | null {
const name = progress.toolName ?? progress.label;
const normalized = name.trim().toLowerCase();
@ -328,6 +330,38 @@ function progressDisplayKey(progress: ReplyProgress): string {
return progressKey(progress);
}
function legacyActiveProgressKey(progress: ReplyProgress): string | null {
const entity = progressEntityKey(progress);
if (!entity) return null;
if (progress.stage !== "started" && progress.stage !== "delta") return null;
return `legacy:${entity}\u0000started`;
}
function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
const entity = progressEntityKey(progress);
if (!entity || progress.stage !== "completed") return null;
if (isDelegationProgress(progress)) return entity;
return `${entity}\u0000${progress.text ?? ""}`;
}
function findLastProgressIndex(
turns: ChatTurn[],
predicate: (turn: Extract<ChatTurn, { role: "progress" }>) => boolean,
): number {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "progress" && predicate(turn)) return index;
}
return -1;
}
function legacyCompletedOccurrenceKey(
progress: ReplyProgress,
entry: ProgressIndexEntry,
): string {
return `legacy-completed:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
}
interface ChatViewState {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
@ -346,13 +380,41 @@ function conversationCacheKey(
}
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
const key = progressUpsertKey(progress);
const existingIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key,
let key = progressUpsertKey(progress);
let existingIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
);
const activeKey =
!progress.progressId && progress.stage === "completed"
? legacyActiveProgressKey({ ...progress, stage: "started" })
: null;
if (activeKey) {
const activeIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey,
);
if (activeIndex >= 0) {
existingIndex = activeIndex;
key = activeKey;
}
}
const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null;
if (completedDedupKey) {
existingIndex = findLastProgressIndex(
state.turns,
(turn) => legacyCompletedDedupKey(turn.progress) === completedDedupKey,
);
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
if (existingTurn?.role === "progress") {
key = turnProgressKey(existingTurn);
}
}
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
const existingProgress =
existingIndex >= 0 && state.turns[existingIndex].role === "progress"
? state.turns[existingIndex].progress
existingTurn?.role === "progress"
? existingTurn.progress
: null;
const merged =
existingProgress && progress.stage === "completed"
@ -365,24 +427,30 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView
progress.occurredAtMs ??
Date.now();
const sequence = existingEntry?.sequence ?? nextIndex.size;
nextIndex.set(key, { firstOccurredAtMs, sequence });
const nextEntry = { firstOccurredAtMs, sequence };
let nextKey = key;
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry);
nextIndex.delete(activeKey);
}
nextIndex.set(nextKey, nextEntry);
if (existingIndex >= 0) {
const nextTurns = [...state.turns];
nextTurns[existingIndex] = { role: "progress", progress: merged };
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
return { turns: nextTurns, progressIndex: nextIndex };
}
const insertAt = state.turns.findIndex((turn) => {
if (turn.role !== "progress") return false;
const candidate = state.progressIndex.get(progressUpsertKey(turn.progress));
const candidate = state.progressIndex.get(turnProgressKey(turn));
if (!candidate) return false;
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
return candidate.firstOccurredAtMs > firstOccurredAtMs;
}
return candidate.sequence > sequence;
});
const nextTurn = { role: "progress" as const, progress: merged };
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
if (insertAt < 0) {
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
}
@ -408,9 +476,32 @@ function mergeCompletedProgress(
};
}
function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
function progressShouldSettle(
turn: Extract<ChatTurn, { role: "progress" }>,
completedEntities: Set<string | null>,
): boolean {
if (
turn.progress.kind === "turn" ||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
) {
return false;
}
const entity = progressEntityKey(turn.progress);
return Boolean(entity && completedEntities.has(entity));
}
function shouldCompleteProgress(
turn: Extract<ChatTurn, { role: "progress" }>,
): boolean {
return (
turn.progress.kind !== "turn" &&
(turn.progress.stage === "started" || turn.progress.stage === "delta")
);
}
function completeChatViewProgress(state: ChatViewState): ChatViewState {
const completedEntities = new Set(
turns
state.turns
.filter(
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
turn.role === "progress" &&
@ -419,40 +510,44 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
)
.map((turn) => progressEntityKey(turn.progress)),
);
return turns.map((turn) => {
if (
turn.role !== "progress" ||
turn.progress.kind === "turn" ||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
) {
return turn;
}
const entity = progressEntityKey(turn.progress);
if (entity && completedEntities.has(entity)) {
const nextIndex = new Map(state.progressIndex);
const turns = state.turns.map((turn) => {
if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn;
const previousKey = turnProgressKey(turn);
const previousEntry = nextIndex.get(previousKey);
const entry =
previousEntry ?? {
firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(),
sequence: nextIndex.size,
};
const completedProgress = {
...turn.progress,
stage: "completed" as const,
};
const nextKey = turn.progress.progressId
? progressUpsertKey(completedProgress)
: legacyCompletedOccurrenceKey(completedProgress, entry);
if (previousKey !== nextKey) nextIndex.delete(previousKey);
nextIndex.set(nextKey, entry);
if (progressShouldSettle(turn, completedEntities)) {
return {
...turn,
settled: true,
progress: {
...turn.progress,
stage: "completed",
},
progress: completedProgress,
progressKey: nextKey,
};
}
return {
...turn,
progress: {
...turn.progress,
stage: "completed",
},
progress: completedProgress,
progressKey: nextKey,
};
});
}
function completeChatViewProgress(state: ChatViewState): ChatViewState {
return {
turns: completeRunningProgress(state.turns),
progressIndex: state.progressIndex,
};
return { turns, progressIndex: nextIndex };
}
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
@ -2206,7 +2301,7 @@ function ChatBubble({
);
}
if (turn.role === "progress") {
const key = progressUpsertKey(turn.progress);
const key = turnProgressKey(turn);
return (
<ProgressBubble
progress={turn.progress}