feat: implement infinite scrolling for native sessions and add reset functionality to queue scheduler

This commit is contained in:
catlog22
2026-02-27 21:24:44 +08:00
parent a581a2e62b
commit 9be35ed5fb
12 changed files with 263 additions and 131 deletions

View File

@@ -4,7 +4,7 @@
// TanStack Query hook for native CLI sessions list
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import {
fetchNativeSessions,
type NativeSessionListItem,
@@ -17,6 +17,7 @@ import { workspaceQueryKeys } from '@/lib/queryKeys';
const STALE_TIME = 2 * 60 * 1000; // 2 minutes (increased from 30s)
const GC_TIME = 10 * 60 * 1000; // 10 minutes (increased from 5min)
const PAGE_SIZE = 50; // Default page size for pagination
// ========== Types ==========
@@ -54,6 +55,29 @@ export interface UseNativeSessionsReturn {
refetch: () => Promise<void>;
}
export interface UseNativeSessionsInfiniteReturn {
/** All sessions data (flattened from all pages) */
sessions: NativeSessionListItem[];
/** Sessions grouped by tool */
byTool: ByToolRecord;
/** Total count from current pages */
count: number;
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Fetching next page */
isFetchingNextPage: boolean;
/** Whether there are more pages */
hasNextPage: boolean;
/** Error object if query failed */
error: Error | null;
/** Fetch next page */
fetchNextPage: () => Promise<void>;
/** Manually refetch data */
refetch: () => Promise<void>;
}
// ========== Helper Functions ==========
/**
@@ -95,7 +119,7 @@ export function useNativeSessions(
const query = useQuery<NativeSessionsListResponse>({
queryKey: workspaceQueryKeys.nativeSessionsList(projectPath, tool),
queryFn: () => fetchNativeSessions(tool, projectPath),
queryFn: () => fetchNativeSessions({ tool, project: projectPath, limit: 200 }),
staleTime,
gcTime,
enabled,
@@ -107,7 +131,7 @@ export function useNativeSessions(
// Memoize sessions and byTool calculations
const { sessions, byTool } = React.useMemo(() => {
const sessions = query.data?.sessions ?? [];
const byTool = groupByTool(sessions);
const byTool = query.data?.byTool ?? groupByTool(sessions);
return { sessions, byTool };
}, [query.data]);
@@ -125,3 +149,81 @@ export function useNativeSessions(
refetch,
};
}
/**
* Hook for fetching native CLI sessions with infinite scroll/pagination
*
* @example
* ```tsx
* const { sessions, fetchNextPage, hasNextPage, isFetchingNextPage } = useNativeSessionsInfinite();
*
* // Load more button
* <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
* {isFetchingNextPage ? 'Loading...' : 'Load More'}
* </button>
* ```
*/
export function useNativeSessionsInfinite(
options: UseNativeSessionsOptions = {}
): UseNativeSessionsInfiniteReturn {
const { tool, staleTime = STALE_TIME, gcTime = GC_TIME, enabled = true } = options;
const projectPath = useWorkflowStore(selectProjectPath);
const query = useInfiniteQuery<NativeSessionsListResponse>({
queryKey: [...workspaceQueryKeys.nativeSessionsList(projectPath, tool), 'infinite'],
queryFn: ({ pageParam }) => fetchNativeSessions({
tool,
project: projectPath,
limit: PAGE_SIZE,
cursor: pageParam as string | null,
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => {
if (!lastPage.hasMore || !lastPage.nextCursor) return undefined;
return lastPage.nextCursor;
},
staleTime,
gcTime,
enabled,
refetchOnWindowFocus: false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
// Flatten all pages into a single array and group by tool
const { sessions, byTool, count } = React.useMemo(() => {
const allSessions = query.data?.pages.flatMap(page => page.sessions) ?? [];
// Merge byTool from all pages
const mergedByTool: ByToolRecord = {};
for (const page of query.data?.pages ?? []) {
for (const [toolKey, toolSessions] of Object.entries(page.byTool ?? {})) {
if (!mergedByTool[toolKey]) {
mergedByTool[toolKey] = [];
}
mergedByTool[toolKey].push(...toolSessions);
}
}
return { sessions: allSessions, byTool: mergedByTool, count: allSessions.length };
}, [query.data]);
const fetchNextPage = async () => {
await query.fetchNextPage();
};
const refetch = async () => {
await query.refetch();
};
return {
sessions,
byTool,
count,
isLoading: query.isLoading,
isFetching: query.isFetching,
isFetchingNextPage: query.isFetchingNextPage,
hasNextPage: query.hasNextPage,
error: query.error,
fetchNextPage,
refetch,
};
}

View File

@@ -2388,21 +2388,35 @@ export interface NativeSessionListItem {
*/
export interface NativeSessionsListResponse {
sessions: NativeSessionListItem[];
byTool: Record<string, NativeSessionListItem[]>;
count: number;
hasMore: boolean;
nextCursor: string | null;
}
/**
* Fetch list of native CLI sessions
* @param tool - Filter by tool type (optional)
* @param project - Filter by project path (optional)
* Fetch options for native sessions pagination
*/
export interface FetchNativeSessionsOptions {
tool?: 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
project?: string;
limit?: number;
cursor?: string | null; // ISO timestamp for cursor-based pagination
}
/**
* Fetch list of native CLI sessions with pagination support
* @param options - Pagination and filter options
*/
export async function fetchNativeSessions(
tool?: 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode',
project?: string
options: FetchNativeSessionsOptions = {}
): Promise<NativeSessionsListResponse> {
const { tool, project, limit = 50, cursor } = options;
const params = new URLSearchParams();
if (tool) params.set('tool', tool);
if (project) params.set('project', project);
if (project) params.set('path', project);
if (limit) params.set('limit', String(limit));
if (cursor) params.set('cursor', cursor);
const query = params.toString();
return fetchApi<NativeSessionsListResponse>(

View File

@@ -38,6 +38,8 @@
"nativeSessions": {
"count": "{count} native sessions",
"sessions": "sessions",
"loading": "Loading...",
"loadMore": "Load More",
"empty": {
"title": "No Native Sessions",
"message": "Native CLI sessions from Gemini, Codex, Qwen, etc. will appear here."

View File

@@ -38,6 +38,8 @@
"nativeSessions": {
"count": "{count} 个原生会话",
"sessions": "个会话",
"loading": "加载中...",
"loadMore": "加载更多",
"empty": {
"title": "无原生会话",
"message": "来自 Gemini、Codex、Qwen 等的原生 CLI 会话将显示在这里。"

View File

@@ -24,7 +24,7 @@ import {
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
import { cn } from '@/lib/utils';
import { useHistory } from '@/hooks/useHistory';
import { useNativeSessions } from '@/hooks/useNativeSessions';
import { useNativeSessionsInfinite } from '@/hooks/useNativeSessions';
import { ConversationCard } from '@/components/shared/ConversationCard';
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
@@ -86,15 +86,18 @@ export function HistoryPage() {
filter: { search: searchQuery || undefined, tool: toolFilter },
});
// Native sessions hook
// Native sessions hook (infinite loading)
const {
sessions: nativeSessions,
byTool: nativeSessionsByTool,
isLoading: isLoadingNativeSessions,
isFetching: isFetchingNativeSessions,
isFetchingNextPage: isLoadingMoreNativeSessions,
hasNextPage: hasMoreNativeSessions,
error: nativeSessionsError,
fetchNextPage: loadMoreNativeSessions,
refetch: refetchNativeSessions,
} = useNativeSessions();
} = useNativeSessionsInfinite();
// Track expanded tool groups in native sessions tab
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(new Set());
@@ -423,7 +426,7 @@ export function HistoryPage() {
variant="outline"
size="sm"
onClick={() => refetchNativeSessions()}
disabled={isFetchingNativeSessions}
disabled={isFetchingNativeSessions && !isLoadingMoreNativeSessions}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetchingNativeSessions && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
@@ -571,6 +574,27 @@ export function HistoryPage() {
</div>
);
})}
{/* Load More Button */}
{hasMoreNativeSessions && (
<div className="flex justify-center pt-4">
<Button
variant="outline"
size="sm"
onClick={() => loadMoreNativeSessions()}
disabled={isLoadingMoreNativeSessions}
>
{isLoadingMoreNativeSessions ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
{formatMessage({ id: 'history.nativeSessions.loading', defaultMessage: 'Loading...' })}
</>
) : (
formatMessage({ id: 'history.nativeSessions.loadMore', defaultMessage: 'Load More' })
)}
</Button>
</div>
)}
</div>
)}
</div>

View File

@@ -55,6 +55,8 @@ interface QueueSchedulerActions {
pauseQueue: () => Promise<void>;
/** Stop the queue scheduler via POST /api/queue/scheduler/stop */
stopQueue: () => Promise<void>;
/** Reset the queue scheduler via POST /api/queue/scheduler/reset */
resetQueue: () => Promise<void>;
/** Update scheduler config via POST /api/queue/scheduler/config */
updateConfig: (config: Partial<QueueSchedulerConfig>) => Promise<void>;
}
@@ -255,6 +257,24 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
}
},
resetQueue: async () => {
try {
const response = await fetch('/api/queue/scheduler/reset', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || body.message || response.statusText);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[QueueScheduler] resetQueue error:', message);
set({ error: message }, false, 'resetQueue/error');
}
},
updateConfig: async (config: Partial<QueueSchedulerConfig>) => {
try {
const response = await fetch('/api/queue/scheduler/config', {

View File

@@ -852,17 +852,33 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: List Native CLI Sessions
// API: List Native CLI Sessions (with pagination support)
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || null;
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const cursor = url.searchParams.get('cursor'); // ISO timestamp for cursor-based pagination
try {
const sessions = listAllNativeSessions({
// Parse cursor timestamp if provided
const afterTimestamp = cursor ? new Date(cursor) : undefined;
// Fetch sessions with limit + 1 to detect if there are more
const allSessions = listAllNativeSessions({
workingDir: projectPath || undefined,
limit
limit: limit + 1, // Fetch one extra to check hasMore
afterTimestamp
});
// Determine if there are more results
const hasMore = allSessions.length > limit;
const sessions = hasMore ? allSessions.slice(0, limit) : allSessions;
// Get next cursor (timestamp of last item for cursor-based pagination)
const nextCursor = sessions.length > 0
? sessions[sessions.length - 1].updatedAt.toISOString()
: null;
// Group sessions by tool
const byTool: Record<string, typeof sessions> = {};
for (const session of sessions) {
@@ -873,7 +889,13 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sessions, byTool }));
res.end(JSON.stringify({
sessions,
byTool,
hasMore,
nextCursor,
count: sessions.length
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));

View File

@@ -46,6 +46,10 @@ export async function handleQueueSchedulerRoutes(
for (const item of items) {
schedulerService.addItem(item);
}
} else if (state.status === 'completed' || state.status === 'failed') {
// Auto-reset when scheduler is in terminal state and start fresh
schedulerService.reset();
schedulerService.start(items);
} else {
return {
error: `Cannot add items when scheduler is in '${state.status}' state`,
@@ -131,6 +135,22 @@ export async function handleQueueSchedulerRoutes(
return true;
}
// POST /api/queue/scheduler/reset - Reset scheduler to idle state
if (pathname === '/api/queue/scheduler/reset' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
schedulerService.reset();
return {
success: true,
state: schedulerService.getState(),
};
} catch (err) {
return { error: (err as Error).message, status: 409 };
}
});
return true;
}
// POST /api/queue/scheduler/config - Update scheduler configuration
if (pathname === '/api/queue/scheduler/config' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {