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 6d7889a9..7c800ac8 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, 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), ["新输入", "旧输入"]); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index fa2d451c..d69a98f0 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 34b4245b..06a0a1ee 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, 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(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 { + 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 { @@ -77,10 +82,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function createSession(): Promise { 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 { 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 { @@ -382,6 +387,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function clearActiveTrace(traceId: string, reason: string): Promise { + 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 { + 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 };