test: cover five-second conversation ordering and interaction pauses

Add App regression coverage and document five-second sidebar sorting, live row updates, and deferred reordering during interaction. The ordering hook and App wiring were already included in ee4eb73. Focused validation: 23 tests passed.
This commit is contained in:
mesalogo
2026-10-02 19:00:10 +08:00
parent ee4eb737e0
commit 0ce9606c89
4 changed files with 81 additions and 4 deletions
@@ -60,6 +60,25 @@ npm run typecheck
的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或
macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。
## 2026-10-02:补齐五秒排序检查
会话列表已接入 `useConversationListOrder`,保留实时行数据,定时调整展示顺序。
交互暂停与即时操作规则见[会话置顶与操作菜单](./ui-design.md#会话置顶与操作菜单)。
本地和消息通道共用此 Renderer 路径,未修改 Agent 执行、同步或持久化。
验证命令:
```text
npx vitest run src/renderer/src/use-conversation-list-order.test.tsx src/renderer/src/chat-conversation.test.ts src/renderer/src/App.test.tsx -t "useConversationListOrder|conversation display order|parallel streaming conversations|summary and detail order|sorts pinned conversations|merges persisted pin metadata|dedicated pin update"
```
3 个文件、23 项通过,覆盖五秒边界、连续增量、交互暂停与恢复、立即增删与置顶、
项目/搜索范围切换、计时器清理,以及 App 中本地/通道摘要更新的实际列表接线。
新增 hook 与相关测试的定向 ESLint、`git diff --check` 通过。未运行 Electron 窗口验证。
全仓检查未通过:typecheck 报并行修改中的 `App.tsx:5436` 缺少 `startTransition`;
ESLint 报打包文件及现有会话视图缓存的 refs 错误;`npm test` 在 120 秒后超时。
额外运行的草稿置顶失败测试未通过,实际显示持久化错误而非预期的置顶错误。
## 2026-09-28:并行会话输出时保持列表顺序
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
@@ -249,12 +249,17 @@ CPU 4% · 内存 620 MB · 桌面进程 7
会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。
置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示
置顶会话,两组内各按最新消息的创建时间倒序;时间相同按会话 ID 固定顺序,无消息时使用
会话更新时间。新增消息可以前移会话,同一消息的文字、推理、工具进度和完成状态更新不
会话更新时间。新增消息在每 5 秒的排序检查时可以前移会话,仅顺序变化时调整位置;同一消息的文字、推理、工具进度和完成状态更新不
改变排序。有消息的会话重命名或修改设置也不前移。摘要刷新、打开详情和重新启动沿用同一
规则,行右侧仍显示实际更新时间。取消置顶恢复消息时间排序,不改变会话更新时间。
置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。
存储尚未就绪或置顶请求进行中时禁用置顶操作。
消息内容、标题与运行状态实时更新。鼠标停留在列表内、焦点在列表内、滚动尚未停止
200ms 或会话操作菜单打开时,跳过自动排序;操作结束后在下一次 5 秒检查时调整。
新建、删除、置顶和取消置顶立即反映到列表中,不提前应用其他会话待处理的位置变化。
切换项目或搜索范围立即显示对应列表;排序计时器不因消息增量而重新计时。
操作菜单通过 body Portal 浮在会话列表上方,不撑开会话行。菜单锚定更多按钮,默认位于
按钮下方,随滚动、窗口及菜单尺寸变化重新定位,距窗口边缘至少 `8px`;空间不足时限制
宽高并允许菜单内部滚动。使用共享表面色、边框、圆角与浮层阴影。
@@ -76,6 +76,8 @@ WAL 增长与 SQL 全历史扫描次数的定向验证见 [BUILD.md](../../../BU
`createdAt` 最大值;摘要的 `latestMessageAt` 在已有聚合查询中取 `MAX(created_at)`,
与摘要上附带的实时消息取最大值。该字段仅用于桌面只读投影,不增加数据库列或持久化副本。
`updatedAt`/`updated_at` 继续随流式事件更新,原有快照合并、终态优先与逐消息保存规则不变。
`useConversationListOrder` 在内存中保留展示 ID 顺序,以单个 5 秒计时器应用自动排序,
并按上述交互规则暂缓重排;行数据始终使用最新会话对象,不延迟保存或同步。
- 本机和托管 SSH 的 Renderer/Main 读取、持久化与恢复投影共用以上实现。Agent 的
transcript、原生 Session、权限及模型桥协议未改变;完整列表、摘要和详情都附带
Main 当前恢复请求的问题投影。
+54 -3
View File
@@ -3857,6 +3857,15 @@ describe("App", () => {
});
it("keeps parallel streaming conversations in message order while persisting fresh deltas", async () => {
const realSetInterval = window.setInterval.bind(window);
const orderingTicks: Array<() => void> = [];
vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
if (timeout === 5_000 && typeof handler === "function") {
orderingTicks.push(handler as () => void);
return realSetInterval(() => {}, timeout);
}
return realSetInterval(handler, timeout, ...args);
}) as typeof window.setInterval);
const snapshots: ConversationSnapshot[] = ["First", "Second"].map((title, index) => ({
id: `parallel-${index}`, projectId, title, updatedAt: 100 - index, messages: [],
}));
@@ -3870,6 +3879,10 @@ describe("App", () => {
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledTimes(index + 1));
}
expect(titles()).toEqual(["First", "Second"]);
act(() => screen.getByLabelText("向 GoodBuddy 提问").focus());
expect(orderingTicks.length).toBeGreaterThan(0);
await act(async () => orderingTicks.forEach(tick => tick()));
expect(titles()).toEqual(["Second", "First"]);
const first = run.mock.calls[0]![0];
const second = run.mock.calls[1]![0];
@@ -3893,7 +3906,19 @@ describe("App", () => {
expect(titles()).toEqual(["Second", "First"]);
});
it.each([false, true])("keeps summary and detail order stable across refreshes and moves a newly messaged conversation forward (channel: %s)", async (channel) => {
it.each([false, true].flatMap(channel =>
(["none", "mouse", "focus", "menu"] as const).map(pause => ({ channel, pause })),
))("batches summary and detail ordering every 5 seconds with live rows (channel: $channel, pause: $pause)", async ({ channel, pause }) => {
const realSetInterval = window.setInterval.bind(window);
const orderingTicks: Array<() => void> = [];
vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
if (timeout === 5_000 && typeof handler === "function") {
orderingTicks.push(handler as () => void);
// Keep async Testing Library waits real, but advance five-second ticks explicitly.
return realSetInterval(() => {}, timeout);
}
return realSetInterval(handler, timeout, ...args);
}) as typeof window.setInterval);
const snapshots: ConversationSnapshot[] = ["Earlier", "Later"].map((title, index) => ({
id: `stable-${index}`, projectId, title, updatedAt: 900 - index,
...(channel ? { remote: { channel: "weixin" as const, accountDisplay: "Account", conversationType: "direct" as const } } : {}),
@@ -3913,10 +3938,36 @@ describe("App", () => {
fireEvent.click(container.querySelectorAll(".conversation-item")[1]!);
await screen.findByText("Body Earlier");
expect(titles()).toEqual(["Later", "Refreshed Earlier"]);
const list = container.querySelector<HTMLElement>(".conversation-list")!;
const composer = screen.getByLabelText("向 GoodBuddy 提问");
act(() => composer.focus());
if (pause === "mouse") fireEvent.mouseEnter(list);
if (pause === "focus") act(() => screen.getByLabelText("更多会话操作 Refreshed Earlier").focus());
if (pause === "menu") {
fireEvent.click(screen.getByLabelText("更多会话操作 Refreshed Earlier"));
await waitFor(() => expect(screen.getByRole("menu")).toContainElement(document.activeElement as HTMLElement));
expect(list).not.toContainElement(screen.getByRole("menu"));
}
snapshots[0] = { ...snapshots[0]!, updatedAt: 1100, messages: [...snapshots[0]!.messages,
{ id: "new-message", role: "user", state: "complete", content: "New turn", createdAt: 200 }] };
{ id: "new-message", role: "user", state: "complete", content: "New turn", createdAt: 200 }], title: "Live Earlier" };
fireEvent(window, new Event("focus"));
await waitFor(() => expect(titles()).toEqual(["Refreshed Earlier", "Later"]));
await waitFor(() => expect(titles()).toEqual(["Later", "Live Earlier"]));
expect(await screen.findByText("New turn")).toBeInTheDocument();
expect(orderingTicks.length).toBeGreaterThan(0);
await act(async () => orderingTicks.forEach(tick => tick()));
if (pause !== "none") {
expect(titles()).toEqual(["Later", "Live Earlier"]);
if (pause === "mouse") fireEvent.mouseLeave(list);
if (pause === "menu") {
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByLabelText("更多会话操作 Live Earlier")).toHaveFocus());
}
act(() => composer.focus());
expect(titles()).toEqual(["Later", "Live Earlier"]);
await act(async () => orderingTicks.forEach(tick => tick()));
}
expect(titles()).toEqual(["Live Earlier", "Later"]);
});
it("uses the dedicated pin update for channel conversations", async () => {