Merge pull request #1307 from pikasTech/fix/issue-1293-selected-session

fix(web): keep selected workbench session visible
This commit is contained in:
Lyon
2026-06-16 05:54:01 +08:00
committed by GitHub
3 changed files with 92 additions and 6 deletions
@@ -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" },
@@ -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;
+5 -4
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, 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();