From cf6b4bfd8af93063c84579fbb816e35517432d88 Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 00:25:56 +0800 Subject: [PATCH] fix(workbench): gate session switch detail projection --- .../scripts/workbench-e2e-server.ts | 17 ++++- web/hwlab-cloud-web/src/stores/workbench.ts | 24 ++++-- .../specs/session-switch-reload.spec.ts | 75 +++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 46918150..322fe149 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -247,6 +247,10 @@ function createScenarioState(scenarioId: string): ScenarioState { sessions.unshift(submitSplitSession()); traces.trc_submit_split = submitSplitTrace(); } + if (id === "session-switch-delayed-detail-frame") { + markSessionText(sessions, "ses_running", "SESSION_A_ONLY", "SESSION_A_ONLY running source session"); + markSessionText(sessions, "ses_completed", "SESSION_B_ONLY", "SESSION_B_ONLY completed target session"); + } if (id === "session-rail-many") { for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index)); } @@ -285,7 +289,7 @@ function createScenarioState(scenarioId: string): ScenarioState { chatRequests: [], listOmitSelected: id === "selected-missing-from-list", sessionDelayMs: id === "loading" ? 2_500 : 0, - sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : 0, + sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : 0, chatDelayMs: id === "submit-authority-race" ? 1_000 : 0, terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore", terminalFailureScript: id === "stale-submit-restore", @@ -295,6 +299,17 @@ function createScenarioState(scenarioId: string): ScenarioState { }; } +function markSessionText(sessions: SessionRecord[], sessionId: string, userText: string, agentText: string): void { + const session = sessions.find((item) => item.sessionId === sessionId); + if (!session) return; + session.firstUserMessagePreview = userText; + session.messages = (session.messages ?? []).map((message) => { + if (message.role === "user") return { ...message, text: userText }; + if (message.role === "agent") return { ...message, text: agentText }; + return message; + }); +} + function createManualSession(body: JsonRecord): SessionRecord { const now = new Date().toISOString(); const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 19645ab8..7d534195 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -75,14 +75,18 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function hydrate(options: HydrateOptions = {}): Promise { const routeRequestId = normalizeWorkbenchSessionRouteId(options.sessionId); const routeSessionId = normalizeWorkbenchSessionId(options.sessionId); - if (routeSessionId) setActiveSessionSelection(routeSessionId); const requestEpoch = beginSessionSelection(); sessionsReady.value = sessions.value.length > 0; loading.value = true; error.value = null; const includeSessionId = routeSessionId ?? activeSessionId.value; + if (routeSessionId) { + sessionDetailLoadingId.value = routeSessionId; + setActiveSessionSelection(routeSessionId); + } const sessionsResult = await api.workbench.sessions({ includeSessionId }); if (!sessionsResult.ok) { + clearSessionDetailLoading(includeSessionId); loading.value = false; error.value = sessionsResult.error ?? "session list unavailable"; return; @@ -93,9 +97,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { setActiveSessionSelection(targetSessionId); } if (targetSessionId) sessionDetailLoadingId.value = targetSessionId; + const previousMessages = messages.value; const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null; - clearSessionDetailLoading(targetSessionId); - if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value); + if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, previousMessages); if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected); if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false }); if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false }); @@ -104,6 +108,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberSessionList(nextSessions); sessionsReady.value = true; void refreshSessionStatusAuthority(nextSessions); + clearSessionDetailLoading(targetSessionId); loading.value = false; await refreshProviderOptions(); restartRealtime("hydrate"); @@ -156,27 +161,30 @@ export const useWorkbenchStore = defineStore("workbench", () => { const requestEpoch = beginSessionSelection(); startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" }); switchingSessionId.value = requestId; - if (normalized) setActiveSessionSelection(normalized); sessionDetailLoadingId.value = requestId; + loading.value = true; + if (normalized) setActiveSessionSelection(normalized); if (existing) { sessions.value = mergeSessionIntoList(sessions.value, existing); rememberSessionDetail(existing); sessionsReady.value = true; } - loading.value = true; const selected = await loadWorkbenchSession(requestId, existing); - loading.value = false; - clearSessionDetailLoading(requestId); - clearSwitchingSession(requestId); if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true; if (selected && !isArchivedSession(selected)) { applySelectedSessionDetail(selected, previousMessages); + clearSessionDetailLoading(requestId); + clearSwitchingSession(requestId); + loading.value = false; error.value = null; await refreshSessions(selected.sessionId); finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok"); return activeSessionId.value === selected.sessionId; } isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) }); + clearSessionDetailLoading(requestId); + clearSwitchingSession(requestId); + loading.value = false; failWorkbenchSessionSwitch(normalized ?? requestId); return false; } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts index e845957a..d516cd3e 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts @@ -1,6 +1,21 @@ import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; import { selectors, sessionTab } from "../fixtures/selectors"; +type FrameSample = { + activeSessionId: string | null; + loading: boolean; + hasA: boolean; + hasB: boolean; + textPreview: string; +}; + +declare global { + interface Window { + __issue1536Sampling?: boolean; + __issue1536Samples?: FrameSample[]; + } +} + test("session switch persists active session after reload", async ({ page }, testInfo) => { await gotoWorkbench(page); await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true"); @@ -20,6 +35,66 @@ test("session switch persists active session after reload", async ({ page }, tes await saveScreenshot(page, testInfo, "session-switch-reload"); }); +test.describe("delayed session detail projection", () => { + test.use({ scenarioId: "session-switch-delayed-detail-frame" }); + + test("loading exit never flashes previous session messages under target session", async ({ page }, testInfo) => { + await gotoWorkbench(page, "/workbench/sessions/ses_running"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.conversationList)).toContainText("SESSION_A_ONLY"); + await saveScreenshot(page, testInfo, "session-switch-delayed-before"); + + await page.evaluate(() => { + const samples: FrameSample[] = []; + const read = (): void => { + const active = document.querySelector('.session-tab[data-active="true"]'); + const panel = document.querySelector("#conversation-list"); + const text = panel?.textContent ?? ""; + samples.push({ + activeSessionId: active?.getAttribute("data-session-id") ?? null, + loading: Boolean(document.querySelector("#conversation-list .conversation-detail-loading")), + hasA: text.includes("SESSION_A_ONLY"), + hasB: text.includes("SESSION_B_ONLY"), + textPreview: text.replace(/\s+/gu, " ").trim().slice(0, 160) + }); + }; + const loop = (): void => { + read(); + if (window.__issue1536Sampling === true) requestAnimationFrame(loop); + }; + window.__issue1536Samples = samples; + window.__issue1536Sampling = true; + requestAnimationFrame(loop); + }); + + await page.locator(sessionTab("ses_completed")).click(); + await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator("#conversation-list .conversation-detail-loading")).toBeVisible(); + await saveScreenshot(page, testInfo, "session-switch-delayed-loading"); + + await expect(page.locator("#conversation-list .conversation-detail-loading")).toHaveCount(0); + await saveScreenshot(page, testInfo, "session-switch-delayed-first-non-loading"); + await expect(page.locator(selectors.conversationList)).toContainText("SESSION_B_ONLY"); + await saveScreenshot(page, testInfo, "session-switch-delayed-final"); + + const samples = await page.evaluate(() => { + window.__issue1536Sampling = false; + return window.__issue1536Samples ?? []; + }) as FrameSample[]; + await testInfo.attach("session-switch-delayed-frame-samples.json", { + body: JSON.stringify(samples, null, 2), + contentType: "application/json" + }); + const targetFrames = samples.filter((sample) => sample.activeSessionId === "ses_completed"); + const leakedFrames = targetFrames.filter((sample) => sample.hasA); + const firstNonLoadingTarget = targetFrames.find((sample) => !sample.loading); + + expect(leakedFrames, JSON.stringify(leakedFrames.slice(0, 5), null, 2)).toEqual([]); + expect(firstNonLoadingTarget?.hasB, JSON.stringify(firstNonLoadingTarget ?? null, null, 2)).toBe(true); + expect(samples.filter((sample) => sample.hasA && sample.hasB), "old and target messages must never share one frame").toEqual([]); + }); +}); + test.describe("empty session switch target", () => { test.use({ scenarioId: "session-switch-empty-reload" });