diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index fbb55011..93f8657f 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -17,6 +17,7 @@ import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { createWorkbenchTurnProjection, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; const DEFAULT_PAGE_LIMIT = 50; +const DEFAULT_SESSION_LIST_LIMIT = 8; const MAX_PAGE_LIMIT = 100; const DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS = 15000; export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) { @@ -307,7 +308,7 @@ async function authenticateWorkbenchRead(request, response, options) { async function handleWorkbenchSessionList(response, url, options, actor) { if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench session list is keyed by sessionId only.")); - const limit = boundedLimit(url.searchParams.get("limit")); + const limit = boundedSessionListLimit(url.searchParams.get("limit")); const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after")); const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId")); @@ -924,6 +925,10 @@ function boundedLimit(value) { return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_PAGE_LIMIT)); } +function boundedSessionListLimit(value) { + return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_SESSION_LIST_LIMIT)); +} + function cursorOffset(value) { const text = textValue(value); if (!text) return 0; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 1d80a08f..e402942c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -21,7 +21,7 @@ const TRACE_HYDRATION_MAX_ATTEMPTS = 3; const TRACE_HYDRATION_RETRY_DELAY_MS = 700; const ACTIVE_TURN_GAP_DELAY_MS = 1_000; const ACTIVE_TURN_GAP_MAX_ATTEMPTS = 12; -const SESSION_LIST_PAGE_LIMIT = 20; +const SESSION_LIST_PAGE_LIMIT = 8; interface HydrateOptions { sessionId?: string | null; @@ -68,6 +68,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { let realtimeKey = ""; let realtimeGapTimer: number | null = null; let activeTurnGapTimer: number | null = null; + const sessionListRefreshInFlight = new Map>(); const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value)); const activeSessionSelectionSource = computed(() => activeSelectionSource.value); @@ -233,7 +234,20 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function refreshSessions(includeSessionId: string | null = activeSessionId.value): Promise { - const response = await api.workbench.sessions({ includeSessionId, limit: currentSessionListLimit() }); + const requestIncludeSessionId = firstNonEmptyString(includeSessionId); + const requestLimit = currentSessionListLimit(); + const requestKey = `${requestIncludeSessionId ?? ""}|${requestLimit}`; + const existing = sessionListRefreshInFlight.get(requestKey); + if (existing) return existing; + const refresh = refreshSessionsNow(requestIncludeSessionId, requestLimit).finally(() => { + if (sessionListRefreshInFlight.get(requestKey) === refresh) sessionListRefreshInFlight.delete(requestKey); + }); + sessionListRefreshInFlight.set(requestKey, refresh); + return refresh; + } + + async function refreshSessionsNow(includeSessionId: string | null, limit: number): Promise { + const response = await api.workbench.sessions({ includeSessionId, limit }); if (response.ok) { const listed = workbenchSessionsFromPayload(response.data); const activeSeed = activeSessionListSeed();