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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user