diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5da98f0a..e70c0446 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -99,7 +99,6 @@ import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; -import { useSessionSelect } from './hooks/useSessionSelect.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -437,13 +436,14 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); - const { isResumeDialogOpen, openResumeDialog, closeResumeDialog } = - useResumeCommand(); - - const handleResumeSessionSelect = useSessionSelect({ + const { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + } = useResumeCommand({ config, historyManager, - closeResumeDialog, startNewSession, remount: refreshStatic, }); @@ -1442,7 +1442,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, }), [ handleThemeSelect, @@ -1478,7 +1478,7 @@ export const AppContainer = (props: AppContainerProps) => { // Resume session dialog openResumeDialog, closeResumeDialog, - handleResumeSessionSelect, + handleResume, ], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d79014e8..ce2c93bb 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -296,7 +296,7 @@ export const DialogManager = ({ ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f8456430..2e396335 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -67,7 +67,7 @@ export interface UIActions { // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; - handleResumeSessionSelect: (sessionId: string) => void; + handleResume: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 3303b644..a0441cd5 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -5,9 +5,59 @@ */ import { act, renderHook } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { useResumeCommand } from './useResumeCommand.js'; +const resumeMocks = vi.hoisted(() => { + let resolveLoadSession: + | ((value: { conversation: unknown } | undefined) => void) + | undefined; + let pendingLoadSession: + | Promise<{ conversation: unknown } | undefined> + | undefined; + + return { + createPendingLoadSession() { + pendingLoadSession = new Promise((resolve) => { + resolveLoadSession = resolve; + }); + return pendingLoadSession; + }, + resolvePendingLoadSession(value: { conversation: unknown } | undefined) { + resolveLoadSession?.(value); + }, + getPendingLoadSession() { + return pendingLoadSession; + }, + reset() { + resolveLoadSession = undefined; + pendingLoadSession = undefined; + }, + }; +}); + +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 ( + resumeMocks.getPendingLoadSession() ?? + Promise.resolve({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }) + ); + } + } + + return { + SessionService, + }; +}); + describe('useResumeCommand', () => { it('should initialize with dialog closed', () => { const { result } = renderHook(() => useResumeCommand()); @@ -48,10 +98,91 @@ describe('useResumeCommand', () => { const initialOpenFn = result.current.openResumeDialog; const initialCloseFn = result.current.closeResumeDialog; + const initialHandleResume = result.current.handleResume; rerender(); expect(result.current.openResumeDialog).toBe(initialOpenFn); expect(result.current.closeResumeDialog).toBe(initialCloseFn); + expect(result.current.handleResume).toBe(initialHandleResume); + }); + + it('handleResume no-ops when config is null', async () => { + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + + const { result } = renderHook(() => + useResumeCommand({ + config: null, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('session-1'); + }); + + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + }); + + it('handleResume closes the dialog immediately and restores session state', async () => { + resumeMocks.reset(); + resumeMocks.createPendingLoadSession(); + + 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(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + // Open first so we can verify the dialog closes immediately. + act(() => { + result.current.openResumeDialog(); + }); + expect(result.current.isResumeDialogOpen).toBe(true); + + const resumePromise = act(async () => { + // Intentionally do not resolve loadSession yet. + await result.current.handleResume('session-2'); + }); + + // After the first flush, the dialog should already be closed even though + // the session load is still pending. + await act(async () => {}); + expect(result.current.isResumeDialogOpen).toBe(false); + + // Now finish the async load and let the handler complete. + resumeMocks.resolvePendingLoadSession({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }); + 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); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index a0f683bf..8fc3d4dd 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -5,8 +5,27 @@ */ import { useState, 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 function useResumeCommand() { +export interface UseResumeCommandOptions { + config: Config | null; + historyManager: Pick; + startNewSession: (sessionId: string) => void; + remount?: () => void; +} + +export interface UseResumeCommandResult { + isResumeDialogOpen: boolean; + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; +} + +export function useResumeCommand( + options?: UseResumeCommandOptions, +): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); const openResumeDialog = useCallback(() => { @@ -17,9 +36,47 @@ export function useResumeCommand() { setIsResumeDialogOpen(false); }, []); + const { config, historyManager, startNewSession, remount } = options ?? {}; + + const handleResume = useCallback( + async (sessionId: string) => { + if (!config || !historyManager || !startNewSession) { + 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], + ); + return { isResumeDialogOpen, openResumeDialog, closeResumeDialog, + handleResume, }; } diff --git a/packages/cli/src/ui/hooks/useSessionSelect.test.ts b/packages/cli/src/ui/hooks/useSessionSelect.test.ts deleted file mode 100644 index 780636ac..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @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); - }); -}); diff --git a/packages/cli/src/ui/hooks/useSessionSelect.ts b/packages/cli/src/ui/hooks/useSessionSelect.ts deleted file mode 100644 index 17ef6879..00000000 --- a/packages/cli/src/ui/hooks/useSessionSelect.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @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; - 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], - ); -}