Merge pull request #1306 from pikasTech/fix/issue-1293-session-selection
fix(web): preserve explicit workbench session selection
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user