99be1d76fe
- 新增 view/project-development/chat 模块:DirectProjectChatView 自己持有项目清单订阅、首屏锚点、历史分页、发送队列、附件、终止、草稿与本地消息 - DirectProjectChatView 入参收窄为项目路径、入口首轮需求(canonical content[])和两条权限门,Direct 状态不再穿过 App.tsx - 线程订阅与聊天 reducer 提成 useDirectThreadChatSubscription:subscribe/consume/notify 单飞循环、订阅欠账、SUBSCRIPTION_EXPIRED 重订与 DirectThreadChatState - chat/components 按「一个组件一个目录、逻辑与表现同目录」重组(ChatHeader、Composer、Conversation、SettingsDialog、ToolCallGroup) - DirectProjectChatView 拆成聊天头、会话区、回合、输入盒四个组件 - App.tsx 删除 Direct 专属 state/ref/effect/handler 与 directCodex 分支,首轮需求按 canonical content 交给聊天 - 工作台级动作(运行本地预览等)经 DirectProjectChatHandle.announce 交给聊天自己的本地消息流 - Direct 首轮认领记录提成 app/initialSupervisorMessageClaims,供各入口共用 - Rust 侧 Direct 契约 ts-rs 导出目录改到 chat/generated - 补齐 direct_codex_user_item/wire.rs 既有的 rustfmt 空格漂移 - 同步 ADR、实施计划、decision-log、pitfalls 与 Direct 技术文档
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
directHistoryAnchorGateToWaitFor,
|
|
openDirectHistoryAnchorGate,
|
|
reuseOrOpenDirectHistoryAnchorGate,
|
|
} from '../src/view/project-development/chat/history/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);
|
|
});
|
|
});
|