From d91b686f8f240d8ef1be140adb23cfdf5ade4fa2 Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 05:51:11 +0800 Subject: [PATCH] fix(web): keep selected workbench session visible --- .../workbench-r2-session-parity.test.ts | 30 +++++++++- .../src/stores/workbench-session.ts | 59 +++++++++++++++++++ web/hwlab-cloud-web/src/stores/workbench.ts | 9 +-- 3 files changed, 92 insertions(+), 6 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 7c800ac8..e47032d1 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 @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { ChatMessage, WorkspaceRecord } from "../src/types/index.ts"; -import { defaultProviderProfileOptions, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts"; +import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, 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" }); @@ -77,6 +77,32 @@ test("R2 session tabs use latest message activity time instead of stale conversa assert.equal(tabs[0]?.updatedAt, "2026-01-04T00:01:00.000Z"); }); +test("R2 session tabs keep the workspace-selected conversation when the list window omits it", () => { + const workspace: WorkspaceRecord = { + ...workspaceRecord({ activeTraceId: null, sessionStatus: "completed" }), + selectedConversationId: "cnv_selected_omitted", + selectedAgentSessionId: "ses_selected_omitted", + selectedConversation: { + conversationId: "cnv_selected_omitted", + sessionId: "ses_selected_omitted", + firstUserMessagePreview: "被列表窗口省略的当前会话", + updatedAt: "2026-01-05T00:00:00.000Z", + messages: [agentMessage({ conversationId: "cnv_selected_omitted", sessionId: "ses_selected_omitted", status: "completed", updatedAt: "2026-01-05T00:00:00.000Z" })] + }, + workspace: { + selectedConversationId: "cnv_selected_omitted", + selectedAgentSessionId: "ses_selected_omitted", + sessionStatus: "completed" + } + }; + const conversations: ConversationRecord[] = [{ conversationId: "cnv_other", sessionId: "ses_other", firstUserMessagePreview: "列表里较新的其他会话", updatedAt: "2026-01-06T00:00:00.000Z" }]; + const merged = mergeSelectedConversation(conversations, workspace); + assert.equal(merged.some((conversation) => conversation.conversationId === "cnv_selected_omitted"), true); + const tabs = sortSessionTabs(merged, "cnv_selected_omitted"); + assert.equal(tabs.find((tab) => tab.conversationId === "cnv_selected_omitted")?.active, true); + assert.equal(tabs.find((tab) => tab.conversationId === "cnv_selected_omitted")?.label, "被列表窗口省略的当前会话"); +}); + test("R2 session tab labels normalize object previews and reject object placeholders", () => { const tabs = sortSessionTabs([ { conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" }, diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index d69a98f0..125b437a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -76,6 +76,20 @@ export function shouldApplyWorkspaceSnapshot(input: { requestEpoch: number; curr return selectedConversationIdFromWorkspace(input.workspace) === currentConversationId; } +export function mergeSelectedConversation(conversations: ConversationRecord[], workspace: WorkspaceRecord | null): ConversationRecord[] { + const selectedConversationId = selectedConversationIdFromWorkspace(workspace); + if (!selectedConversationId) return conversations; + const selected = selectedConversationFromWorkspace(workspace, selectedConversationId); + if (!selected) return conversations; + const existingIndex = conversations.findIndex((conversation) => conversation.conversationId === selectedConversationId); + if (existingIndex < 0) return [selected, ...conversations]; + const existing = conversations[existingIndex]; + if (!existing) return [selected, ...conversations]; + const next = conversations.slice(); + next[existingIndex] = mergeConversationRecords(existing, selected); + return next; +} + export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string): WorkspaceRecord | null { if (!workspace || !traceId) return workspace; const activeTraceId = activeTraceIdFromWorkspace(workspace); @@ -162,6 +176,51 @@ function activeConversation(conversations: ConversationRecord[], conversationId: return conversations.find((item) => item.conversationId === conversationId) ?? null; } +function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null, selectedConversationId: string): ConversationRecord | null { + const selected = workspace?.selectedConversation; + const stub = selectedConversationStub(workspace, selectedConversationId); + if (selected?.conversationId === selectedConversationId) return stub ? mergeConversationRecords(stub, selected) : selected; + return stub; +} + +function selectedConversationStub(workspace: WorkspaceRecord | null, selectedConversationId: string): ConversationRecord | null { + if (!workspace) return null; + const messages = Array.isArray(workspace.workspace?.messages) ? workspace.workspace.messages : []; + const sessionId = firstNonEmptyString(workspace.selectedAgentSessionId, workspace.workspace?.selectedAgentSessionId, messages.find((message) => message.sessionId)?.sessionId) ?? null; + const threadId = firstNonEmptyString(workspace.workspace?.threadId, messages.find((message) => message.threadId)?.threadId) ?? null; + const lastTraceId = firstNonEmptyString(workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId, [...messages].reverse().find((message) => message.traceId)?.traceId) ?? null; + return { + conversationId: selectedConversationId, + projectId: firstNonEmptyString(workspace.projectId, workspace.workspace?.projectId) ?? null, + sessionId, + threadId, + status: firstNonEmptyString(workspace.workspace?.sessionStatus, messages.at(-1)?.status) ?? null, + updatedAt: latestTimestamp(workspace.updatedAt, workspace.workspace?.updatedAt, ...messages.flatMap((message) => [message.updatedAt, message.runnerTrace?.updatedAt, message.createdAt])), + lastTraceId, + messageCount: messages.length, + messages + }; +} + +function mergeConversationRecords(existing: ConversationRecord, selected: ConversationRecord): ConversationRecord { + const selectedMessages = selected.messages ?? []; + const existingMessages = existing.messages ?? []; + return { + ...existing, + ...selected, + conversationId: existing.conversationId, + projectId: firstNonEmptyString(selected.projectId, existing.projectId) ?? null, + sessionId: firstNonEmptyString(selected.sessionId, selected.session?.sessionId, existing.sessionId, existing.session?.sessionId) ?? null, + threadId: firstNonEmptyString(selected.threadId, selected.session?.threadId, existing.threadId, existing.session?.threadId) ?? null, + status: firstNonEmptyString(selected.status, existing.status) ?? null, + lastTraceId: firstNonEmptyString(selected.lastTraceId, existing.lastTraceId) ?? null, + firstUserMessagePreview: firstNonEmptyString(selected.firstUserMessagePreview, existing.firstUserMessagePreview) ?? null, + messageCount: selected.messageCount ?? (selectedMessages.length > 0 ? selectedMessages.length : existing.messageCount ?? existingMessages.length), + messages: selectedMessages.length > 0 ? selectedMessages : existing.messages, + updatedAt: latestTimestamp(selected.updatedAt, selected.snapshot?.updatedAt, existing.updatedAt, existing.snapshot?.updatedAt) + }; +} + function findActiveAgentMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null { for (const message of [...messages].reverse()) { if (message.role !== "agent") continue; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 06a0a1ee..d4abc502 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, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, 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; @@ -29,13 +29,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null); const workspaceSelectionEpoch = ref(0); + const visibleConversations = computed(() => mergeSelectedConversation(conversations.value, workspace.value)); const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId)); 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(() => conversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null); - const sessionTabs = computed(() => sortSessionTabs(conversations.value, activeConversationId.value)); + const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null); + const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value)); const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value)); - const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: conversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value })); + const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value })); function recordActivity(label = "user-activity"): void { const now = Date.now();