fix(workbench): 增量显示会话加载进度
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ConversationPanel from "./ConversationPanel.vue";
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
activeSessionId: "ses_loading_progress",
|
||||
activeMessages: [] as Array<Record<string, unknown>>,
|
||||
error: null as string | null,
|
||||
sessionDetailLoading: false,
|
||||
sessionDetailLoadingProgress: {
|
||||
active: false,
|
||||
blocking: false,
|
||||
phase: "idle",
|
||||
label: ""
|
||||
},
|
||||
cancelAgentMessage: vi.fn(),
|
||||
retryAgentMessage: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/workbench", () => ({ useWorkbenchStore: () => store }));
|
||||
vi.mock("@/composables/useWorkbenchScrollRuntime", () => ({
|
||||
useWorkbenchBottomFollowScroll: () => ({
|
||||
following: { value: true },
|
||||
keepBottomAfterUpdate: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
scrollToBottom: vi.fn()
|
||||
})
|
||||
}));
|
||||
vi.mock("@/utils/workbench-performance", () => ({ acknowledgeWorkbenchVisibleAfterPaint: vi.fn() }));
|
||||
|
||||
function mountPanel() {
|
||||
return mount(ConversationPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingState: { props: ["label"], template: "<div class='loading-state'>{{ label }}</div>" },
|
||||
WorkbenchMessageCard: { props: ["message"], template: "<article class='message-card'>{{ message.text }}</article>" },
|
||||
ApiErrorDiagnostic: { template: "<div />" },
|
||||
CodeAgentStatusSummary: { template: "<div />" },
|
||||
MessageActions: { template: "<div />" },
|
||||
MessageTraceDebugPanel: { template: "<div />" }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("ConversationPanel progressive session loading", () => {
|
||||
beforeEach(() => {
|
||||
store.activeMessages = [];
|
||||
store.error = null;
|
||||
store.sessionDetailLoading = false;
|
||||
store.sessionDetailLoadingProgress = { active: false, blocking: false, phase: "idle", label: "" };
|
||||
});
|
||||
|
||||
it("renders replayed messages while the SSE replay continues at the panel edge", () => {
|
||||
store.activeMessages = [{
|
||||
id: "msg_loading_progress",
|
||||
messageId: "msg_loading_progress",
|
||||
role: "user",
|
||||
title: "用户",
|
||||
text: "已经到达的回放消息",
|
||||
status: "sent",
|
||||
sessionId: store.activeSessionId,
|
||||
traceId: "trc_loading_progress",
|
||||
createdAt: "2026-07-21T12:00:00.000Z"
|
||||
}];
|
||||
store.sessionDetailLoadingProgress = {
|
||||
active: true,
|
||||
blocking: false,
|
||||
phase: "replaying",
|
||||
label: "实时流已连接,已显示 1 条,继续回放..."
|
||||
};
|
||||
|
||||
const wrapper = mountPanel();
|
||||
expect(wrapper.get(".message-card").text()).toContain("已经到达的回放消息");
|
||||
expect(wrapper.get(".conversation-load-progress").text()).toContain("实时流已连接,已显示 1 条,继续回放...");
|
||||
expect(wrapper.find(".conversation-detail-loading").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps the centered phase label before the first replay message", () => {
|
||||
store.sessionDetailLoading = true;
|
||||
store.sessionDetailLoadingProgress = {
|
||||
active: true,
|
||||
blocking: true,
|
||||
phase: "selecting",
|
||||
label: "正在选择会话并建立实时连接..."
|
||||
};
|
||||
|
||||
const wrapper = mountPanel();
|
||||
expect(wrapper.get(".conversation-detail-loading").text()).toContain("正在选择会话并建立实时连接...");
|
||||
expect(wrapper.find(".conversation-empty-hint").exists()).toBe(false);
|
||||
expect(wrapper.find(".conversation-load-progress").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const panelRef = ref<HTMLElement | null>(null);
|
||||
const scrollSessionKey = computed(() => workbench.activeSessionId ?? "workbench");
|
||||
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56, sessionKey: scrollSessionKey, tabKey: "conversation" });
|
||||
const detailMessageId = ref<string | null>(null);
|
||||
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
|
||||
const visibleMessages = computed(() => workbench.activeMessages);
|
||||
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
|
||||
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
|
||||
const scrollSignature = computed(() => workbenchTimelineSignature(visibleTimelineRows.value));
|
||||
@@ -64,7 +64,10 @@ function acknowledgeVisibleMessages(): void {
|
||||
|
||||
<template>
|
||||
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
|
||||
<LoadingState v-if="workbench.sessionDetailLoading" class="conversation-detail-loading" label="加载中" />
|
||||
<LoadingState v-if="workbench.sessionDetailLoadingProgress.blocking" class="conversation-detail-loading" :label="workbench.sessionDetailLoadingProgress.label" />
|
||||
<div v-else-if="workbench.sessionDetailLoadingProgress.active" class="conversation-load-progress" :data-phase="workbench.sessionDetailLoadingProgress.phase">
|
||||
<LoadingState :label="workbench.sessionDetailLoadingProgress.label" compact />
|
||||
</div>
|
||||
<template v-for="row in visibleTimelineRows" :key="row.key">
|
||||
<div v-if="row.type === 'TurnGap'" class="timeline-row timeline-turn-gap" aria-hidden="true" />
|
||||
<div v-else-if="row.type === 'Thinking'" class="timeline-row timeline-thinking" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">
|
||||
@@ -74,8 +77,8 @@ function acknowledgeVisibleMessages(): void {
|
||||
<div v-else-if="row.type === 'Error'" class="timeline-row timeline-error" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">{{ row.text }}</div>
|
||||
<WorkbenchMessageCard v-else-if="row.message" :message="row.message" @details="openDetails" />
|
||||
</template>
|
||||
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||||
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话,或从左侧选择 session。</div>
|
||||
<div v-if="!workbench.sessionDetailLoadingProgress.blocking && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||||
<div v-else-if="!workbench.sessionDetailLoadingProgress.active && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话,或从左侧选择 session。</div>
|
||||
<div v-if="detailMessage" class="workbench-dialog-backdrop" role="presentation" @click.self="detailMessageId = null">
|
||||
<section id="message-detail-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="message-detail-title">
|
||||
<header>
|
||||
@@ -98,3 +101,17 @@ function acknowledgeVisibleMessages(): void {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conversation-load-progress {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
align-self: flex-end;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 3px;
|
||||
background: #f8fafc;
|
||||
padding: 5px 8px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 8%);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user