Merge pull request #1306 from pikasTech/fix/issue-1293-session-selection

fix(web): preserve explicit workbench session selection
This commit is contained in:
Lyon
2026-06-16 05:24:43 +08:00
committed by GitHub
3 changed files with 85 additions and 18 deletions
@@ -2,7 +2,7 @@ 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, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
import { defaultProviderProfileOptions, 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" });
@@ -30,6 +30,21 @@ test("R2 active trace cleanup preserves workspace and records stale reason", ()
assert.equal(cleared?.workspace?.sessionStatus, "failed");
});
test("R2 workspace snapshots cannot override a newer explicit session selection", () => {
assert.equal(shouldApplyWorkspaceSnapshot({
requestEpoch: 1,
currentEpoch: 2,
currentConversationId: "cnv_user_selected",
workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" })
}), false);
assert.equal(shouldApplyWorkspaceSnapshot({
requestEpoch: 1,
currentEpoch: 2,
currentConversationId: "cnv_r2",
workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" })
}), true);
});
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), ["新输入", "旧输入"]);
@@ -65,6 +65,17 @@ export function activeTraceIdFromWorkspace(workspace: WorkspaceRecord | null): s
return firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId);
}
export function selectedConversationIdFromWorkspace(workspace: WorkspaceRecord | null): string | null {
return firstNonEmptyString(workspace?.selectedConversationId, workspace?.workspace?.selectedConversationId, workspace?.selectedConversation?.conversationId);
}
export function shouldApplyWorkspaceSnapshot(input: { requestEpoch: number; currentEpoch: number; currentConversationId: string | null; workspace: WorkspaceRecord | null }): boolean {
if (input.requestEpoch === input.currentEpoch) return true;
const currentConversationId = firstNonEmptyString(input.currentConversationId);
if (!currentConversationId) return false;
return selectedConversationIdFromWorkspace(input.workspace) === currentConversationId;
}
export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string): WorkspaceRecord | null {
if (!workspace || !traceId) return workspace;
const activeTraceId = activeTraceIdFromWorkspace(workspace);
+58 -17
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, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, 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;
@@ -27,6 +27,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
const activeAbort = ref<AbortController | null>(null);
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const workspaceSelectionEpoch = ref(0);
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));
@@ -42,6 +43,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
async function hydrate(): Promise<void> {
const requestEpoch = workspaceSelectionEpoch.value;
loading.value = true;
error.value = null;
const [workspaceResult, conversationsResult] = await Promise.all([api.workbench.workspace(projectId.value), api.workbench.conversations(projectId.value)]);
@@ -50,15 +52,18 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = workspaceResult.error ?? "workspace unavailable";
return;
}
workspace.value = workspaceResult.data?.workspace ?? null;
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value));
const nextWorkspace = workspaceResult.data?.workspace ?? null;
conversations.value = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : [];
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) {
workspace.value = nextWorkspace;
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, projectId.value));
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
const activeTraceId = activeTraceIdFromWorkspace(workspace.value);
if (activeTraceId) void validateAndReattachTrace(activeTraceId);
}
await refreshProviderOptions();
const activeTraceId = activeTraceIdFromWorkspace(workspace.value);
if (activeTraceId) void validateAndReattachTrace(activeTraceId);
}
async function refreshLive(): Promise<void> {
@@ -77,10 +82,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function createSession(): Promise<void> {
const current = await ensureWorkspace();
if (!current) return;
const requestEpoch = beginWorkspaceSelection();
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: activeProjectId.value, create: true, updatedByClient: "cloud-web-vue" });
loading.value = false;
if (response.ok) {
if (response.ok && isCurrentWorkspaceSelection(requestEpoch)) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
currentRequest.value = null;
@@ -91,8 +97,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function selectConversation(conversation: ConversationRecord): Promise<void> {
const current = await ensureWorkspace();
if (!current) return;
const previousWorkspace = workspace.value;
const previousMessages = messages.value;
const requestEpoch = beginWorkspaceSelection();
const tabProjectId = conversation.projectId ?? activeProjectId.value;
switchingConversationId.value = conversation.conversationId;
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
@@ -100,17 +105,17 @@ export const useWorkbenchStore = defineStore("workbench", () => {
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
loading.value = false;
switchingConversationId.value = null;
clearSwitchingConversation(conversation.conversationId);
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return;
if (response.ok) {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
currentRequest.value = null;
} else {
workspace.value = previousWorkspace;
messages.value = previousMessages;
error.value = response.error ?? "session switch failed";
return;
}
if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return;
error.value = response.error ?? "session switch failed";
}
async function deleteCurrentSession(): Promise<void> {
@@ -382,6 +387,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
async function clearActiveTrace(traceId: string, reason: string): Promise<void> {
const requestEpoch = workspaceSelectionEpoch.value;
const current = workspaceWithClearedActiveTrace(workspace.value, traceId, reason);
if (current === workspace.value) return;
workspace.value = current;
@@ -395,7 +401,42 @@ export const useWorkbenchStore = defineStore("workbench", () => {
workspace: current.workspace,
updatedByClient: "cloud-web-vue-active-trace-repair"
});
if (response.ok && response.data?.workspace) workspace.value = response.data.workspace;
if (response.ok && response.data?.workspace && shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: response.data.workspace })) workspace.value = response.data.workspace;
}
function beginWorkspaceSelection(): number {
workspaceSelectionEpoch.value += 1;
return workspaceSelectionEpoch.value;
}
function isCurrentWorkspaceSelection(epoch: number, conversationId?: string | null): boolean {
if (epoch !== workspaceSelectionEpoch.value) return false;
if (!conversationId) return true;
return activeConversationId.value === conversationId || switchingConversationId.value === conversationId;
}
function clearSwitchingConversation(conversationId: string): void {
if (switchingConversationId.value === conversationId) switchingConversationId.value = null;
}
async function retrySelectConversationWithFreshWorkspace(conversation: ConversationRecord, tabProjectId: string, requestEpoch: number): Promise<boolean> {
const fresh = await api.workbench.workspace(tabProjectId);
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return true;
const freshWorkspace = fresh.data?.workspace;
if (!fresh.ok || !freshWorkspace?.workspaceId) return false;
workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
const retried = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" });
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return true;
if (!retried.ok) {
error.value = retried.error ?? "session switch failed";
return false;
}
workspace.value = retried.data?.workspace ?? workspace.value;
messages.value = messagesFromWorkspace(workspace.value);
void hydrateTerminalMessageDiagnostics();
currentRequest.value = null;
return true;
}
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };