Fix input focus issue by using useKeypress instead of useInput for ResumeSessionDialog

This commit is contained in:
Alexander Farber
2025-12-13 13:04:01 +01:00
parent 1098c23b26
commit 56a62bcb2a
2 changed files with 289 additions and 2 deletions

View File

@@ -8,7 +8,7 @@ import { useState, useEffect, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core'; import { SessionService, getGitBranch } from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useSessionPicker } from '../hooks/useSessionPicker.js'; import { useDialogSessionPicker } from '../hooks/useDialogSessionPicker.js';
import { SessionListItemView } from './SessionListItem.js'; import { SessionListItemView } from './SessionListItem.js';
export interface ResumeSessionDialogProps { export interface ResumeSessionDialogProps {
@@ -40,7 +40,7 @@ export function ResumeSessionDialog({
? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3)) ? Math.max(3, Math.floor((availableTerminalHeight - 6) / 3))
: 5; : 5;
const picker = useSessionPicker({ const picker = useDialogSessionPicker({
sessionService: sessionServiceRef.current, sessionService: sessionServiceRef.current,
currentBranch, currentBranch,
onSelect, onSelect,

View File

@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Session picker hook for dialog mode (within main app).
* Uses useKeypress (KeypressContext) instead of useInput (ink).
* For standalone mode, use useSessionPicker instead.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type {
SessionService,
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
import {
SESSION_PAGE_SIZE,
filterSessions,
} from '../utils/sessionPickerUtils.js';
import { useKeypress } from './useKeypress.js';
export interface SessionState {
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}
export interface UseDialogSessionPickerOptions {
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;
/**
* Enable/disable input handling.
*/
isActive?: boolean;
}
export interface UseDialogSessionPickerResult {
// 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 useDialogSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection = false,
isActive = true,
}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult {
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 using useKeypress (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) {
onSelect(session.sessionId);
}
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);
}
return newIndex;
});
return;
}
// Navigation down
if (name === 'down' || name === '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 (sequence === 'b' || sequence === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
},
{ isActive },
);
return {
selectedIndex,
sessionState,
filteredSessions,
filterByBranch,
isLoading,
scrollOffset,
visibleSessions,
showScrollUp,
showScrollDown,
loadMoreSessions,
};
}