From 7d1a012d7b39a563332bb33c79dc8145bd633fde Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 19:42:02 +0800 Subject: [PATCH] fix: keep selected session tab in workbench rail --- .../workbench-r2-session-parity.test.ts | 21 +++++++-- .../src/stores/workbench-session.ts | 45 ++++++++++++++++--- web/hwlab-cloud-web/src/stores/workbench.ts | 32 +++++++++++-- 3 files changed, 86 insertions(+), 12 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 0e0bff0d..78cbcd78 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, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts"; +import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, 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" }); @@ -95,9 +95,14 @@ test("R2 session list shows loading until conversations are ready", () => { assert.equal(shouldShowSessionListLoading({ loading: false, conversationsReady: false }), false); }); -test("R2 session tabs render only conversations supplied by the list API", () => { - const tabs = sortSessionTabs([], "cnv_selected_only"); - assert.equal(tabs.length, 0); +test("R2 session tabs keep selected conversation even when the list window misses it", () => { + const selected: ConversationRecord = { conversationId: "cnv_selected_only", sessionId: "ses_selected_only", firstUserMessagePreview: "当前 deep link 会话" }; + const conversations = stableConversationList([], [], "cnv_selected_only", selected); + const tabs = sortSessionTabs(conversations, "cnv_selected_only"); + assert.equal(tabs.length, 1); + assert.equal(tabs[0]?.active, true); + assert.equal(tabs[0]?.conversationId, "cnv_selected_only"); + assert.equal(tabs[0]?.label, "当前 deep link 会话"); }); test("R2 session list keeps current tabs when include response misses selected conversation", () => { @@ -107,6 +112,14 @@ test("R2 session list keeps current tabs when include response misses selected c assert.deepEqual(stableConversationList(current, [], null), []); }); +test("R2 merging selected conversation preserves the active tab while filling details", () => { + const merged = mergeConversationIntoList([{ conversationId: "cnv_selected", sessionId: "ses_old", firstUserMessagePreview: "旧标题" }], { conversationId: "cnv_selected", sessionId: "ses_new", threadId: "thr_new", messages: [userMessage({ text: "新标题" })] }); + assert.equal(merged.length, 1); + assert.equal(merged[0]?.sessionId, "ses_new"); + assert.equal(merged[0]?.threadId, "thr_new"); + assert.equal(merged[0]?.messages?.length, 1); +}); + test("R2 drafts keep recent unique values and normalize corrupt storage", () => { const next = recordRecentDraft([{ text: "旧输入", ts: "2026-01-01T00:00:00.000Z" }], "新输入", "2026-01-02T00:00:00.000Z"); assert.deepEqual(next.map((item) => item.text), ["新输入", "旧输入"]); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 9376edad..9e5822f9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -191,12 +191,24 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt)); } -export function stableConversationList(current: ConversationRecord[], candidate: unknown, includeConversationId: string | null | undefined): ConversationRecord[] { - if (!Array.isArray(candidate)) return current; +export function stableConversationList(current: ConversationRecord[], candidate: unknown, includeConversationId: string | null | undefined, selectedConversation?: ConversationRecord | null): ConversationRecord[] { + if (!Array.isArray(candidate)) return selectedConversation ? mergeConversationIntoList(current, selectedConversation) : current; const includeId = firstNonEmptyString(includeConversationId); - if (!includeId) return candidate as ConversationRecord[]; - if ((candidate as ConversationRecord[]).some((conversation) => conversation.conversationId === includeId)) return candidate as ConversationRecord[]; - return current.length > 0 ? current : candidate as ConversationRecord[]; + const candidateList = candidate as ConversationRecord[]; + const stable = !includeId || candidateList.some((conversation) => conversation.conversationId === includeId) + ? candidateList + : current.length > 0 + ? current + : candidateList; + return selectedConversation ? mergeConversationIntoList(stable, selectedConversation) : stable; +} + +export function mergeConversationIntoList(current: ConversationRecord[], conversation: ConversationRecord | null | undefined): ConversationRecord[] { + const conversationId = firstNonEmptyString(conversation?.conversationId); + if (!conversationId || !conversation) return current; + const existingIndex = current.findIndex((item) => item.conversationId === conversationId); + if (existingIndex < 0) return [conversation, ...current]; + return current.map((item, index) => index === existingIndex ? mergeConversationRecord(item, conversation) : item); } export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] { @@ -229,6 +241,29 @@ function activeConversation(conversations: ConversationRecord[], conversationId: return conversations.find((item) => item.conversationId === conversationId) ?? null; } +function mergeConversationRecord(existing: ConversationRecord, incoming: ConversationRecord): ConversationRecord { + const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages : []; + const existingMessages = Array.isArray(existing.messages) ? existing.messages : []; + return { + ...existing, + ...incoming, + projectId: firstNonEmptyString(incoming.projectId, existing.projectId), + sessionId: firstNonEmptyString(incoming.sessionId, incoming.session?.sessionId, existing.sessionId, existing.session?.sessionId), + threadId: firstNonEmptyString(incoming.threadId, incoming.session?.threadId, existing.threadId, existing.session?.threadId), + status: firstNonEmptyString(incoming.status, incoming.session?.status, existing.status, existing.session?.status), + updatedAt: firstNonEmptyString(incoming.updatedAt, existing.updatedAt), + lastUserMessageAt: firstNonEmptyString(incoming.lastUserMessageAt, existing.lastUserMessageAt), + startedAt: firstNonEmptyString(incoming.startedAt, existing.startedAt), + lastTraceId: firstNonEmptyString(incoming.lastTraceId, existing.lastTraceId), + firstUserMessagePreview: firstNonEmptyString(incoming.firstUserMessagePreview, existing.firstUserMessagePreview), + title: firstNonEmptyString(incoming.title, existing.title), + name: firstNonEmptyString(incoming.name, existing.name), + userPreview: firstNonEmptyString(incoming.userPreview, existing.userPreview), + messageCount: incoming.messageCount ?? existing.messageCount ?? (incomingMessages.length || existingMessages.length || undefined), + messages: incomingMessages.length > 0 ? incomingMessages : existing.messages + }; +} + function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | null { return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 541f007f..da5757eb 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, defaultProviderProfileOptions, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeConversationIntoList, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -63,7 +63,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { loading.value = false; let nextConversations = conversations.value; if (conversationsResult.ok) { - nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace)); + nextConversations = stableConversationList(conversations.value, conversationsResult.data?.conversations, selectedConversationIdFromWorkspace(nextWorkspace), selectedConversationFromWorkspace(nextWorkspace)); conversations.value = nextConversations; conversationsReady.value = true; void refreshSessionStatusAuthority(nextConversations); @@ -119,6 +119,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const requestEpoch = beginWorkspaceSelection(); const tabProjectId = conversation.projectId ?? activeProjectId.value; switchingConversationId.value = conversation.conversationId; + conversations.value = mergeConversationIntoList(conversations.value, conversation); + conversationsReady.value = true; workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId); messages.value = messagesFromConversation(conversation); void hydrateTurnStatusAuthority(messages.value); @@ -187,7 +189,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise { const response = await api.workbench.conversations(activeProjectId.value, { includeConversationId }); if (response.ok) { - conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId); + conversations.value = stableConversationList(conversations.value, response.data?.conversations, includeConversationId, activeConversation.value ?? selectedConversationFromWorkspace(workspace.value)); conversationsReady.value = true; await refreshSessionStatusAuthority(conversations.value); return; @@ -647,6 +649,30 @@ function messagesFromWorkspaceSelection(workspace: WorkspaceRecord | null, fallb return fallback ? messagesFromConversation(fallback) : []; } +function selectedConversationFromWorkspace(workspace: WorkspaceRecord | null): ConversationRecord | null { + const selected = workspace?.selectedConversation; + if (selected?.conversationId) { + const messages = Array.isArray(selected.messages) && selected.messages.length > 0 ? selected.messages : messagesFromWorkspace(workspace); + return { ...selected, messages, messageCount: selected.messageCount ?? (messages.length || undefined) }; + } + const conversationId = selectedConversationIdFromWorkspace(workspace); + if (!conversationId) return null; + const messages = messagesFromWorkspace(workspace); + const firstUserMessage = messages.find((message) => message.role === "user"); + return { + conversationId, + projectId: firstNonEmptyString(workspace?.projectId, workspace?.workspace?.projectId), + sessionId: firstNonEmptyString(workspace?.selectedAgentSessionId, workspace?.workspace?.selectedAgentSessionId), + threadId: firstNonEmptyString(workspace?.workspace?.threadId), + status: firstNonEmptyString(workspace?.workspace?.sessionStatus), + updatedAt: firstNonEmptyString(workspace?.updatedAt, workspace?.workspace?.updatedAt), + lastTraceId: firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId, workspace?.workspace?.lastTraceId), + firstUserMessagePreview: firstNonEmptyString(firstUserMessage?.text, firstUserMessage?.content), + messageCount: messages.length || undefined, + messages + }; +} + function normalizeChatMessage(message: ChatMessage): ChatMessage { const text = firstNonEmptyString(message.text, messageText((message as Record).content), messageText((message as Record).message), finalResponseText((message as Record).finalResponse)) ?? ""; const runnerTrace = normalizeMessageRunnerTrace(message);