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") || ""));
+}