mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: implement infinite scrolling for native sessions and add reset functionality to queue scheduler
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"nativeSessions": {
|
||||
"count": "{count} 个原生会话",
|
||||
"sessions": "个会话",
|
||||
"loading": "加载中...",
|
||||
"loadMore": "加载更多",
|
||||
"empty": {
|
||||
"title": "无原生会话",
|
||||
"message": "来自 Gemini、Codex、Qwen 等的原生 CLI 会话将显示在这里。"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user