首屏历史改按订阅回执锚定,回执到达前不读首屏
锚点闸门抽到 directHistoryAnchorGate:开闸、同项目复用、消费后不再等待三件事只有一份实现 App 订阅 effect 复用首屏开好的闸门,success 时 settle 成回执的 lastCompletedItemId,订阅不可用/失败/切项目/清理时 settle 成 null 首屏切片只在新端边界 throughItemId 有值时带上它,非 replace 模式才等闸门,/history 手动重读仍按文件尾 测试 harness 的切片桩支持 throughItemId,setDirectThreadHistory 同时同步回执用的 lastCompletedItemId 新增 directHistoryAnchorGate 单测覆盖复用/消费回退规则,App 集成用例断言首屏锚在回执那条、回执先于首屏读取
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user