diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.test.ts b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.test.ts new file mode 100644 index 00000000..6ae5df3c --- /dev/null +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.test.ts @@ -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>, + 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: "
{{ label }}
" }, + WorkbenchMessageCard: { props: ["message"], template: "
{{ message.text }}
" }, + ApiErrorDiagnostic: { template: "
" }, + CodeAgentStatusSummary: { template: "
" }, + MessageActions: { template: "
" }, + MessageTraceDebugPanel: { template: "
" } + } + } + }); +} + +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(); + }); +}); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 7c742c06..1f9fc156 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -24,7 +24,7 @@ const panelRef = ref(null); const scrollSessionKey = computed(() => workbench.activeSessionId ?? "workbench"); const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56, sessionKey: scrollSessionKey, tabKey: "conversation" }); const detailMessageId = ref(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 { + + diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts index e4dff3ce..bff2f5e2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts @@ -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 & Pick): 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"; diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 8199544f..eb848886 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index ced24e2e..1e54dc61 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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(null); const realtimeReplayLoadingSessionId = ref(null); + const realtimeTransportPhase = ref("idle"); const explicitSessionId = ref(initialWorkbenchSessionIdFromLocation()); const activeSelectionSource = ref(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[] {