fix: keep created workbench session active

This commit is contained in:
lyon
2026-06-19 09:14:47 +08:00
parent 3c60b9ae58
commit d9078c4929
5 changed files with 82 additions and 29 deletions
@@ -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));
@@ -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;
}
+41 -21
View File
@@ -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<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
const providerOptions = ref<ProviderProfileOption[]>(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<string | null>(initialWorkbenchSessionIdFromLocation());
const activeSelectionSource = ref<SessionSelectionSource>(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<void> {
await selectSessionById(session.sessionId, session);
async function selectSession(session: WorkbenchSessionRecord, options: SelectSessionOptions = {}): Promise<void> {
await selectSessionById(session.sessionId, session, options);
}
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<boolean> {
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null, options: SelectSessionOptions = {}): Promise<boolean> {
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<void> {
@@ -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[] {
@@ -50,7 +50,7 @@ async function applyRouteSession(): Promise<boolean> {
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<void> {
routeSection: route.meta.section,
routeSessionId: routeTarget,
sessionId,
activeSelectionSource: workbench.activeSessionSelectionSource,
applyingRouteSession: applyingRouteSession.value
})) return;
await router.replace({
@@ -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" });