终止钮按 canStopTurn 显示:队列非空不再顶出终止按钮
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m8s
Project CI / Frontend tests (pull_request) Successful in 1m57s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m27s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m38s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m57s

- `useDirectProjectTurnStatus` 新增派生判据 `canStopTurn`(`nativeRunning || commandInFlight`)与共享的
  `canStopDirectProjectTurn`,写清它与 `displayBusy` 的分工:前者回答"终止这一脚踢给谁",后者回答"界面能不能声称空闲"
- composer 的发送钮 / 终止钮按 `canStopTurn` 换;`busy` 继续管模型 / 附件 / 语音这些与当前这轮冲突的控件
- `cancelTurn` 的受理判据改用同一个函数:显示与受理同源,不再出现"按钮在、点了说没有回合"
- `displayBusy` 保持三者并集不变(卡片仍要早于首个 token 显示"正在处理")
- 测试:`directProjectTurnStatus` 补 `canStopTurn` 用例;appSurface 补"只有待发消息、没有回合在跑时不显示终止钮"(撤掉修复会红)
This commit is contained in:
2026-09-30 13:11:45 +08:00
parent 9f04940c6d
commit e221543947
6 changed files with 110 additions and 4 deletions
@@ -272,6 +272,7 @@ export function DirectProjectChatView({
pendingTurns={pendingTurns}
composerNotice={composerNotice}
busy={turnStatus.displayBusy}
canStopTurn={turnStatus.canStopTurn}
turnCancelling={turnCancelling}
onCancelPendingTurn={(clientTurnId) =>
void cancelPendingTurn(clientTurnId)
@@ -57,6 +57,7 @@ export function DirectProjectComposer({
pendingTurns,
composerNotice,
busy,
canStopTurn,
turnCancelling,
onCancelPendingTurn,
onCancelTurn,
@@ -72,7 +73,15 @@ export function DirectProjectComposer({
attachmentsImporting: boolean;
pendingTurns: readonly DirectPendingTurn[];
composerNotice: string;
/** 「界面忙不忙」:三者并集,用来锁住模型 / 附件 / 语音这些与当前这轮冲突的控件。 */
busy: boolean;
/**
* 「现在点终止有没有作用对象」(见 `useDirectProjectTurnStatus` 的 `canStopTurn`)。
*
* 与 `busy` 分开:队列非空也算忙,但待发消息不属于「终止」——按 `busy` 显示终止钮会得到一个
* 点了只会报「当前没有正在运行的回合」的按钮。发送钮 / 终止钮只按它换。
*/
canStopTurn: boolean;
turnCancelling: boolean;
onCancelPendingTurn: (clientTurnId: string) => void;
onCancelTurn: () => void;
@@ -159,7 +168,8 @@ export function DirectProjectComposer({
},
[onUploadFiles],
);
// 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
// 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。
const submitButton = (
<button
type="submit"
@@ -246,7 +256,7 @@ export function DirectProjectComposer({
onTranscript={(text) => composerRef.current?.insertText(text)}
onNotice={setVoiceNotice}
/>
{busy ? (
{canStopTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
onCancel={onCancelTurn}
@@ -44,6 +44,7 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS
import type { DirectThreadItem } from '../generated/DirectThreadItem';
import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate';
import { readDirectHistoryPages } from '../history/directHistoryPaging';
import { canStopDirectProjectTurn } from './useDirectProjectTurnStatus';
import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscription';
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
@@ -645,10 +646,15 @@ export function useDirectProjectChatController({
async function cancelTurn() {
if (turnCancelling) return;
const invoke = resolveTauriInvoke();
// 受理判据与终止钮的显示判据**同一个函数**(`useDirectProjectTurnStatus` 的 `canStopTurn`):
// 两处各拼一遍布尔就会出现"按钮在、点了说没有回合"。
if (
!invoke ||
!projectPath ||
(!commandInFlightRef.current && !currentTurnRunning)
!canStopDirectProjectTurn({
turnRunning: currentTurnRunning,
commandInFlight: commandInFlightRef.current,
})
) {
onRuntimeError('当前没有正在运行的回合,无法终止。');
return;
@@ -24,6 +24,11 @@ import type {
* 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started`
* 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」的
* 交代(2026-09-24 口令)。
* - `canStopTurn`:**「现在点终止有没有作用对象」的唯一判据**(`nativeRunning || commandInFlight`)。
* 终止钮按它出现,命令受理时也按它把关:排队中还没放行的那几条不属于「终止」,`pendingCount`
* 不该把终止钮顶出来(那是一个点了只会报「当前没有正在运行的回合」的按钮)。注意它与
* `displayBusy` 是两个问题:`displayBusy` 回答「界面能不能声称空闲」,`canStopTurn` 回答
* 「终止这一脚踢给谁」。
* - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。
*
* 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
@@ -36,9 +41,28 @@ export type DirectProjectTurnStatus = {
commandInFlight: boolean;
pendingCount: number;
displayBusy: boolean;
canStopTurn: boolean;
latestTurnState: DirectChatTurnState | null;
};
/**
* 「现在点终止有没有作用对象」的唯一判据。
*
* 只有两种状态里有东西可终止:宿主已经放行(`nativeRunning`)、命令还在飞(`commandInFlight`,
* 这一段结束前这一轮也可能已经成立)。待发消息属于「还没放行」,取消它走 chip 上的逐条入口,
* 不属于「终止」。视图决定终止钮显不显示、控制器决定受理不受理,都读这一个函数——两处口径必须
* 是同一份,否则就会出现「按钮在、点了说没有回合」。
*/
export function canStopDirectProjectTurn({
turnRunning,
commandInFlight,
}: {
turnRunning: boolean;
commandInFlight: boolean;
}): boolean {
return Boolean(turnRunning) || Boolean(commandInFlight);
}
export function deriveDirectProjectTurnStatus({
turnRunning,
commandInFlight,
@@ -59,6 +83,7 @@ export function deriveDirectProjectTurnStatus({
commandInFlight: inFlight,
pendingCount,
displayBusy: nativeRunning || inFlight || pendingCount > 0,
canStopTurn: canStopDirectProjectTurn({ turnRunning, commandInFlight }),
latestTurnState: latest ? latest.state : null,
};
}
@@ -967,6 +967,34 @@ export function registerChatComposerControlTests() {
});
});
it('只有待发消息、没有回合在跑时不显示终止钮', async () => {
// 队列非空但既没有在飞的命令、也没有在跑的回合(比如宿主还没认领队首):这时"终止"没有作用
// 对象,界面不该给一个点了只会报「当前没有正在运行的回合」的按钮。
const { surface, harness } = await openDirectCodexSurface({
enqueue_direct_codex_turn: (args) => {
harness.emitDirectThreadEvents({
type: 'queue.enqueued',
clientTurnId: String(args?.clientTurnId ?? ''),
userItem: args?.userItem,
at: 1,
});
return null;
},
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '排着的那条');
const queue = await within(surface).findByLabelText('待发送消息队列');
await waitFor(() => {
expect(within(queue).getByText('排着的那条')).not.toBeNull();
});
// 队列非空仍然是"忙"(卡片照旧说正在处理),但发送入口是发送钮:用户可以继续排。
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect(
await within(surface).findByRole('button', { name: '发送' }),
).not.toBeNull();
});
it('queues the next message on the host while a turn is already running', async () => {
// 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。
let host: ReturnType<typeof hostQueue> | null = null;
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus';
import {
canStopDirectProjectTurn,
deriveDirectProjectTurnStatus,
} from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus';
import type { DirectPendingTurn } from '../src/view/project-development/chat/conversation/directPendingTurns';
import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation';
@@ -75,6 +78,39 @@ describe('DirectProject 回合状态派生', () => {
expect(status.pendingCount).toBe(2);
});
it('canStopTurn 只认"有作用对象"的两种状态:队列非空不算', () => {
// 队列非空仍然是 displayBusy(界面不假装空闲),但它不是终止的作用对象——终止钮不该出现,
// 否则点了只会报「当前没有正在运行的回合」。
const queuedOnly = deriveDirectProjectTurnStatus({
turnRunning: false,
commandInFlight: false,
pendingTurns: [pending('client-1')],
turns: [],
});
expect(queuedOnly.displayBusy).toBe(true);
expect(queuedOnly.canStopTurn).toBe(false);
// 宿主已经放行 / 命令还在飞:这两段里有东西可终止。
expect(
canStopDirectProjectTurn({ turnRunning: true, commandInFlight: false }),
).toBe(true);
expect(
canStopDirectProjectTurn({ turnRunning: false, commandInFlight: true }),
).toBe(true);
// 真空闲:终止钮与发送钮各自归位。
expect(
canStopDirectProjectTurn({ turnRunning: false, commandInFlight: false }),
).toBe(false);
expect(
deriveDirectProjectTurnStatus({
turnRunning: false,
commandInFlight: true,
pendingTurns: [],
turns: [],
}).canStopTurn,
).toBe(true);
});
it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => {
expect(
deriveDirectProjectTurnStatus({