From c71da8a0620036d9af993bbdaf3d73ee12975fb7 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:03:09 +0800 Subject: [PATCH] fix: reflect live workbench turns in session tabs (#1319) --- .../workbench-r2-session-parity.test.ts | 20 ++++++++- .../src/stores/workbench-session.ts | 45 +++++++++++++++++++ web/hwlab-cloud-web/src/stores/workbench.ts | 4 +- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index 3d0d1b74..0c91ca02 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; test("R2 composer ignores stale workspace activeTraceId without verified active message", () => { const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }); @@ -130,6 +130,24 @@ test("R2 selected workspace stubs cannot clear a running conversation tab", () = assert.equal(tabs[0]?.label, "保持原始标题"); }); +test("R2 active in-flight turn updates the selected session tab before list refresh catches up", () => { + const messages: ChatMessage[] = [ + userMessage({ conversationId: "cnv_live", sessionId: "ses_live", text: "立即显示左侧运行态", createdAt: "2026-01-04T00:00:00.000Z" }), + agentMessage({ conversationId: "cnv_live", sessionId: "ses_live", status: "running", traceId: "trc_live", updatedAt: "2026-01-04T00:00:01.000Z" }) + ]; + const merged = mergeActiveConversationActivity([{ conversationId: "cnv_live", sessionId: "ses_live", status: "active", messageCount: 0 }], { + activeConversationId: "cnv_live", + messages, + currentRequest: { conversationId: "cnv_live", sessionId: "ses_live", traceId: "trc_live", status: "running" }, + chatPending: true + }); + const tabs = sortSessionTabs(merged, "cnv_live"); + assert.equal(tabs[0]?.status, "running"); + assert.equal(tabs[0]?.running, true); + assert.equal(tabs[0]?.label, "立即显示左侧运行态"); + assert.equal(tabs[0]?.messageCount, 2); +}); + test("R2 session tabs use conversation status before stale snapshot or message status", () => { const conversation: ConversationRecord = { conversationId: "cnv_stale_snapshot", diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 13a65683..6b1557e1 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -31,6 +31,13 @@ export interface ProviderProfileOption { configured?: boolean; } +export interface ActiveConversationActivityInput { + activeConversationId: string | null; + messages: ChatMessage[]; + currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; + chatPending: boolean; +} + export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1"; export const RECENT_DRAFTS_LIMIT = 8; const OBJECT_OBJECT_TEXT = "[object Object]"; @@ -173,6 +180,44 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)); } +export function mergeActiveConversationActivity(conversations: ConversationRecord[], input: ActiveConversationActivityInput): ConversationRecord[] { + const conversationId = firstNonEmptyString(input.activeConversationId, input.currentRequest?.conversationId, input.messages.find((message) => message.conversationId)?.conversationId); + if (!conversationId) return conversations; + const activityMessages = input.messages.filter((message) => !message.conversationId || message.conversationId === conversationId); + const latestAgent = latestAgentMessage(activityMessages); + const requestMatches = !input.currentRequest?.conversationId || input.currentRequest.conversationId === conversationId; + const liveStatus = requestMatches && isActiveStatus(input.currentRequest?.status) + ? input.currentRequest?.status + : input.chatPending && isActiveStatus(latestAgent?.status) + ? latestAgent?.status + : null; + if (!liveStatus || activityMessages.length === 0) return conversations; + const existing = conversations.find((conversation) => conversation.conversationId === conversationId) ?? { conversationId }; + const userMessage = activityMessages.find((message) => message.role === "user"); + const lastUserMessageAt = latestUserMessageAtFromMessages(activityMessages) ?? firstNonEmptyString(existing.lastUserMessageAt, existing.snapshot?.lastUserMessageAt) ?? null; + const updatedAt = lastUserMessageAt ?? conversationDisplayUpdatedAt(existing) ?? new Date().toISOString(); + const next: ConversationRecord = { + ...existing, + conversationId, + sessionId: firstNonEmptyString(input.currentRequest?.sessionId, latestAgent?.sessionId, existing.sessionId, existing.session?.sessionId) ?? null, + threadId: firstNonEmptyString(input.currentRequest?.threadId, latestAgent?.threadId, existing.threadId, existing.session?.threadId) ?? null, + status: liveStatus, + lastTraceId: firstNonEmptyString(input.currentRequest?.traceId, latestAgent?.traceId, latestAgent?.runnerTrace?.traceId, existing.lastTraceId) ?? null, + firstUserMessagePreview: firstNonEmptyString(existing.firstUserMessagePreview, existing.snapshot?.firstUserMessagePreview, userMessage?.text, userMessage?.content) ?? null, + messages: activityMessages, + messageCount: activityMessages.length, + lastUserMessageAt, + updatedAt, + snapshot: { + ...(existing.snapshot ?? {}), + sessionStatus: liveStatus, + ...(lastUserMessageAt ? { lastUserMessageAt } : {}), + updatedAt + } + }; + return [next, ...conversations.filter((conversation) => conversation.conversationId !== conversationId)]; +} + export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] { const trimmed = text.trim(); if (!trimmed) return existing; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index bf118cab..dc0aa48b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,7 @@ import { api } from "@/api"; import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types"; import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -35,7 +35,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const selectedSessionId = computed(() => firstNonEmptyString(workspace.value?.selectedAgentSessionId, workspace.value?.workspace?.selectedAgentSessionId, activeConversation.value?.sessionId)); const selectedThreadId = computed(() => firstNonEmptyString(workspace.value?.workspace?.threadId, activeConversation.value?.threadId)); const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null); - const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value)); + const sessionTabs = computed(() => sortSessionTabs(mergeActiveConversationActivity(visibleConversations.value, { activeConversationId: activeConversationId.value, messages: messages.value, currentRequest: currentRequest.value, chatPending: chatPending.value }), activeConversationId.value)); const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value })); const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value)); const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value }));