DirectProject 聊天状态显式化:回合三态与「在跑吗」唯一派生入口

- DirectChatTurn 的 active 布尔改为三态 state(running / awaiting-start / finished),三态判定收在投影里、用身份而不是时间戳
- buildDirectChatTurns 新增 pendingUserItemId 入参;controller 的 beginTurnCommand / endTurnCommand 与 turnBusy 同生共死地维护它
- 新增 useDirectProjectTurnStatus 作为「在跑吗」的唯一派生入口:nativeRunning / commandInFlight / displayBusy / latestTurnState
- DirectProjectChatView 不再手拼 busy:header 与 composer 读 displayBusy,「正在处理」卡片读 nativeRunning
- DirectProjectConversation 的 running 入参改名 nativeRunning,语义写在类型注释里
- DirectProjectTurn 改读 turn.state;渲染条件保持不变,awaiting-start 仍按已结束渲染(空窗缺陷另开改动)
- directTurnPresentation 的起点优先级与三态判定由嵌套三元改为逐级 if 覆盖
- 新增 directProjectTurnStatus 用例 4 条;directTurnPresentation 补三态用例 4 条并改按 state 断言
- ADR 补「活动回合唯一判据约束的是原生回合」澄清与代码指针
- decision-log 记录本次重构决策、未修的窗口期缺陷与验证证据
This commit is contained in:
2026-09-22 14:06:20 +08:00
parent 1b951619c9
commit 860dcb1bf5
10 changed files with 333 additions and 48 deletions
@@ -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({
>
<div className="project-chat-conversation">
<DirectProjectChatHeader
busy={busy}
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => 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()}
@@ -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<HTMLDivElement | null>;
historyHasMore: boolean;
running: boolean;
/**
* 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。
* 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
*/
nativeRunning: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
@@ -53,7 +57,7 @@ export function DirectProjectConversation({
<DirectProjectTurn key={turn.key} turn={turn} />
))}
</div>
{running ? (
{nativeRunning ? (
<AgentMessageContent
as="section"
tone="process"
@@ -24,10 +24,11 @@ import {
* 表现的渲染,不拥有任何回合状态。
*/
export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
const streamingKey = turn.active
? ([...turn.process].reverse().find((block) => block.kind === 'assistant')
?.key ?? null)
: null;
const streamingKey =
turn.state === 'running'
? ([...turn.process].reverse().find((block) => block.kind === 'assistant')
?.key ?? null)
: null;
return (
<Fragment>
{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 (
<details className="message-turn-process" data-testid="turn-process">
<summary></summary>
@@ -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 (
<p
@@ -84,7 +87,7 @@ function renderBlock(
<ToolCallGroup
key={block.key}
calls={block.calls}
active={turn.active}
active={turn.state === 'running'}
className="message-tool-call"
/>
);
@@ -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<string>('');
const [localMessages, setLocalMessages] = useState<ChatMessage[]>([]);
// 订阅(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,
@@ -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],
);
}
@@ -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;
@@ -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');
});
});
@@ -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('历史无用户条目时也保留一个回合承载正文', () => {
@@ -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 会话核对仍列为运行时验收项。
@@ -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` 通过。真实客户端观感与窗口期表现未在客户端复核。