diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index bc2eea41..ff890823 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -152,7 +152,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const session = createManualSession(body); state.selectedSessionId = session.sessionId; state.sessions.unshift(session); - return json(response, 200, { ok: true, status: "ok", session }); + return json(response, state.scenarioId === "create-while-route-loading" ? 201 : 200, { ok: true, status: "ok", session }); } const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u); @@ -305,7 +305,9 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_terminal_empty" : id === "progress-only-final-response" || id === "terminal-completed-no-final-response" || id === "tool-completed-projection-running" ? "ses_running" - : base.selectedSessionId; + : id === "create-while-route-loading" + ? "ses_running" + : base.selectedSessionId; const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; return { scenarioId: id, @@ -318,7 +320,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 : id === "session-switch-delayed-detail-frame" ? 900 : 0, + sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : id === "submit-authority-race" ? 1_000 : 0, chatDelayMs: id === "submit-authority-race" ? 1_000 : 0, terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore" || id === "progress-only-final-response" || id === "terminal-completed-no-final-response", terminalFailureScript: id === "stale-submit-restore", @@ -511,7 +513,11 @@ function appendScrollFollowEvents(count: number): JsonRecord { function createManualSession(body: JsonRecord): SessionRecord { const now = new Date().toISOString(); - const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); + const token = state.scenarioId === "server-authoritative-create" + ? "server_created" + : state.scenarioId === "create-while-route-loading" + ? "created_while_running" + : Date.now().toString(36); const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`; const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; return { sessionId, threadId, status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: null, messages: [] }; @@ -692,6 +698,7 @@ function crossSessionLateTraceA(): JsonRecord { function sessionDetailDelayMs(sessionId: string): number { if (state.scenarioId === "cross-session-late-events" && sessionId === "ses_late_A") return 900; + if (state.scenarioId === "create-while-route-loading" && (sessionId === "ses_running" || sessionId === "ses_created_while_running")) return 4_000; return state.sessionDetailDelayMs; } @@ -837,7 +844,10 @@ function workbenchSessionListPayload(url: URL): JsonRecord { const limit = boundedPageLimit(url.searchParams.get("limit")); const offset = cursorOffset(url.searchParams.get("cursor") || url.searchParams.get("after")); const visible = state.sessions.filter((item) => !item.hidden && (item.status !== "archived" || item.sessionId === includeSessionId)); - const listed = state.listOmitSelected && includeSessionId ? visible.filter((item) => item.sessionId !== includeSessionId) : visible; + const authorityVisible = state.scenarioId === "create-while-route-loading" && includeSessionId === "ses_running" + ? visible.filter((item) => item.sessionId !== "ses_created_while_running") + : visible; + const listed = state.listOmitSelected && includeSessionId ? authorityVisible.filter((item) => item.sessionId !== includeSessionId) : authorityVisible; const page = listed.slice(offset, offset + limit); const included = includeSessionId && !page.some((session) => session.sessionId === includeSessionId) ? listed.find((session) => session.sessionId === includeSessionId) ?? null : null; const summaries = [...(included ? [included] : []), ...page].map((session) => sessionSummary(session)); diff --git a/web/hwlab-cloud-web/src/router/workbench-navigation.ts b/web/hwlab-cloud-web/src/router/workbench-navigation.ts index c6351b48..465bfc95 100644 --- a/web/hwlab-cloud-web/src/router/workbench-navigation.ts +++ b/web/hwlab-cloud-web/src/router/workbench-navigation.ts @@ -5,6 +5,7 @@ export interface WorkbenchNavigationContext { routeSection?: unknown; routeSessionId?: string | null; sessionId?: string | null; + activeSelectionSource?: unknown; applyingRouteSession?: boolean; } @@ -26,7 +27,7 @@ export function shouldReflectWorkbenchSessionUrl(input: WorkbenchNavigationConte if (!input.sessionId) return false; if (!isWorkbenchNavigationContext(input)) return false; if (input.routeSessionId === input.sessionId) return false; - if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false; + if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId && input.activeSelectionSource !== "user" && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false; return true; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index b06b592e..4aafa3f0 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -28,6 +28,12 @@ interface HydrateOptions { invalidRouteId?: string | null; } +type SessionSelectionSource = "route" | "user" | "system"; + +interface SelectSessionOptions { + source?: SessionSelectionSource; +} + export const useWorkbenchStore = defineStore("workbench", () => { const providerProfile = ref(readString("hwlab.workbench.providerProfile.v1", "codex")); const providerOptions = ref(defaultProviderProfileOptions(providerProfile.value)); @@ -48,6 +54,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null }); const currentRequest = ref<{ traceId: string; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null); const explicitSessionId = ref(initialWorkbenchSessionIdFromLocation()); + const activeSelectionSource = ref(explicitSessionId.value ? "route" : "system"); const selectionEpoch = ref(0); const serverState = ref(createWorkbenchServerState()); const sessions = computed(() => selectSessionList(serverState.value)); @@ -63,6 +70,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { let activeTurnGapTimer: number | null = null; const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value)); + const activeSessionSelectionSource = computed(() => activeSelectionSource.value); const selectedSessionId = computed(() => firstNonEmptyString(activeSession.value?.sessionId, explicitSessionId.value)); const activeSessionId = computed(() => selectedSessionId.value); const activeMessages = computed(() => selectActiveMessages(serverState.value, activeSessionId.value)); @@ -89,7 +97,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const includeSessionId = routeSessionId ?? activeSessionId.value; if (routeSessionId) { sessionDetailLoadingId.value = routeSessionId; - setActiveSessionSelection(routeSessionId); + setActiveSessionSelection(routeSessionId, "route"); } const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT }); if (!sessionsResult.ok) { @@ -102,16 +110,18 @@ export const useWorkbenchStore = defineStore("workbench", () => { const listedSessions = workbenchSessionsFromPayload(sessionsResult.data); const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null; if (targetSessionId && (routeSessionId || !routeRequestId)) { - setActiveSessionSelection(targetSessionId); + setActiveSessionSelection(targetSessionId, routeSessionId ? "route" : "system"); } if (targetSessionId) sessionDetailLoadingId.value = targetSessionId; const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null; - if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId); - if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected); - if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected); + if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, "route"); + const selectedIsCurrent = Boolean(selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)); + if (selectedIsCurrent && selected) applySelectedSessionDetail(selected, "route"); + if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId) && selected.sessionId !== activeSessionId.value) rememberSessionDetail(selected); if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived"); if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found"); - const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected); + const stableSelected = selectedIsCurrent && selected ? selected : activeSessionListSeed(); + const nextSessions = stableSessionList(sessions.value, listedSessions, stableSelected?.sessionId ?? selected?.sessionId ?? routeSessionId ?? includeSessionId, stableSelected); rememberSessionList(nextSessions); sessionsReady.value = true; void refreshSessionStatusAuthority(nextSessions); @@ -146,15 +156,16 @@ export const useWorkbenchStore = defineStore("workbench", () => { } rememberSessionList(mergeSessionIntoList(sessions.value, created)); sessionsReady.value = true; - setActiveSessionSelection(created.sessionId); + setActiveSessionSelection(created.sessionId, "user"); await selectSession(created); } - async function selectSession(session: WorkbenchSessionRecord): Promise { - await selectSessionById(session.sessionId, session); + async function selectSession(session: WorkbenchSessionRecord, options: SelectSessionOptions = {}): Promise { + await selectSessionById(session.sessionId, session, options); } - async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise { + async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null, options: SelectSessionOptions = {}): Promise { + const source = options.source ?? "user"; const requestId = normalizeWorkbenchSessionRouteId(sessionId); const normalized = normalizeWorkbenchSessionId(sessionId); if (!requestId) { @@ -167,20 +178,20 @@ export const useWorkbenchStore = defineStore("workbench", () => { switchingSessionId.value = requestId; sessionDetailLoadingId.value = requestId; loading.value = true; - if (normalized) setActiveSessionSelection(normalized); + if (normalized) setActiveSessionSelection(normalized, source); if (existing) { rememberSessionList(mergeSessionIntoList(sessions.value, existing)); sessionsReady.value = true; } const selected = await loadWorkbenchSession(requestId, existing); - if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId); + if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, source); if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) { clearSessionDetailLoading(requestId); clearSwitchingSession(requestId); return true; } if (selected && !isArchivedSession(selected)) { - applySelectedSessionDetail(selected); + applySelectedSessionDetail(selected, source); await nextTick(); loading.value = false; clearSessionDetailLoading(requestId); @@ -213,7 +224,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { replaceActiveSessionSelection(next?.sessionId ?? null); currentRequest.value = null; if (next) { - await selectSession(next); + await selectSession(next, { source: "system" }); return; } restartRealtime("delete-current-session"); @@ -223,7 +234,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await api.workbench.sessions({ includeSessionId, limit: currentSessionListLimit() }); if (response.ok) { const listed = workbenchSessionsFromPayload(response.data); - const refreshed = stableSessionList(sessions.value, listed, includeSessionId, activeSession.value); + const activeSeed = activeSessionListSeed(); + const refreshed = stableSessionList(sessions.value, listed, activeSeed?.sessionId ?? includeSessionId, activeSeed); const next = sessions.value.length > refreshed.length ? appendSessionPage(sessions.value, refreshed) : refreshed; rememberSessionList(next); applySessionPagination(response.data); @@ -300,12 +312,20 @@ export const useWorkbenchStore = defineStore("workbench", () => { replaceActiveMessages([...messages.value, ...items]); } - function setActiveSessionSelection(sessionId: string | null | undefined): void { - explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value; + function setActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void { + const normalized = normalizeWorkbenchSessionId(sessionId); + if (!normalized) return; + explicitSessionId.value = normalized; + activeSelectionSource.value = source; } - function replaceActiveSessionSelection(sessionId: string | null | undefined): void { + function replaceActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void { explicitSessionId.value = normalizeWorkbenchSessionId(sessionId); + activeSelectionSource.value = source; + } + + function activeSessionListSeed(): WorkbenchSessionRecord | null { + return activeSession.value ?? null; } async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise { @@ -987,8 +1007,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!sessionId || sessionDetailLoadingId.value === sessionId) sessionDetailLoadingId.value = null; } - function applySelectedSessionDetail(session: WorkbenchSessionRecord): void { - setActiveSessionSelection(session.sessionId); + function applySelectedSessionDetail(session: WorkbenchSessionRecord, source: SessionSelectionSource = "system"): void { + setActiveSessionSelection(session.sessionId, source); const projectedMessages = messagesWithTraceAuthority(messagesFromSession(session)); rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length }); sessionsReady.value = true; @@ -1017,7 +1037,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { installRealtimeVisibilityHandler(); - return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity }; + return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity }; }); function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] { diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index 195029d4..f6a2e9eb 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -50,7 +50,7 @@ async function applyRouteSession(): Promise { applyingRouteSession.value = true; let selected = false; try { - selected = sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectSessionById(rawRouteSessionId.value); + selected = sessionId ? await workbench.selectSessionById(sessionId, null, { source: "route" }) : await workbench.selectSessionById(rawRouteSessionId.value, null, { source: "route" }); return selected; } finally { applyingRouteSession.value = false; @@ -68,6 +68,7 @@ async function reflectActiveSessionInUrl(value: string | null): Promise { routeSection: route.meta.section, routeSessionId: routeTarget, sessionId, + activeSelectionSource: workbench.activeSessionSelectionSource, applyingRouteSession: applyingRouteSession.value })) return; await router.replace({ diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts index 47bee4ac..ff4a0124 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts @@ -1,5 +1,5 @@ import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; -import { selectors } from "../fixtures/selectors"; +import { selectors, sessionTab } from "../fixtures/selectors"; test("new optimistic session first turn does not submit local thr_* as upstream thread", async ({ page }) => { await gotoWorkbench(page); @@ -51,6 +51,27 @@ test.describe("server authoritative session create", () => { }); }); +test.describe("session create while old route is still loading", () => { + test.use({ scenarioId: "create-while-route-loading" }); + + test("created session owns URL, active tab, and composer while old running session refreshes", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/ses_running"); + + const created = page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/v1/agent/sessions"); + await page.locator(selectors.sessionCreate).click(); + expect((await created).status()).toBe(201); + + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_created_while_running/u, { timeout: 500 }); + await expect(page.locator(sessionTab("ses_created_while_running"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "false"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true"); + await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn"); + + const state = await fakeServerState(page); + expect((state.legacyRequestLedger as unknown[]).length).toBe(0); + }); +}); + test.describe("session create and delete authority", () => { test.use({ scenarioId: "server-authoritative-create" });