fix(workbench): 增量显示会话加载进度

This commit is contained in:
root
2026-07-21 10:15:12 +02:00
parent 38990c4e81
commit 01f627ba93
5 changed files with 216 additions and 8 deletions
@@ -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>
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
import { resolveCancelableAgentMessage, resolveComposerState, sessionToSessionTab, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading } from "./workbench-session";
import { resolveCancelableAgentMessage, resolveComposerState, sessionDetailLoadingProgress, sessionToSessionTab, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading } from "./workbench-session";
import type { TurnStatusAuthority } from "./workbench-session";
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): ChatMessage {
@@ -83,6 +83,13 @@ test("session detail keeps a visible loading state until Kafka replay connects",
replayLoadingSessionId: "ses_refresh",
activeSessionId: "ses_refresh"
}), true);
assert.equal(shouldShowSessionDetailLoading({
loading: false,
messageCount: 2,
sessionDetailLoadingId: "ses_refresh",
replayLoadingSessionId: "ses_refresh",
activeSessionId: "ses_refresh"
}), false);
assert.equal(shouldShowSessionDetailLoading({
loading: false,
messageCount: 2,
@@ -97,6 +104,35 @@ test("session detail keeps a visible loading state until Kafka replay connects",
}), false);
});
test("session detail exposes transport progress and renders replayed messages before full load", () => {
assert.deepEqual(sessionDetailLoadingProgress({
loading: true,
messageCount: 0,
sessionDetailLoadingId: "ses_progress",
activeSessionId: "ses_progress",
transportPhase: "connecting"
}), {
active: true,
blocking: true,
phase: "selecting",
label: "正在选择会话并建立实时连接..."
});
assert.deepEqual(sessionDetailLoadingProgress({
loading: true,
messageCount: 3,
sessionDetailLoadingId: "ses_progress",
replayLoadingSessionId: "ses_progress",
activeSessionId: "ses_progress",
transportPhase: "event"
}), {
active: true,
blocking: false,
phase: "replaying",
label: "实时流已连接,已显示 3 条,继续回放..."
});
});
test("cancel target remains available for completed message until final response exists", () => {
const traceId = "trc_cancel_completed_without_final";
const sessionId = "ses_cancel_completed_without_final";
@@ -136,11 +136,55 @@ export function shouldShowSessionDetailLoading(input: {
replayLoadingSessionId?: string | null;
activeSessionId?: string | null;
}): boolean {
if (input.messageCount > 0) return false;
if (input.sessionDetailLoadingId || input.switchingSessionId) return true;
if (input.loading && input.messageCount === 0) return true;
if (input.loading) return true;
return Boolean(input.replayLoadingSessionId && input.replayLoadingSessionId === input.activeSessionId);
}
export interface SessionDetailLoadingProgress {
active: boolean;
blocking: boolean;
phase: "idle" | "selecting" | "connecting" | "replaying" | "reconnecting";
label: string;
}
export function sessionDetailLoadingProgress(input: {
loading: boolean;
messageCount: number;
sessionDetailLoadingId?: string | null;
switchingSessionId?: string | null;
replayLoadingSessionId?: string | null;
activeSessionId?: string | null;
transportPhase?: string | null;
}): SessionDetailLoadingProgress {
const detailPending = Boolean(input.sessionDetailLoadingId || input.switchingSessionId || input.loading);
const replayPending = Boolean(input.replayLoadingSessionId && input.replayLoadingSessionId === input.activeSessionId);
const active = detailPending || replayPending;
if (!active) return { active: false, blocking: false, phase: "idle", label: "" };
const messageCount = Math.max(0, Math.trunc(input.messageCount));
const transportPhase = String(input.transportPhase ?? "").trim().toLowerCase();
const transportOpen = transportPhase === "open" || transportPhase === "event";
if (transportOpen) {
return {
active: true,
blocking: messageCount === 0,
phase: "replaying",
label: messageCount > 0
? `实时流已连接,已显示 ${messageCount} 条,继续回放...`
: "实时流已连接,正在回放首批记录..."
};
}
if (transportPhase === "error" || transportPhase === "closed" || transportPhase === "blocked") {
return { active: true, blocking: messageCount === 0, phase: "reconnecting", label: "实时连接中断,正在重连..." };
}
if (input.switchingSessionId || input.sessionDetailLoadingId) {
return { active: true, blocking: messageCount === 0, phase: "selecting", label: "正在选择会话并建立实时连接..." };
}
return { active: true, blocking: messageCount === 0, phase: "connecting", label: "正在连接实时流并准备回放..." };
}
export function sessionToSessionTab(session: WorkbenchSessionRecord, activeSessionId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab {
const sessionId = session.sessionId;
const messageProjection = projectSessionTabMessages(session.messages);
+15 -2
View File
@@ -16,7 +16,7 @@ import type { AgentChatResponse, AgentChatResultResponse, ApiError, ApiResult, C
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, sessionDetailLoadingProgress as projectSessionDetailLoadingProgress, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
@@ -114,6 +114,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const currentRequest = ref<{ traceId: string; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const liveRealtimeReadySessionId = ref<string | null>(null);
const realtimeReplayLoadingSessionId = ref<string | null>(null);
const realtimeTransportPhase = ref("idle");
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
const activeSelectionSource = ref<SessionSelectionSource>(explicitSessionId.value ? "route" : "system");
const selectionEpoch = ref(0);
@@ -147,6 +148,15 @@ export const useWorkbenchStore = defineStore("workbench", () => {
replayLoadingSessionId: realtimeReplayLoadingSessionId.value,
activeSessionId: activeSessionId.value
}));
const sessionDetailLoadingProgress = computed(() => projectSessionDetailLoadingProgress({
loading: loading.value,
messageCount: messages.value.length,
sessionDetailLoadingId: sessionDetailLoadingId.value,
switchingSessionId: switchingSessionId.value,
replayLoadingSessionId: realtimeReplayLoadingSessionId.value,
activeSessionId: activeSessionId.value,
transportPhase: realtimeTransportPhase.value
}));
const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, realtimeReady: !realtimeCapabilities.liveKafkaSse || liveRealtimeReadySessionId.value === activeSessionId.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
function recordActivity(label = "user-activity"): void {
@@ -850,6 +860,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (realtimeCapabilities.liveKafkaSse && scopeChanged) {
liveRealtimeReadySessionId.value = null;
realtimeReplayLoadingSessionId.value = realtimeCapabilities.kafkaRefreshReplay ? sessionId : null;
realtimeTransportPhase.value = "connecting";
}
if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey);
realtimeTransport.restart({
@@ -868,6 +879,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (realtimeCapabilities.liveKafkaSse) liveRealtimeReadySessionId.value = null;
},
onState: (state) => {
realtimeTransportPhase.value = state.phase;
if (realtimeCapabilities.liveKafkaSse && ["connecting", "error", "closed", "blocked"].includes(state.phase)) liveRealtimeReadySessionId.value = null;
},
onRecovery: (recovery) => handleRealtimeRecovery(recovery),
@@ -928,6 +940,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function stopRealtime(): void {
liveRealtimeReadySessionId.value = null;
realtimeTransportPhase.value = "idle";
realtimeTransport.stop();
}
@@ -1411,7 +1424,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
installRealtimeVisibilityHandler();
installWorkbenchProjectionSignalHandler();
return { sessions, messages, activeMessages, activeSession, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, rawHwlabIngress, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, clearRawHwlabIngress, recordActivity };
return { sessions, messages, activeMessages, activeSession, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionDetailLoadingProgress, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, rawHwlabIngress, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, clearRawHwlabIngress, recordActivity };
});
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {