首屏历史改按订阅回执锚定,回执到达前不读首屏

锚点闸门抽到 directHistoryAnchorGate:开闸、同项目复用、消费后不再等待三件事只有一份实现
App 订阅 effect 复用首屏开好的闸门,success 时 settle 成回执的 lastCompletedItemId,订阅不可用/失败/切项目/清理时 settle 成 null
首屏切片只在新端边界 throughItemId 有值时带上它,非 replace 模式才等闸门,/history 手动重读仍按文件尾
测试 harness 的切片桩支持 throughItemId,setDirectThreadHistory 同时同步回执用的 lastCompletedItemId
新增 directHistoryAnchorGate 单测覆盖复用/消费回退规则,App 集成用例断言首屏锚在回执那条、回执先于首屏读取
This commit is contained in:
2026-09-17 18:42:29 +08:00
parent 110e9260a7
commit de4dfe8538
5 changed files with 340 additions and 5 deletions
+45 -2
View File
@@ -232,6 +232,11 @@ import {
} from './features/project-workspace/chatComposerQueue';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
type DirectHistoryAnchorGate,
directHistoryAnchorGateToWaitFor,
reuseOrOpenDirectHistoryAnchorGate,
} from './features/project-workspace/directHistoryAnchorGate';
import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging';
import {
applyDirectThreadConsumeResult,
@@ -1472,6 +1477,9 @@ export function App({
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
const directHistoryOldestItemIdRef = useRef<string | null>(null);
const directHistoryLoadingRef = useRef(false);
const directHistoryAnchorGateRef = useRef<DirectHistoryAnchorGate | null>(
null,
);
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
null,
);
@@ -1827,7 +1835,15 @@ export function App({
}
const projectPath = localProject?.projectPath ?? null;
const directInvoke = resolveTauriInvoke();
// 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。
const anchorGate = reuseOrOpenDirectHistoryAnchorGate(
directHistoryAnchorGateRef.current,
projectPath ?? '',
);
directHistoryAnchorGateRef.current = anchorGate;
if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) {
// 订阅不可用:首屏退化成"取文件尾",不阻塞加载。
anchorGate.settle(null);
return;
}
let disposed = false;
@@ -1844,8 +1860,13 @@ export function App({
'subscribe_direct_project_thread',
{ projectPath },
);
if (disposed) return;
if (disposed) {
anchorGate.settle(null);
return;
}
subscriptionId = result.subscriptionId;
// 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。
anchorGate.settle(result.lastCompletedItemId ?? null);
setDirectThreadChat((state) =>
resolveDirectThreadBootstrap(state, result),
);
@@ -1906,7 +1927,8 @@ export function App({
cleanup = unlisten;
await bootstrap();
} catch {
// 历史仍可使用;订阅失败不伪造忙碌态。
// 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏
anchorGate.settle(null);
}
};
// 换项目就是换一份聊天:先回到初始态,再订阅新线程。
@@ -1915,6 +1937,8 @@ export function App({
return () => {
disposed = true;
cleanup?.();
// 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。
anchorGate.settle(null);
};
}, [directCodexProductRuntime, localProject?.projectPath]);
@@ -3500,6 +3524,22 @@ export function App({
// 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer:
// 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。
let loadedDirectHistoryItems: DirectThreadItem[] = [];
// 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目
// 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用
// 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目)
// 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。
let directHistoryThroughItemId: string | null = null;
if (directCodexProductRuntime && mode !== 'replace') {
const anchorGate = directHistoryAnchorGateToWaitFor(
directHistoryAnchorGateRef.current,
nextProjectPath,
);
if (anchorGate) {
directHistoryAnchorGateRef.current = anchorGate;
directHistoryThroughItemId = await anchorGate.anchor;
anchorGate.consumed = true;
}
}
const projectConversation = directCodexProductRuntime
? (() => {
// 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染
@@ -3519,6 +3559,9 @@ export function App({
}
: {
projectPath: nextProjectPath,
...(directHistoryThroughItemId
? { throughItemId: directHistoryThroughItemId }
: {}),
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
},
),
@@ -0,0 +1,59 @@
/**
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
*
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
*
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
*/
export type DirectHistoryAnchorGate = {
projectPath: string;
anchor: Promise<string | null>;
settle: (anchor: string | null) => void;
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
consumed: boolean;
};
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
export function openDirectHistoryAnchorGate(
projectPath: string,
): DirectHistoryAnchorGate {
let settle: (anchor: string | null) => void = () => {};
const anchor = new Promise<string | null>((resolve) => {
settle = resolve;
});
return { projectPath, anchor, settle, consumed: false };
}
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
export function reuseOrOpenDirectHistoryAnchorGate(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate {
if (current && current.projectPath === projectPath) {
return current;
}
return openDirectHistoryAnchorGate(projectPath);
}
/**
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
*
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
*/
export function directHistoryAnchorGateToWaitFor(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate | null {
const sameProjectGate =
current && current.projectPath === projectPath ? current : null;
if (!sameProjectGate) {
return openDirectHistoryAnchorGate(projectPath);
}
return sameProjectGate.consumed ? null : sameProjectGate;
}
@@ -1023,19 +1023,34 @@ function createProjectSupervisorRuntimeHarness({
return { events };
}
if (command === 'read_direct_project_history_slice') {
// 生产口径:从文件尾反向取一屏可显示条目,锚点条目本身不进窗口;收满一屏之后
// 再见一条才算 `hasMore``firstItemId` 是本屏最老一条的 itemId。
// 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条,
// 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算
// `hasMore``firstItemId` 是本屏最老一条的 itemId。
const requestedLimit = Number(args?.limit ?? 20);
const limit = Math.min(
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
200,
);
const throughItemId =
typeof args?.throughItemId === 'string' && args.throughItemId
? args.throughItemId
: null;
const beforeItemId =
typeof args?.beforeItemId === 'string' && args.beforeItemId
? args.beforeItemId
: null;
let end = directThreadHistoryItems.length;
if (beforeItemId) {
if (throughItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === throughItemId,
);
if (anchorIndex < 0) {
throw new Error(
`DirectProject 历史中不存在 item${throughItemId}`,
);
}
end = anchorIndex + 1;
} else if (beforeItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === beforeItemId,
);
@@ -1433,6 +1448,15 @@ function createProjectSupervisorRuntimeHarness({
},
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
directThreadHistoryItems = [...items];
// 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示
// 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。
const newest = directThreadHistoryItems.at(-1);
directThreadLastCompletedItemId =
typeof newest?.itemId === 'string' ? newest.itemId : null;
},
/** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */
setDirectThreadLastCompletedItemId(itemId: string | null) {
directThreadLastCompletedItemId = itemId;
},
};
}
@@ -8338,6 +8338,8 @@ export function registerProjectSupervisorSurfaceTests() {
// 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
// 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。
throughItemId: 'direct-codex:turn-persisted:assistant',
limit: 20,
});
expect(
@@ -8434,6 +8436,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull();
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
throughItemId: 'direct-codex:turn-历史对话 26:user',
limit: 20,
});
@@ -8452,6 +8455,103 @@ export function registerProjectSupervisorSurfaceTests() {
});
});
it('anchors the first history page at the subscribe receipt instead of the file tail', async () => {
// 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才
// 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。
const projectPath = '/tmp/launcher-direct-history-anchor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-direct-history-anchor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
supervisorHarness.setDirectThreadHistory([
{
itemType: 'message',
itemId: 'turn-1-user',
role: 'user',
text: '历史对话 01',
at: 1000,
},
{
itemType: 'message',
itemId: 'turn-2-user',
role: 'user',
text: '历史对话 02',
at: 1100,
},
// 订阅回执之后才落盘的两条:只应从运行态事件来。
{
itemType: 'function_call',
itemId: 'process-1',
name: 'exec_command',
arguments: 'npm run build',
at: 1200,
},
{
itemType: 'message',
itemId: 'turn-3-user',
role: 'user',
text: '历史对话 03',
at: 1300,
},
]);
supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-direct-history-anchor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
throughItemId: 'turn-2-user',
limit: 20,
}),
);
// 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。
expect(
await within(supervisorSurface).findByText('历史对话 02'),
).not.toBeNull();
expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull();
// 锚点只能来自订阅回执:回执必须先于首屏读取发生。
const commands = invoke.mock.calls.map(([command]) => command);
expect(
commands.indexOf('subscribe_direct_project_thread'),
).toBeGreaterThanOrEqual(0);
expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan(
commands.indexOf('read_direct_project_history_slice'),
);
});
it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => {
// 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里,
// 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。
@@ -8516,6 +8616,7 @@ export function registerProjectSupervisorSurfaceTests() {
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
throughItemId: 'process-50',
limit: 20,
}),
);
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
directHistoryAnchorGateToWaitFor,
openDirectHistoryAnchorGate,
reuseOrOpenDirectHistoryAnchorGate,
} from '../src/features/project-workspace/directHistoryAnchorGate';
const projectA = '/tmp/项目A';
const projectB = '/tmp/项目B';
/** 订阅侧的回执:一次订阅只 settle 一次。 */
function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) {
gate.settle('turn-2-user');
}
describe('openDirectHistoryAnchorGate', () => {
it('默认不解析:回执到达前首屏必须等它', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
const settled = { value: 'pending' as string | null | 'pending' };
void gate.anchor.then((anchor) => {
settled.value = anchor;
});
// 让微任务跑一轮:没有回执就不该有结果。
await Promise.resolve();
await Promise.resolve();
expect(settled.value).toBe('pending');
expect(gate.projectPath).toBe(projectA);
expect(gate.consumed).toBe(false);
});
it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
subscribeReceipt(gate);
await expect(gate.anchor).resolves.toBe('turn-2-user');
});
it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
gate.settle(null);
await expect(gate.anchor).resolves.toBeNull();
});
});
describe('reuseOrOpenDirectHistoryAnchorGate', () => {
it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => {
const opened = openDirectHistoryAnchorGate(projectA);
expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened);
});
it('换项目开一道新闸门,不复用旧项目的回执', () => {
const opened = openDirectHistoryAnchorGate(projectA);
const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB);
expect(next).not.toBe(opened);
expect(next.projectPath).toBe(projectB);
expect(next.consumed).toBe(false);
});
it('还没有闸门时新开一道', () => {
expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe(
projectA,
);
});
});
describe('directHistoryAnchorGateToWaitFor', () => {
it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => {
const gate = directHistoryAnchorGateToWaitFor(null, projectA);
expect(gate).not.toBeNull();
expect(gate?.projectPath).toBe(projectA);
subscribeReceipt(gate!);
await expect(gate!.anchor).resolves.toBe('turn-2-user');
});
it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => {
const opened = openDirectHistoryAnchorGate(projectA);
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA);
expect(toWaitFor).toBe(opened);
subscribeReceipt(opened);
await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user');
});
it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => {
const opened = openDirectHistoryAnchorGate(projectA);
opened.consumed = true;
expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull();
});
it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => {
const opened = openDirectHistoryAnchorGate(projectA);
subscribeReceipt(opened);
opened.consumed = true;
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB);
expect(toWaitFor).not.toBeNull();
expect(toWaitFor).not.toBe(opened);
expect(toWaitFor?.projectPath).toBe(projectB);
});
});