diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
index 73b987cf8..22ed7d4c0 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
@@ -168,9 +168,12 @@ function TurnStreamSequence({
active,
userSentAt,
className,
+ toolsOnly = false,
}: {
items: readonly TurnStreamItem[];
toolCalls: readonly GameCreatorDirectToolCall[];
+ /** 只渲染工具块:当流的文本不完整(老数据被截断)时用,正文交给持久化消息渲染。 */
+ toolsOnly?: boolean;
/** 这个回合是否正在跑(决定工具行显示"执行中")。 */
active: boolean;
userSentAt: number;
@@ -188,6 +191,7 @@ function TurnStreamSequence({
<>
{runs.map((run, index) =>
run.kind === 'text' ? (
+ toolsOnly ? null : (
+ )
) : (
({ turnId, calls }));
+ // 历史消息的 id 是 Codex 原始 id(不是 `direct-codex::...`),无法从 id 反解回合;
+ // 因此按位置配对:回合按首次出现时间排序后,第 N 个回合 ↔ 第 N 个用户消息。
+ const turnFirstAt = (turnId: string) => {
+ let first = Number.POSITIVE_INFINITY;
+ for (const item of turnStreamByTurn.get(turnId) ?? []) {
+ first = Math.min(first, Number(item.at) || Number.POSITIVE_INFINITY);
+ }
+ for (const call of toolCalls) {
+ if (call.turnId === turnId) {
+ first = Math.min(first, Number(call.startedAt) || Number.POSITIVE_INFINITY);
+ }
+ }
+ return Number.isFinite(first) ? first : 0;
+ };
+ const orderedStreamTurnIds = [
+ ...new Set([
+ ...turnStreamByTurn.keys(),
+ ...toolCalls.map((call) => call.turnId ?? ''),
+ ]),
+ ]
+ .filter((turnId) => Boolean(turnId.trim()))
+ .sort((left, right) => turnFirstAt(left) - turnFirstAt(right));
+ const turnIdByMessageIndex = new Map();
+ {
+ let userIndex = -1;
+ visibleMessages.forEach((message, index) => {
+ if (message.role === 'user') {
+ userIndex += 1;
+ }
+ const turnId = orderedStreamTurnIds[userIndex];
+ if (turnId && userIndex >= 0) {
+ turnIdByMessageIndex.set(index, turnId);
+ }
+ });
+ }
+ // 每个回合的 assistant 原文长度:用于判断流的文本是否完整(老数据可能被截断)。
+ const assistantTextLengthByTurn = new Map();
+ visibleMessages.forEach((message, index) => {
+ if (message.role !== 'assistant') {
+ return;
+ }
+ const turnId = turnIdByMessageIndex.get(index);
+ if (!turnId) {
+ return;
+ }
+ const length = projectSupervisorChatMessageText(message).length;
+ assistantTextLengthByTurn.set(
+ turnId,
+ (assistantTextLengthByTurn.get(turnId) ?? 0) + length,
+ );
+ });
+ const streamTextLengthByTurn = new Map();
+ for (const [turnId, items] of turnStreamByTurn) {
+ let total = 0;
+ for (const item of items) {
+ if (item.kind === 'text') {
+ total += item.text?.length ?? 0;
+ }
+ }
+ streamTextLengthByTurn.set(turnId, total);
+ }
+ const mappedStreamTurnIds = new Set(turnIdByMessageIndex.values());
+ const unmappedStreamTurns = orderedStreamTurnIds.filter(
+ (turnId) =>
+ !mappedStreamTurnIds.has(turnId) &&
+ (turnStreamByTurn.get(turnId)?.length ?? 0) > 0,
+ );
+
// 该回合用户消息的 `updatedAt`:拿得到就在块头显示「发送 → 结束」,拿不到只显示结束时间。
const userMessageUpdatedAtForTurn = (turnId: string) => {
if (!turnId) {
@@ -684,11 +757,30 @@ export function ProjectSupervisorView({
const userTurnId = message.messageId
? directCodexTurnIdFromUserMessageId(message.messageId)
: null;
- const streamTurnIdForMessage = userTurnId ?? anchoredTurnId;
+ const streamTurnIdForMessage =
+ (userTurnId ?? anchoredTurnId) ??
+ turnIdByMessageIndex.get(index) ??
+ null;
const turnStream = streamTurnIdForMessage
? (turnStreamByTurn.get(streamTurnIdForMessage) ?? [])
: [];
const streamCovered = turnStream.length > 0;
+ // 有流时只在用户消息这一格渲染流(用户消息 → 文本/工具交替,按 seq 顺序);
+ // 该回合的 assistant 消息内容已经在流里,跳过以免重复。
+ const streamTextLength = streamTurnIdForMessage
+ ? (streamTextLengthByTurn.get(streamTurnIdForMessage) ?? 0)
+ : 0;
+ const assistantTextLength = streamTurnIdForMessage
+ ? (assistantTextLengthByTurn.get(streamTurnIdForMessage) ?? 0)
+ : 0;
+ // 流的文本明显短于持久化原文 → 视为不完整:流只画工具块,正文由消息渲染。
+ const streamTextIncomplete =
+ streamCovered &&
+ assistantTextLength > 0 &&
+ streamTextLength < assistantTextLength * 0.8;
+ const ownsStream = streamCovered && message.role === 'user';
+ const suppressForStream =
+ streamCovered && message.role !== 'user' && !streamTextIncomplete;
const nextTurnId =
index + 1 < visibleMessages.length &&
visibleMessages[index + 1]?.messageId
@@ -714,10 +806,11 @@ export function ProjectSupervisorView({
) : null}
{/* 有回合流的回合:assistant 消息这一格交给流自己渲染——文本段 → 工具块 →
文本段 → 最终回复,按条目的 `seq` 顺序出现,不再把工具抽到消息前面。 */}
- {streamCovered ? (
+ {suppressForStream ? null : ownsStream ? (
);
})}
+ {unmappedStreamTurns.map((turnId) => (
+
+ ))}
{unanchoredToolCalls.map(({ turnId, calls }) => (