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],
- );
-}