Merge pull request #1550 from pikasTech/codex/1544-workbench-session-pagination

feat(workbench): paginate session rail history
This commit is contained in:
Lyon
2026-06-19 01:37:20 +08:00
committed by GitHub
10 changed files with 212 additions and 20 deletions
+10 -4
View File
@@ -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);
+20 -3
View File
@@ -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);
+2 -1
View File
@@ -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);
@@ -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 {
+9
View File
@@ -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}` : ""}`;
}
@@ -90,6 +90,13 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
</button>
<p v-if="workbench.sessionTabs.length === 0" class="muted-box">没有 session发送前必须显式新建或选择 session</p>
<div v-else class="session-list-footer">
<button v-if="workbench.sessionListHasMore" id="session-load-more" class="btn btn-secondary btn-sm session-load-more" type="button" :disabled="workbench.sessionListLoadingMore" @click="workbench.loadMoreSessions">
{{ workbench.sessionListLoadingMore ? '加载中' : '加载更多' }}
</button>
<p v-else id="session-list-complete" class="session-list-terminal">已显示全部 sessions</p>
<p v-if="workbench.sessionListLoadMoreError" id="session-load-more-error" class="session-list-error">{{ workbench.sessionListLoadMoreError }}</p>
</div>
</template>
</div>
<div class="session-active-metadata" id="session-status">
@@ -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;
+49 -5
View File
@@ -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<string | null>(null);
const sessionDetailLoadingId = ref<string | null>(null);
const sessionListHasMore = ref(false);
const sessionListNextCursor = ref<string | null>(null);
const sessionListLoadingMore = ref(false);
const sessionListLoadMoreError = ref<string | null>(null);
const chatPending = ref(false);
const error = ref<string | null>(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<void> {
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<void> {
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[] {
@@ -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;
@@ -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: <T>(callback: (nodes: Element[]) => T) => Promise<T> } }): Promise<string[]> {
return await page.locator(".session-tab").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-session-id") || ""));
}