Merge pull request #1550 from pikasTech/codex/1544-workbench-session-pagination
feat(workbench): paginate session rail history
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") || ""));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user