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('历史无用户条目时也保留一个回合承载正文', () => {