diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 4c9ff116..26aadcf6 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1150,13 +1150,14 @@ class MemoryAccessStore { const projectId = textOr(input.projectId, ""); const conversationId = textOr(input.conversationId, ""); const limit = boundedListLimit(input.limit); + const offset = nonNegativeListOffset(input.offset); return [...this.agentSessions.values()] .filter((session) => input.ownerScoped === true || role !== "admin" ? session.ownerUserId === ownerUserId : true) .filter((session) => !projectId || session.projectId === projectId) .filter((session) => !conversationId || session.conversationId === conversationId) .filter((session) => input.includeArchived === true || session.status !== "archived") - .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))) - .slice(0, limit); + .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")) || String(b.id ?? "").localeCompare(String(a.id ?? ""))) + .slice(offset, offset + limit); } async archiveAgentConversation(input = {}) { const ownerUserId = textOr(input.ownerUserId, ""); @@ -1308,6 +1309,7 @@ class PostgresAccessStore extends MemoryAccessStore { const projectId = textOr(input.projectId, ""); const conversationId = textOr(input.conversationId, ""); const limit = boundedListLimit(input.limit); + const offset = nonNegativeListOffset(input.offset); const clauses = []; const params = []; if (input.ownerScoped === true || role !== "admin") { @@ -1324,7 +1326,10 @@ class PostgresAccessStore extends MemoryAccessStore { } if (input.includeArchived !== true) clauses.push("status <> 'archived'"); params.push(limit); - const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST LIMIT $${params.length}`; + const limitParam = params.length; + params.push(offset); + const offsetParam = params.length; + const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST, id DESC LIMIT $${limitParam} OFFSET $${offsetParam}`; const result = await this.query(sql, params); return result.rows.map(pgAgentSession); } @@ -1761,7 +1766,8 @@ function firstMessageField(messagesSource, key) { return null; } function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } -function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } +function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 101); } +function nonNegativeListOffset(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; } function defaultWorkspaceId(ownerUserId, projectId) { return `wsp_${sha256(`${ownerUserId}:${projectId}`).slice(0, 24)}`; } function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date().toISOString(), { create = false, replaceJson = false } = {}) { const patch = normalizeObject(input.patch); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index a129dce8..9817f0a3 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -208,23 +208,40 @@ 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 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")); const readModel = createWorkbenchReadModel(options, actor); - const sessions = await readModel.listSessions({ limit, includeRouteId }); - const summaries = sessions.map((session) => sessionSummary(session, options)).filter(Boolean); + const naturalPage = await readModel.listSessions({ limit: limit + 1, offset }); + const pageSessions = naturalPage.slice(0, limit); + let responseSessions = pageSessions; + if (includeRouteId && !pageSessions.some((session) => sessionMatchesRouteId(session, includeRouteId))) { + const included = await readModel.getSessionByRouteId(includeRouteId); + if (included) responseSessions = [included, ...pageSessions]; + } + const summaries = responseSessions.map((session) => sessionSummary(session, options)).filter(Boolean); + const hasMore = naturalPage.length > limit; sendJson(response, 200, { ok: true, status: "succeeded", contractVersion: "workbench-sessions-v1", sessions: summaries, count: summaries.length, - nextCursor: null, + cursor: offset > 0 ? cursorFromOffset(offset) : null, + hasMore, + nextCursor: hasMore ? cursorFromOffset(offset + limit) : null, valuesRedacted: true, secretMaterialStored: false }); } +function sessionMatchesRouteId(session, routeId) { + const sessionId = safeSessionId(routeId); + if (sessionId && session?.id === sessionId) return true; + const conversationId = safeConversationId(routeId); + return Boolean(conversationId && session?.conversationId === conversationId); +} + async function handleWorkbenchSessionDetail(response, options, actor, sessionId) { const readModel = createWorkbenchReadModel(options, actor); const session = await readModel.getSessionByRouteId(sessionId); diff --git a/internal/cloud/workbench-facts-store.ts b/internal/cloud/workbench-facts-store.ts index 2d6a5ed2..08fc603d 100644 --- a/internal/cloud/workbench-facts-store.ts +++ b/internal/cloud/workbench-facts-store.ts @@ -45,12 +45,13 @@ export function createWorkbenchFactsStore(options = {}, actor = null) { return canActorReadSession(session, actor) ? session : null; } - async function listSessions({ limit, includeRouteId } = {}) { + async function listSessions({ limit, offset = 0, includeRouteId } = {}) { let sessions = await accessStore?.listAgentSessionsForUser?.({ ownerUserId: actor?.id, actorRole: actor?.role, ownerScoped: true, limit, + offset, includeArchived: false }) ?? []; const includeSessionId = safeSessionId(includeRouteId); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index da2f8b1e..a7c67091 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -263,7 +263,7 @@ function createScenarioState(scenarioId: string): ScenarioState { 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)); + for (let index = 0; index < 51; index += 1) sessions.push(manyRailSession(index)); } if (id === "session-switch-detail-404-isolated") { sessions.unshift(staleDetailSession()); @@ -628,11 +628,33 @@ function isTerminalStatus(status: string): boolean { function workbenchSessionListPayload(url: URL): JsonRecord { if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return { ok: false, status: 400, error: { code: "workbench_authority_removed" } }; - const includeSessionId = url.searchParams.get("includeSessionId") || ""; + const includeRouteId = url.searchParams.get("includeSessionId") || ""; + const includeSessionId = includeRouteId ? canonicalSessionId(includeRouteId) : ""; + 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 summaries = listed.map((session) => sessionSummary(session)); - return { ok: true, status: "succeeded", sessions: summaries, count: summaries.length }; + 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)); + const hasMore = offset + limit < listed.length; + return { ok: true, status: "succeeded", contractVersion: "workbench-sessions-v1", sessions: summaries, count: summaries.length, total: listed.length, cursor: offset > 0 ? cursorFromOffset(offset) : null, hasMore, nextCursor: hasMore ? cursorFromOffset(offset + limit) : null }; +} + +function boundedPageLimit(value: string | null): number { + const parsed = Number.parseInt(value ?? "", 10); + return Math.min(100, Math.max(1, Number.isInteger(parsed) && parsed > 0 ? parsed : 50)); +} + +function cursorOffset(value: string | null): number { + const text = typeof value === "string" ? value.trim() : ""; + if (!text) return 0; + if (text.startsWith("idx:")) return Math.max(0, Number.parseInt(text.slice(4), 10) || 0); + return Math.max(0, Number.parseInt(text, 10) || 0); +} + +function cursorFromOffset(offset: number): string { + return `idx:${Math.max(0, Math.trunc(offset))}`; } function workbenchSessionMessagesPayload(session: SessionRecord, url: URL): JsonRecord { diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 9daf8f37..cfa5b523 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -7,6 +7,10 @@ import type { AgentChatResultResponse, ApiResult, ChatMessage, WorkbenchSessionR export interface WorkbenchSessionListResponse { sessions?: WorkbenchSessionRecord[]; count?: number; + total?: number; + cursor?: string | null; + hasMore?: boolean; + nextCursor?: string | null; } export interface WorkbenchSessionDetailResponse { @@ -23,6 +27,8 @@ export interface WorkbenchMessagePageResponse { export interface SessionListOptions { includeSessionId?: string | null; + limit?: number | null; + cursor?: string | null; timeoutMs?: number | null; } @@ -52,6 +58,9 @@ function sessionListPath(options: SessionListOptions): string { const params = new URLSearchParams(); const includeSessionId = typeof options.includeSessionId === "string" ? options.includeSessionId.trim() : ""; if (includeSessionId) params.set("includeSessionId", includeSessionId); + if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); + const cursor = typeof options.cursor === "string" ? options.cursor.trim() : ""; + if (cursor) params.set("cursor", cursor); const query = params.toString(); return `/v1/workbench/sessions${query ? `?${query}` : ""}`; } diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index 9b406f7c..b57519f1 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -90,6 +90,13 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {

没有 session。发送前必须显式新建或选择 session。

+
diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 415b1303..ffce3d7c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -173,6 +173,23 @@ export function stableSessionList(current: WorkbenchSessionRecord[], candidate: return selected ? mergeSelectedSessionDetail(stable, selected) : stable.filter((session) => !isArchivedSession(session)); } +export function appendSessionPage(current: WorkbenchSessionRecord[], candidate: unknown): WorkbenchSessionRecord[] { + if (!Array.isArray(candidate)) return current.filter((session) => !isArchivedSession(session)); + const next = current.filter((session) => !isArchivedSession(session)); + for (const session of (candidate as WorkbenchSessionRecord[]).filter((item) => !isArchivedSession(item))) { + const sessionId = firstNonEmptyString(session.sessionId); + if (!sessionId) continue; + const existingIndex = next.findIndex((item) => item.sessionId === sessionId); + const existing = existingIndex >= 0 ? next[existingIndex] : null; + if (existing) { + next[existingIndex] = mergeSessionRecord(existing, session); + } else { + next.push(session); + } + } + return next; +} + export function mergeSessionIntoList(current: WorkbenchSessionRecord[], session: WorkbenchSessionRecord | null | undefined): WorkbenchSessionRecord[] { const sessionId = firstNonEmptyString(session?.sessionId); if (!sessionId || !session) return current; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 089b4e25..1ba235f7 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -9,7 +9,7 @@ import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProviderProfile, TraceEvent, WorkbenchSessionRecord } from "@/types"; import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance"; -import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; @@ -21,6 +21,8 @@ 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; + interface HydrateOptions { sessionId?: string | null; invalidRouteId?: string | null; @@ -37,6 +39,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { const sessionsReady = ref(false); const switchingSessionId = ref(null); const sessionDetailLoadingId = ref(null); + const sessionListHasMore = ref(false); + const sessionListNextCursor = ref(null); + const sessionListLoadingMore = ref(false); + const sessionListLoadMoreError = ref(null); const chatPending = ref(false); const error = ref(null); const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null }); @@ -63,6 +69,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const messages = activeMessages; const selectedThreadId = computed(() => firstNonEmptyString(activeSession.value?.threadId)); const sessionTabs = computed(() => sortSessionTabs(sessions.value, activeSessionId.value, sessionStatusAuthority.value)); + const sessionListLoadedCount = computed(() => sessionTabs.value.length); const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value })); const sessionDetailLoading = computed(() => Boolean(sessionDetailLoadingId.value || switchingSessionId.value || (loading.value && messages.value.length === 0))); const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value })); @@ -84,13 +91,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { sessionDetailLoadingId.value = routeSessionId; setActiveSessionSelection(routeSessionId); } - const sessionsResult = await api.workbench.sessions({ includeSessionId }); + const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT }); if (!sessionsResult.ok) { clearSessionDetailLoading(includeSessionId); loading.value = false; error.value = sessionsResult.error ?? "session list unavailable"; return; } + applySessionPagination(sessionsResult.data); const listedSessions = workbenchSessionsFromPayload(sessionsResult.data); const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null; if (targetSessionId && (routeSessionId || !routeRequestId)) { @@ -212,10 +220,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function refreshSessions(includeSessionId: string | null = activeSessionId.value): Promise { - const response = await api.workbench.sessions({ includeSessionId }); + const response = await api.workbench.sessions({ includeSessionId, limit: currentSessionListLimit() }); if (response.ok) { - const next = stableSessionList(sessions.value, workbenchSessionsFromPayload(response.data), includeSessionId, activeSession.value); + const listed = workbenchSessionsFromPayload(response.data); + const refreshed = stableSessionList(sessions.value, listed, includeSessionId, activeSession.value); + const next = sessions.value.length > refreshed.length ? appendSessionPage(sessions.value, refreshed) : refreshed; rememberSessionList(next); + applySessionPagination(response.data); sessionsReady.value = true; await refreshSessionStatusAuthority(next); return; @@ -224,6 +235,39 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable"; } + async function loadMoreSessions(): Promise { + if (sessionListLoadingMore.value || !sessionListHasMore.value) return; + const cursor = sessionListNextCursor.value; + if (!cursor) { + sessionListHasMore.value = false; + return; + } + sessionListLoadingMore.value = true; + sessionListLoadMoreError.value = null; + const response = await api.workbench.sessions({ includeSessionId: activeSessionId.value, limit: SESSION_LIST_PAGE_LIMIT, cursor }); + sessionListLoadingMore.value = false; + if (!response.ok) { + sessionListLoadMoreError.value = response.error ?? "session list load more failed"; + return; + } + const next = appendSessionPage(sessions.value, workbenchSessionsFromPayload(response.data)); + rememberSessionList(next); + applySessionPagination(response.data); + sessionsReady.value = true; + await refreshSessionStatusAuthority(next); + } + + function currentSessionListLimit(): number { + return Math.max(SESSION_LIST_PAGE_LIMIT, sessionListLoadedCount.value || 0); + } + + function applySessionPagination(payload: unknown): void { + const record = recordValue(payload); + sessionListHasMore.value = record?.hasMore === true; + sessionListNextCursor.value = sessionListHasMore.value ? firstNonEmptyString(record?.nextCursor) : null; + if (!sessionListHasMore.value) sessionListLoadMoreError.value = null; + } + function reduceServerState(action: WorkbenchServerAction): void { serverState.value = reduceWorkbenchServerState(serverState.value, action); } @@ -966,7 +1010,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { installRealtimeVisibilityHandler(); - return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, 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, 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/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 75ee4cfc..c7c11900 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -693,6 +693,30 @@ white-space: nowrap; } +.session-list-footer { + display: grid; + gap: 6px; + padding: 2px 0 6px; +} + +.session-load-more { + width: 100%; + justify-content: center; +} + +.session-list-terminal, +.session-list-error { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.35; + text-align: center; +} + +.session-list-error { + color: #b91c1c; +} + .muted-box { color: #64748b; font-size: 12px; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts index 0edfd3a2..da84eb08 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts @@ -1,11 +1,11 @@ -import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; -import { selectors } from "../fixtures/selectors"; +import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; test.use({ scenarioId: "session-rail-many" }); test("session rail does not issue per-tab status requests", async ({ page }) => { await gotoWorkbench(page); - await expect(page.locator(".session-tab")).toHaveCount(50); + await expect(page.locator(".session-tab")).toHaveCount(20); await page.waitForTimeout(1200); const state = await fakeServerState(page); const requests = state.requestLedger as Array<{ method?: string; path?: string }>; @@ -16,3 +16,48 @@ test("session rail does not issue per-tab status requests", async ({ page }) => expect((state.legacyRequestLedger as unknown[]).length).toBe(0); await expect(page.locator(selectors.commandInput)).toBeVisible(); }); + +test("session rail loads historical pages and keeps deep-linked old sessions visible", async ({ page }, testInfo) => { + const firstPage = await page.request.get("/v1/workbench/sessions?limit=20"); + expect(firstPage.ok()).toBeTruthy(); + const firstBody = await firstPage.json() as { count?: number; hasMore?: boolean; nextCursor?: string | null; sessions?: Array<{ sessionId?: string }> }; + expect(firstBody.count).toBe(20); + expect(firstBody.hasMore).toBe(true); + expect(firstBody.nextCursor).toBe("idx:20"); + + await gotoWorkbench(page, "/workbench/sessions/ses_completed"); + await expect(page.locator(".session-tab")).toHaveCount(20); + await expect(page.locator("#session-load-more")).toBeVisible(); + const page1Ids = await visibleSessionIds(page); + expect(new Set(page1Ids).size).toBe(20); + const messageCountBefore = await page.locator(selectors.messageCard).count(); + expect(messageCountBefore).toBeGreaterThan(0); + await saveScreenshot(page, testInfo, "session-pagination-first-page"); + + await page.locator("#session-load-more").click(); + await expect(page.locator(".session-tab")).toHaveCount(40); + await expect(page.locator(selectors.messageCard)).toHaveCount(messageCountBefore); + const page2Ids = await visibleSessionIds(page); + expect(new Set(page2Ids).size).toBe(40); + for (const sessionId of page1Ids) expect(page2Ids).toContain(sessionId); + await saveScreenshot(page, testInfo, "session-pagination-second-page"); + + await page.locator("#session-load-more").click(); + await expect(page.locator(".session-tab")).toHaveCount(55); + await expect(page.locator("#session-list-complete")).toBeVisible(); + await expect(page.locator("#session-load-more")).toHaveCount(0); + const allIds = await visibleSessionIds(page); + expect(new Set(allIds).size).toBe(55); + expect(allIds).toContain("ses_many_42"); + await saveScreenshot(page, testInfo, "session-pagination-no-more"); + + await gotoWorkbench(page, "/workbench/sessions/ses_many_42"); + const oldTab = page.locator(sessionTab("ses_many_42")); + await expect(oldTab).toBeVisible(); + await expect(oldTab).toHaveAttribute("data-active", "true"); + await saveScreenshot(page, testInfo, "session-pagination-deep-link"); +}); + +async function visibleSessionIds(page: { locator: (selector: string) => { evaluateAll: (callback: (nodes: Element[]) => T) => Promise } }): Promise { + return await page.locator(".session-tab").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-session-id") || "")); +}