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:
+22
-15
@@ -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()}
|
||||
|
||||
+7
-3
@@ -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"
|
||||
|
||||
+10
-7
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
+26
-7
@@ -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,
|
||||
|
||||
+65
@@ -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],
|
||||
);
|
||||
}
|
||||
+57
-14
@@ -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` 通过。真实客户端观感与窗口期表现未在客户端复核。
|
||||
|
||||
Reference in New Issue
Block a user