mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
rework /resume slash command
This commit is contained in:
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'clear',
|
||||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
|
||||
@@ -5,25 +5,28 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session picker hook for dialog mode (within main app).
|
||||
* Uses useKeypress (KeypressContext) instead of useInput (ink).
|
||||
* For standalone mode, use useSessionPicker instead.
|
||||
* Unified session picker hook for both dialog and standalone modes.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
|
||||
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
|
||||
* outside the main app.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
SessionService,
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
SessionListItem,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SESSION_PAGE_SIZE,
|
||||
filterSessions,
|
||||
SESSION_PAGE_SIZE,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
|
||||
export interface UseDialogSessionPickerOptions {
|
||||
export interface UseSessionPickerOptions {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
@@ -40,8 +43,7 @@ export interface UseDialogSessionPickerOptions {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDialogSessionPickerResult {
|
||||
// State
|
||||
export interface UseSessionPickerResult {
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
@@ -51,12 +53,10 @@ export interface UseDialogSessionPickerResult {
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
|
||||
// Actions
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDialogSessionPicker({
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
@@ -64,7 +64,7 @@ export function useDialogSessionPicker({
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
isActive = true,
|
||||
}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult {
|
||||
}: UseSessionPickerOptions): UseSessionPickerResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
@@ -73,43 +73,47 @@ export function useDialogSessionPicker({
|
||||
});
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// For follow mode (non-centered)
|
||||
const [followScrollOffset, setFollowScrollOffset] = useState(0);
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = filterSessions(
|
||||
sessionState.sessions,
|
||||
filterByBranch,
|
||||
currentBranch,
|
||||
const filteredSessions = useMemo(
|
||||
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
|
||||
[sessionState.sessions, filterByBranch, currentBranch],
|
||||
);
|
||||
|
||||
// Calculate scroll offset based on mode
|
||||
const scrollOffset = centerSelection
|
||||
? (() => {
|
||||
if (filteredSessions.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
})()
|
||||
: followScrollOffset;
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (centerSelection) {
|
||||
if (filteredSessions.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
}
|
||||
return followScrollOffset;
|
||||
}, [
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
followScrollOffset,
|
||||
maxVisibleItems,
|
||||
selectedIndex,
|
||||
]);
|
||||
|
||||
const visibleSessions = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
const visibleSessions = useMemo(
|
||||
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
|
||||
[filteredSessions, maxVisibleItems, scrollOffset],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Load initial sessions
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
// Guard: don't load if sessionService is not ready
|
||||
if (!sessionService) {
|
||||
return;
|
||||
}
|
||||
@@ -128,10 +132,10 @@ export function useDialogSessionPicker({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialSessions();
|
||||
|
||||
void loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
// Load more sessions
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
@@ -169,9 +173,8 @@ export function useDialogSessionPicker({
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Auto-load more when list is empty or near end (for centered mode)
|
||||
// Auto-load more when centered mode hits the sentinel or list is empty.
|
||||
useEffect(() => {
|
||||
// Don't auto-load during initial load or if not in centered mode
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
@@ -182,7 +185,6 @@ export function useDialogSessionPicker({
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
sessionState.hasMore &&
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
@@ -190,27 +192,25 @@ export function useDialogSessionPicker({
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
scrollOffset,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
filteredSessions.length,
|
||||
isLoading,
|
||||
loadMoreSessions,
|
||||
maxVisibleItems,
|
||||
scrollOffset,
|
||||
sessionState.hasMore,
|
||||
]);
|
||||
|
||||
// Handle keyboard input using useKeypress (KeypressContext)
|
||||
// Key handling (KeypressContext)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { name, sequence, ctrl } = key;
|
||||
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (name === 'escape' || (ctrl && name === 'c')) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (name === 'return') {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
@@ -219,11 +219,9 @@ export function useDialogSessionPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation up
|
||||
if (name === 'up' || name === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
@@ -232,7 +230,6 @@ export function useDialogSessionPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation down
|
||||
if (name === 'down' || name === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
@@ -240,28 +237,28 @@ export function useDialogSessionPicker({
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
// Load more if near the end
|
||||
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
|
||||
loadMoreSessions();
|
||||
|
||||
// Follow mode: load more when near the end.
|
||||
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle branch filter
|
||||
if (sequence === 'b' || sequence === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
97
packages/cli/src/ui/hooks/useSessionSelect.test.ts
Normal file
97
packages/cli/src/ui/hooks/useSessionSelect.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useSessionSelect } from './useSessionSelect.js';
|
||||
|
||||
vi.mock('../utils/resumeHistoryUtils.js', () => ({
|
||||
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
async loadSession(_sessionId: string) {
|
||||
return { conversation: [{ role: 'user', parts: [{ text: 'hello' }] }] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation: vi.fn(() => [{ role: 'user', parts: [] }]),
|
||||
replayUiTelemetryFromConversation: vi.fn(),
|
||||
uiTelemetryService: { reset: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
describe('useSessionSelect', () => {
|
||||
it('no-ops when config is null', async () => {
|
||||
const closeResumeDialog = vi.fn();
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionSelect({
|
||||
config: null,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current('session-1');
|
||||
});
|
||||
|
||||
expect(closeResumeDialog).not.toHaveBeenCalled();
|
||||
expect(startNewSession).not.toHaveBeenCalled();
|
||||
expect(historyManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(historyManager.loadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the dialog immediately and restores session state', async () => {
|
||||
const closeResumeDialog = vi.fn();
|
||||
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
|
||||
const startNewSession = vi.fn();
|
||||
const geminiClient = {
|
||||
initialize: vi.fn(),
|
||||
};
|
||||
|
||||
const config = {
|
||||
getTargetDir: () => '/tmp',
|
||||
getGeminiClient: () => geminiClient,
|
||||
startNewSession: vi.fn(),
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSessionSelect({
|
||||
config,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
}),
|
||||
);
|
||||
|
||||
const resumePromise = act(async () => {
|
||||
await result.current('session-2');
|
||||
});
|
||||
|
||||
expect(closeResumeDialog).toHaveBeenCalledTimes(1);
|
||||
await resumePromise;
|
||||
|
||||
expect(config.startNewSession).toHaveBeenCalledWith(
|
||||
'session-2',
|
||||
expect.objectContaining({
|
||||
conversation: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(startNewSession).toHaveBeenCalledWith('session-2');
|
||||
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
|
||||
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/ui/hooks/useSessionSelect.ts
Normal file
64
packages/cli/src/ui/hooks/useSessionSelect.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
export interface UseSessionSelectOptions {
|
||||
config: Config | null;
|
||||
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
|
||||
closeResumeDialog: () => void;
|
||||
startNewSession: (sessionId: string) => void;
|
||||
remount?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable callback to resume a saved session and restore UI + client state.
|
||||
*/
|
||||
export function useSessionSelect({
|
||||
config,
|
||||
closeResumeDialog,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
remount,
|
||||
}: UseSessionSelectOptions): (sessionId: string) => void {
|
||||
return useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog immediately to prevent input capture during async operations.
|
||||
closeResumeDialog();
|
||||
|
||||
const cwd = config.getTargetDir();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new session in UI context.
|
||||
startNewSession(sessionId);
|
||||
|
||||
// Reset UI history.
|
||||
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
|
||||
historyManager.clearItems();
|
||||
historyManager.loadHistory(uiHistoryItems);
|
||||
|
||||
// Update session history core.
|
||||
config.startNewSession(sessionId, sessionData);
|
||||
await config.getGeminiClient()?.initialize?.();
|
||||
|
||||
// Refresh terminal UI.
|
||||
remount?.();
|
||||
},
|
||||
[closeResumeDialog, config, historyManager, startNewSession, remount],
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session picker hook for standalone mode (fullscreen CLI picker).
|
||||
* Uses useInput (ink) instead of useKeypress (KeypressContext).
|
||||
* For dialog mode within the main app, use useDialogSessionPicker instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useInput } from 'ink';
|
||||
import type {
|
||||
SessionService,
|
||||
SessionListItem,
|
||||
ListSessionsResult,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SESSION_PAGE_SIZE,
|
||||
filterSessions,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
|
||||
export interface UseSessionPickerOptions {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
maxVisibleItems: number;
|
||||
/**
|
||||
* If true, computes centered scroll offset (keeps selection near middle).
|
||||
* If false, uses follow mode (scrolls when selection reaches edge).
|
||||
*/
|
||||
centerSelection?: boolean;
|
||||
/**
|
||||
* Optional callback when exiting (for standalone mode).
|
||||
*/
|
||||
onExit?: () => void;
|
||||
/**
|
||||
* Enable/disable input handling.
|
||||
*/
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UseSessionPickerResult {
|
||||
// State
|
||||
selectedIndex: number;
|
||||
sessionState: SessionState;
|
||||
filteredSessions: SessionListItem[];
|
||||
filterByBranch: boolean;
|
||||
isLoading: boolean;
|
||||
scrollOffset: number;
|
||||
visibleSessions: SessionListItem[];
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
|
||||
// Actions
|
||||
loadMoreSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSessionPicker({
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
maxVisibleItems,
|
||||
centerSelection = false,
|
||||
onExit,
|
||||
isActive = true,
|
||||
}: UseSessionPickerOptions): UseSessionPickerResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
hasMore: true,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// For follow mode (non-centered)
|
||||
const [followScrollOffset, setFollowScrollOffset] = useState(0);
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = filterSessions(
|
||||
sessionState.sessions,
|
||||
filterByBranch,
|
||||
currentBranch,
|
||||
);
|
||||
|
||||
// Calculate scroll offset based on mode
|
||||
const scrollOffset = centerSelection
|
||||
? (() => {
|
||||
if (filteredSessions.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
let offset = selectedIndex - halfVisible;
|
||||
offset = Math.max(0, offset);
|
||||
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
|
||||
return offset;
|
||||
})()
|
||||
: followScrollOffset;
|
||||
|
||||
const visibleSessions = filteredSessions.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + maxVisibleItems,
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
// Load initial sessions
|
||||
useEffect(() => {
|
||||
// Guard: don't load if sessionService is not ready
|
||||
if (!sessionService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadInitialSessions = async () => {
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
});
|
||||
setSessionState({
|
||||
sessions: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialSessions();
|
||||
}, [sessionService]);
|
||||
|
||||
// Load more sessions
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setFollowScrollOffset(0);
|
||||
}, [filterByBranch]);
|
||||
|
||||
// Ensure selectedIndex is valid when filtered sessions change
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex]);
|
||||
|
||||
// Auto-load more when list is empty or near end (for centered mode)
|
||||
useEffect(() => {
|
||||
// Don't auto-load during initial load or if not in centered mode
|
||||
if (
|
||||
isLoading ||
|
||||
!sessionState.hasMore ||
|
||||
isLoadingMoreRef.current ||
|
||||
!centerSelection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelVisible =
|
||||
sessionState.hasMore &&
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
|
||||
|
||||
if (shouldLoadMore) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
filteredSessions.length,
|
||||
loadMoreSessions,
|
||||
sessionState.hasMore,
|
||||
scrollOffset,
|
||||
maxVisibleItems,
|
||||
centerSelection,
|
||||
]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
onCancel();
|
||||
onExit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (key.return) {
|
||||
const session = filteredSessions[selectedIndex];
|
||||
if (session) {
|
||||
onSelect(session.sessionId);
|
||||
onExit?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation up
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.max(0, prev - 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (!centerSelection && newIndex < followScrollOffset) {
|
||||
setFollowScrollOffset(newIndex);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation down
|
||||
if (key.downArrow || input === 'j') {
|
||||
if (filteredSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
|
||||
// Adjust scroll offset if needed (for follow mode)
|
||||
if (
|
||||
!centerSelection &&
|
||||
newIndex >= followScrollOffset + maxVisibleItems
|
||||
) {
|
||||
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
|
||||
}
|
||||
// Load more if near the end
|
||||
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
|
||||
loadMoreSessions();
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle branch filter
|
||||
if (input === 'b' || input === 'B') {
|
||||
if (currentBranch) {
|
||||
setFilterByBranch((prev) => !prev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
sessionState,
|
||||
filteredSessions,
|
||||
filterByBranch,
|
||||
isLoading,
|
||||
scrollOffset,
|
||||
visibleSessions,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
loadMoreSessions,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user