diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index 839b37053..8e3515ace 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -18,6 +18,7 @@ import { useDirectProjectChatController, } from './controller/useDirectProjectChatController'; import { useDirectProjectManifest } from './controller/useDirectProjectManifest'; +import { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus'; import { createDirectProjectTurnId, directCodexConversationMessageId, @@ -122,6 +123,7 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, + pendingUserItemId, queuedTurns, removeAttachment, startInitialTurn, @@ -131,7 +133,20 @@ export function DirectProjectChatView({ turnCancelling, uploadFiles, } = chat; - const busy = turnBusy || directTurnRunning; + const directTurns = buildDirectChatTurns({ + entries: directEntries, + localMessages, + turnRunning: directTurnRunning, + pendingUserItemId, + }); + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 + const turnStatus = useDirectProjectTurnStatus({ + turnRunning: directTurnRunning, + turnBusy, + turns: directTurns, + }); + const activeTurnStartedAt = + directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0; const statusText = runtimeNotice || statusNotice || @@ -144,7 +159,7 @@ export function DirectProjectChatView({ return; if (projectPath !== initialTurn.projectPath) return; // projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。 - if (!projectId || busy) return; + if (!projectId || turnStatus.displayBusy) return; if ( !claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope) ) { @@ -165,9 +180,9 @@ export function DirectProjectChatView({ : {}), userItem, }); - // 首轮需求只由入口 payload、项目身份、清单就绪和回合态驱动。 + // 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [busy, initialTurn, projectId, projectPath]); + }, [turnStatus.displayBusy, initialTurn, projectId, projectPath]); useEffect(() => { if (!shouldFollowLatestRef.current) return; @@ -175,14 +190,6 @@ export function DirectProjectChatView({ if (list) list.scrollTop = list.scrollHeight; }, [directEntries, localMessages]); - const directTurns = buildDirectChatTurns({ - entries: directEntries, - localMessages, - turnRunning: directTurnRunning, - }); - const activeTurnStartedAt = - directTurns.find((turn) => turn.active)?.startedAt ?? 0; - useImperativeHandle(ref, () => ({ announce: (text: string) => { appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); @@ -206,7 +213,7 @@ export function DirectProjectChatView({ >
setSettingsOpen(true)} /> @@ -214,7 +221,7 @@ export function DirectProjectChatView({ turns={directTurns} messagesRef={messagesRef} historyHasMore={historyHasMore} - running={directTurnRunning} + nativeRunning={turnStatus.nativeRunning} activeTurnStartedAt={activeTurnStartedAt} onLoadEarlierHistory={() => void loadEarlierHistory()} onScroll={handleScroll} @@ -227,7 +234,7 @@ export function DirectProjectChatView({ attachmentNotice={attachmentNotice} queuedTurns={queuedTurns} composerNotice={composerNotice} - busy={busy} + busy={turnStatus.displayBusy} turnCancelling={turnCancelling} onCancelQueuedTurn={cancelQueuedTurn} onCancelTurn={() => void cancelTurn()} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index b982c84ae..fec7a00e4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -19,7 +19,7 @@ export function DirectProjectConversation({ turns, messagesRef, historyHasMore, - running, + nativeRunning, activeTurnStartedAt, onLoadEarlierHistory, onScroll, @@ -27,7 +27,11 @@ export function DirectProjectConversation({ turns: DirectChatTurn[]; messagesRef: RefObject; historyHasMore: boolean; - running: boolean; + /** + * 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。 + * 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。 + */ + nativeRunning: boolean; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; onScroll: UIEventHandler; @@ -53,7 +57,7 @@ export function DirectProjectConversation({ ))}
- {running ? ( + {nativeRunning ? ( block.kind === 'assistant') - ?.key ?? null) - : null; + const streamingKey = + turn.state === 'running' + ? ([...turn.process].reverse().find((block) => block.kind === 'assistant') + ?.key ?? null) + : null; return ( {turn.users.map((block) => @@ -48,7 +49,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { const blocks = turn.process.map((block) => renderBlock(turn, block, 'process', streamingKey), ); - if (turn.active) return blocks; + if (turn.state === 'running') return blocks; return (
执行过程 @@ -58,7 +59,9 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - if (turn.active || !turn.startedAt) return null; + // 已知缺陷(另开改动):`awaiting-start` 目前与 `finished` 同渲染,所以本地已发出、 + // 原生还没认领的那个窗口里会显示「本轮结束于 … · 耗时 0.0秒」。 + if (turn.state === 'running' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index d368e8bb5..0ec2935c6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -113,6 +113,9 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); + // 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态, + // 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。 + const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -143,6 +146,7 @@ export function useDirectProjectChatController({ setQueuedTurns([]); queuedTurnsRef.current = []; setLocalMessages([]); + setPendingUserItemId(''); setHistoryHasMore(false); historyOldestItemIdRef.current = null; }, [projectPath]); @@ -194,9 +198,21 @@ export function useDirectProjectChatController({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, projectPath]); - function markTurnBusy(busy: boolean) { - turnBusyRef.current = busy; - setTurnBusy(busy); + /** + * 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。 + * + * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 + */ + function beginTurnCommand(userItemId: string) { + turnBusyRef.current = true; + setTurnBusy(true); + setPendingUserItemId(userItemId); + } + + function endTurnCommand() { + turnBusyRef.current = false; + setTurnBusy(false); + setPendingUserItemId(''); } function appendLocalMessage(message: ChatMessage) { @@ -388,7 +404,9 @@ export function useDirectProjectChatController({ updatedAt: Date.now(), }); } - markTurnBusy(true); + beginTurnCommand( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ); void (async () => { let invoked = false; try { @@ -406,7 +424,7 @@ export function useDirectProjectChatController({ // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), // 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把 // 忙态放下并继续出队,否则后面的排队消息会永久卡住。 - markTurnBusy(false); + endTurnCommand(); dispatchNextQueuedTurn(); return; } @@ -429,7 +447,7 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - markTurnBusy(false); + endTurnCommand(); if (invoked && projectPathRef.current === nextProjectPath) { dispatchNextQueuedTurn(); } @@ -541,7 +559,7 @@ export function useDirectProjectChatController({ const message = result?.message?.trim(); if (result?.outcome === 'released') { directThread.markTurnStopped(); - markTurnBusy(false); + endTurnCommand(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -714,6 +732,7 @@ export function useDirectProjectChatController({ historyHasMore, loadEarlierHistory, localMessages, + pendingUserItemId, queuedTurns, reloadHistory, removeAttachment, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts new file mode 100644 index 000000000..356e7d48c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; + +import type { + DirectChatTurn, + DirectChatTurnState, +} from '../conversation/directTurnPresentation'; + +/** + * DirectProject「这一轮在跑吗」的唯一派生入口。 + * + * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、 + * 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义 + * 一次讲清楚,组件只读这一个对象: + * + * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` + * 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。 + * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke → + * 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。 + * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再 + * 接受新的发送。 + * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 + * + * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 + */ +export type DirectProjectTurnStatus = { + nativeRunning: boolean; + commandInFlight: boolean; + displayBusy: boolean; + latestTurnState: DirectChatTurnState | null; +}; + +export function deriveDirectProjectTurnStatus({ + turnRunning, + turnBusy, + turns, +}: { + turnRunning: boolean; + turnBusy: boolean; + turns: readonly DirectChatTurn[]; +}): DirectProjectTurnStatus { + const nativeRunning = Boolean(turnRunning); + const commandInFlight = Boolean(turnBusy); + const latest = turns.length > 0 ? turns[turns.length - 1] : null; + return { + nativeRunning, + commandInFlight, + displayBusy: nativeRunning || commandInFlight, + latestTurnState: latest ? latest.state : null, + }; +} + +export function useDirectProjectTurnStatus({ + turnRunning, + turnBusy, + turns, +}: { + turnRunning: boolean; + turnBusy: boolean; + turns: readonly DirectChatTurn[]; +}): DirectProjectTurnStatus { + return useMemo( + () => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }), + [turnRunning, turnBusy, turns], + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 1673032c9..230f07255 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -36,6 +36,20 @@ export type DirectChatBlock = | { kind: 'reasoning'; key: string; text: string } | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; +/** + * 界面上一轮的三态。 + * + * - `running`:原生回合在跑(订阅流里出现过 `turn.started`、还没出现 `turn.completed`)。 + * - `awaiting-start`:本地已发出、原生还没认领(乐观用户气泡已出现,`turn.started` 未到)。 + * - `finished`:拿到明确终态,或不再有理由认为它在跑。 + * + * 它是**展示态**,不是第二套回合生命周期:`running` 只由 reducer 的 `turnRunning` 决定, + * `awaiting-start` 只由「这条用户条目身份就是本地这次发送的身份、且本轮还没有终态」决定。 + * 数据流与变量归属见 `../controller/useDirectProjectChatController.ts` 的模块注释; + * 三态在渲染上的映射见 `DirectProjectTurn.tsx`。 + */ +export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; + export type DirectChatTurn = { key: string; /** 用户气泡:顺序即发出顺序。 */ @@ -44,7 +58,8 @@ export type DirectChatTurn = { process: DirectChatBlock[]; /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ finals: DirectChatBlock[]; - active: boolean; + /** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */ + state: DirectChatTurnState; /** * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 @@ -60,7 +75,8 @@ type DirectChatTurnEntries = { /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ localUsers: DirectChatBlock[]; notices: ChatMessage[]; - active: boolean; + /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ + nativeRunning: boolean; }; function blockFromEntry( @@ -178,7 +194,7 @@ function newTurn(key: string): DirectChatTurnEntries { entries: [], localUsers: [], notices: [], - active: false, + nativeRunning: false, }; } @@ -193,6 +209,7 @@ export function buildDirectChatTurns({ localMessages = [], turnRunning = false, turnStartedAt = 0, + pendingUserItemId = '', }: { entries: readonly DirectChatEntry[]; localMessages?: readonly ChatMessage[]; @@ -202,6 +219,14 @@ export function buildDirectChatTurns({ * 不会覆盖用户实际发送时间,也不参与已完成回合。 */ turnStartedAt?: number; + /** + * 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。 + * + * 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按 + * 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接 + * 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。 + */ + pendingUserItemId?: string; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; let current: DirectChatTurnEntries | null = null; @@ -247,10 +272,11 @@ export function buildDirectChatTurns({ } current.notices.push(message); }); - if (current) current.active = turnRunning; + if (current) current.nativeRunning = turnRunning; - return turns.map((turn) => { - const lastAssistant = turn.active + const newestTurnIndex = turns.length - 1; + return turns.map((turn, turnIndex) => { + const lastAssistant = turn.nativeRunning ? -1 : turn.entries.reduce( (found, entry, index) => @@ -301,21 +327,38 @@ export function buildDirectChatTurns({ (found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt), 0, ); - const startedAt = - userSentAt > 0 - ? userSentAt - : turn.active - ? normalizeDirectTimestamp(turnStartedAt) - : stampedStart; + // 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生 + // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送 + // 时间最高优先。 + let startedAt = stampedStart; + if (turn.nativeRunning) { + startedAt = normalizeDirectTimestamp(turnStartedAt); + } + if (userSentAt > 0) { + startedAt = userSentAt; + } // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 - const endedAt = turn.active ? 0 : stampedEnd; + const endedAt = turn.nativeRunning ? 0 : stampedEnd; + // 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 = + // awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。 + let state: DirectChatTurnState = 'finished'; + if (turn.nativeRunning) { + state = 'running'; + } else if ( + turnIndex === newestTurnIndex && + pendingUserItemId !== '' && + turn.key === pendingUserItemId && + stampedEnd <= 0 + ) { + state = 'awaiting-start'; + } return { key: turn.key, users, process: mergeToolBlocks(process), finals, - active: turn.active, + state, startedAt, endedAt, } satisfies DirectChatTurn; diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts new file mode 100644 index 000000000..7cab1828d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus'; +import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation'; + +const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ + key, + users: [], + process: [], + finals: [], + state, + startedAt: 1_800_000_000_000, + endedAt: 0, +}); + +describe('DirectProject 回合状态派生', () => { + it('displayBusy 是原生真相与本地命令在飞的并集', () => { + expect( + deriveDirectProjectTurnStatus({ + turnRunning: true, + turnBusy: false, + turns: [], + }).displayBusy, + ).toBe(true); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: true, + turns: [], + }).displayBusy, + ).toBe(true); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [], + }).displayBusy, + ).toBe(false); + }); + + it('两个来源各自独立暴露,不被并集吃掉', () => { + const status = deriveDirectProjectTurnStatus({ + turnRunning: true, + turnBusy: true, + turns: [], + }); + expect(status.nativeRunning).toBe(true); + expect(status.commandInFlight).toBe(true); + }); + + it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => { + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')], + }).latestTurnState, + ).toBe('awaiting-start'); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [], + }).latestTurnState, + ).toBeNull(); + }); + + it('本地命令在飞不等于原生在跑', () => { + const status = deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: true, + turns: [turn('u1', 'awaiting-start')], + }); + expect(status.nativeRunning).toBe(false); + expect(status.latestTurnState).toBe('awaiting-start'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 853c930cd..1b95a2c0a 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -184,7 +184,7 @@ describe('DirectProject 聊天分区', () => { ], turnRunning: true, }); - expect(turns[0]?.active).toBe(true); + expect(turns[0]?.state).toBe('running'); expect(turns[0]?.finals).toEqual([]); expect(turns[0]?.process.map((block) => block.kind)).toEqual([ 'assistant', @@ -293,7 +293,63 @@ describe('DirectProject 聊天分区', () => { }); expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); expect(turns[1]?.users).toHaveLength(1); - expect(turns[1]?.active).toBe(true); + expect(turns[1]?.state).toBe('running'); + }); + + it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => { + const turns = buildDirectChatTurns({ + entries: [], + localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); + expect(turns[0]?.endedAt).toBe(0); + expect(turns[0]?.startedAt).toBe(1_800_000_002_000); + }); + + it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => { + const turns = buildDirectChatTurns({ + // 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。 + entries: [userEntry('direct-codex:turn-1:user')], + localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); + }); + + it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => { + const turns = buildDirectChatTurns({ + entries: [ + { + ...userEntry('direct-codex:turn-1:user'), + turnEndedAt: 1_800_000_010_000, + }, + ], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns[0]?.state).toBe('finished'); + expect(turns[0]?.endedAt).toBe(1_800_000_010_000); + }); + + it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => { + const notNewest = buildDirectChatTurns({ + entries: [userEntry('direct-codex:turn-1:user')], + localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(notNewest.map((turn) => turn.state)).toEqual([ + 'finished', + 'finished', + ]); + const mismatch = buildDirectChatTurns({ + entries: [userEntry('u1')], + localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], + pendingUserItemId: 'direct-codex:turn-9:user', + }); + expect(mismatch.map((turn) => turn.state)).toEqual([ + 'finished', + 'finished', + ]); }); it('历史无用户条目时也保留一个回合承载正文', () => { diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 0642ca205..48a2e5933 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -51,5 +51,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 +- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 - id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5913f4cfb..8f330e4e5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9312,3 +9312,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(空白口径):`directCodexContentToPromptText` 逐字投影、不再 `trim`(前端只在整条 content 上判空);出站提示词的收边规范化收敛成一个共享函数 `resourceCanvasAssetGenerationPromptText`,面板校验与任务落账共用,图集走 Unicode White_Space、其余走 JS 口径。 - 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。 - 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding`、`git diff --check` 通过。未复核真实客户端观感。 + +## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图 + +- 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 +- 决策(三态取代布尔):`DirectChatTurn.active` 改为 `DirectChatTurn.state: 'running' | 'awaiting-start' | 'finished'`。`running` 只由 reducer 的 `turnRunning` 决定;`awaiting-start` 由「最新一轮的用户条目身份 = 本地在途的 `pendingUserItemId`(`direct-codex:{clientTurnId}:user`)、且本轮还没有明确终态」决定;其余是 `finished`。判据是身份不是时间戳,`pendingUserItemId` 由 controller 在 `beginTurnCommand()` / `endTurnCommand()` 里与 `turnBusy` 同生共死。 +- 决策(单一派生入口):新增 `useDirectProjectTurnStatus()`,返回 `{ nativeRunning, commandInFlight, displayBusy, latestTurnState }`。header / composer 只读 `displayBusy`(两者并集,语义与原来的 `turnBusy || directTurnRunning` 完全一致),「陶泥儿正在处理」卡片只读 `nativeRunning`。约定:新增「忙 / 在跑」类判据先落进这里,不在组件里另拼布尔。 +- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。这套说明是给改这块代码的人看的,放代码里才不会与实现脱节;ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 +- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见后续那次三态接入渲染的改动,以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 +- 验证:`npx vitest run` 定向 `directTurnPresentation`(17 条,新增 4 条三态用例)、`directProjectTurnStatus`(4 条)、`directHistoryPaging`(9 条)全绿;`appSurface.test.ts` 202 passed / 13 skipped;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。真实客户端观感与窗口期表现未在客户端复核。