Merge pull request #1341 from pikasTech/fix/1337-selected-session-tab

fix: 保留 Workbench 当前会话标签
This commit is contained in:
Lyon
2026-06-16 19:42:57 +08:00
committed by GitHub
3 changed files with 86 additions and 12 deletions
@@ -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), ["新输入", "旧输入"]);
@@ -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;
}
+29 -3
View File
@@ -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<void> {
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<string, unknown>).content), messageText((message as Record<string, unknown>).message), finalResponseText((message as Record<string, unknown>).finalResponse)) ?? "";
const runnerTrace = normalizeMessageRunnerTrace(message);