diff --git a/web/src/components/NewSession/CodexImportActions.test.tsx b/web/src/components/NewSession/CodexImportActions.test.tsx new file mode 100644 index 00000000..51bed940 --- /dev/null +++ b/web/src/components/NewSession/CodexImportActions.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CodexImportActions } from './CodexImportActions' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +describe('CodexImportActions', () => { + it('exposes one Codex history entry point', () => { + const onChooseHistory = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'codexSync.newSessionInline.choose' })) + + expect(onChooseHistory).toHaveBeenCalledOnce() + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('disables the import entry point while sessions are loading', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'codexSync.confirm.loading' })).toBeDisabled() + }) +}) diff --git a/web/src/components/NewSession/CodexImportActions.tsx b/web/src/components/NewSession/CodexImportActions.tsx new file mode 100644 index 00000000..5b23c65c --- /dev/null +++ b/web/src/components/NewSession/CodexImportActions.tsx @@ -0,0 +1,42 @@ +import type { CodexLocalSessionSummary } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' + +export function CodexImportActions(props: { + selectedSession: CodexLocalSessionSummary | null + isLoading: boolean + isDisabled: boolean + error: string | null + onChooseHistory: () => void + onClear: () => void +}) { + const { t } = useTranslation() + + return ( +
+
+
+
{t('codexSync.newSessionInline.title')}
+
+ {props.selectedSession ? props.selectedSession.title : t('codexSync.newSessionInline.description')} +
+
+
+ {props.selectedSession ? ( + + ) : null} + +
+
+ {props.error ?
{props.error}
: null} +
+ ) +} diff --git a/web/src/components/NewSession/codexImportMerge.test.ts b/web/src/components/NewSession/codexImportMerge.test.ts new file mode 100644 index 00000000..ec124f06 --- /dev/null +++ b/web/src/components/NewSession/codexImportMerge.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge' + +describe('resolveCodexImportRedirectSessionId', () => { + it('prefers the canonical session returned by duplicate merge', () => { + expect(resolveCodexImportRedirectSessionId( + [{ canonicalSessionId: 'canonical-session' }], + ['imported-session'] + )).toBe('canonical-session') + }) + + it('falls back to the imported Hapi session when merge omits a canonical id', () => { + expect(resolveCodexImportRedirectSessionId( + [{}], + ['imported-session'] + )).toBe('imported-session') + }) + + it('returns null when neither source provides a session id', () => { + expect(resolveCodexImportRedirectSessionId([], [])).toBeNull() + }) +}) + + +describe('clearBatchImportedCodexSelection', () => { + it('clears a selected history included in the completed batch', () => { + expect(clearBatchImportedCodexSelection('codex-a', ['codex-a', 'codex-b'])).toBeNull() + }) + + it('preserves a selected history outside the completed batch', () => { + expect(clearBatchImportedCodexSelection('codex-a', ['codex-b'])).toBe('codex-a') + }) +}) diff --git a/web/src/components/NewSession/codexImportMerge.ts b/web/src/components/NewSession/codexImportMerge.ts new file mode 100644 index 00000000..20fa91b0 --- /dev/null +++ b/web/src/components/NewSession/codexImportMerge.ts @@ -0,0 +1,15 @@ +export function resolveCodexImportRedirectSessionId( + merged: ReadonlyArray<{ canonicalSessionId?: string | null }>, + importedHapiSessionIds: readonly string[] +): string | null { + return merged.find((group) => Boolean(group.canonicalSessionId))?.canonicalSessionId + ?? importedHapiSessionIds[0] + ?? null +} + +export function clearBatchImportedCodexSelection( + selectedSessionId: string | null, + importedSessionIds: string[] +): string | null { + return selectedSessionId && importedSessionIds.includes(selectedSessionId) ? null : selectedSessionId +} diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index c7685fed..034c98a3 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -21,6 +21,9 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/use-translation', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/lib/toast-context', () => ({ + useToast: () => ({ addToast: vi.fn() }) +})) vi.mock('@/hooks/usePlatform', () => ({ usePlatform: () => ({ haptic: { notification: mocks.notification } }) })) diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 8d4d8c91..7287e77b 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' -import type { CodexLocalSessionSummary, Machine } from '@/types/api' +import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine } from '@/types/api' import type { CodexCollaborationMode, GrokPermissionMode } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' @@ -38,6 +38,8 @@ import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTi import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' import { CollaborationModeSelector } from './CollaborationModeSelector' +import { CodexImportActions } from './CodexImportActions' +import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge' import { DirectorySection } from './DirectorySection' import { GrokPermissionModeSelector } from './GrokPermissionModeSelector' import { FastModeSelector } from './FastModeSelector' @@ -60,51 +62,14 @@ import { import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' import { markCodexSessionsImported } from '@/lib/codexImportedSessions' +import { useToast } from '@/lib/toast-context' -function CodexImportSelectButton(props: { - selectedSession: CodexLocalSessionSummary | null - isLoading: boolean - isDisabled: boolean - error: string | null - onOpen: () => void - onClear: () => void -}) { - const { t } = useTranslation() - return ( -
-
-
-
{t('codexSync.newSessionInline.title')}
-
- {props.selectedSession ? props.selectedSession.title : t('codexSync.newSessionInline.description')} -
-
-
- {props.selectedSession ? ( - - ) : null} - -
-
- {props.error ?
{props.error}
: null} -
- ) -} - export function NewSession(props: { api: ApiClient machines: Machine[] @@ -117,8 +82,9 @@ export function NewSession(props: { }) { const { haptic } = usePlatform() const { t } = useTranslation() + const { addToast } = useToast() const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) - const { sessions } = useSessions(props.api) + const { sessions, refetch: refetchSessions } = useSessions(props.api) const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() const [machineId, setMachineId] = useState(props.initialMachineId ?? null) @@ -149,7 +115,14 @@ export function NewSession(props: { const [isCodexImportDialogOpen, setIsCodexImportDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const createInFlightRef = useRef(false) - const isFormDisabled = Boolean(isCreating || isPending || props.isLoading || isImportingCodexSession) + const [isBulkImportingCodexSessions, setIsBulkImportingCodexSessions] = useState(false) + const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false) + const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState([]) + const [pendingDuplicateHapiSessionIds, setPendingDuplicateHapiSessionIds] = useState([]) + const [duplicateSessionGroups, setDuplicateSessionGroups] = useState([]) + const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) + const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) + const isFormDisabled = Boolean(isCreating || isPending || props.isLoading || isImportingCodexSession || isBulkImportingCodexSessions) const worktreeInputRef = useRef(null) const preserveRestoredDraftRef = useRef(false) @@ -726,6 +699,200 @@ export function NewSession(props: { } }, [agent, machineId, props.api, trimmedDirectory, t]) + const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => { + const raw = (message ?? '').trim() + if (!raw) return fallback + if (/执行超时|timed\s*out|timeout/i.test(raw)) return t('codexSync.error.timeout') + if (/当前会话仍处于活跃状态,请等待会话结束后重试|Active Hapi process already has this Codex thread/i.test(raw)) { + return t('codexSync.error.active') + } + if (/未安装\/找不到codex客户端|unable to find codex launcher|找不到.*codex/i.test(raw)) { + return t('codexSync.restart.failed.notFound') + } + return raw + }, [t]) + + const formatCodexImportFailure = useCallback((reason: string): string => { + if ( + reason === t('codexSync.error.timeout') + || reason === t('codexSync.error.active') + || reason === t('codexSync.restart.failed.notFound') + ) { + return reason + } + return t('codexSync.failed.bodyWithReason', { reason }) + }, [t]) + + const handleRestartCodexDesktop = useCallback(async () => { + setIsRestartingCodexDesktop(true) + try { + const status = await props.api.getCodexDesktopStatus() + if (!status.codexClientAvailable) throw new Error(t('codexSync.restart.failed.notFound')) + + const result = await props.api.restartCodexDesktop() + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.restart.failed.body'))) + } + addToast({ + title: t('codexSync.restart.started.title'), + body: t('codexSync.restart.started.body'), + sessionId: '', + url: '' + }) + } catch (restartError) { + addToast({ + title: t('codexSync.restart.failed.title'), + body: normalizeCodexScriptError( + restartError instanceof Error ? restartError.message : null, + t('codexSync.restart.failed.body') + ), + sessionId: '', + url: '' + }) + } finally { + setIsRestartingCodexDesktop(false) + } + }, [addToast, normalizeCodexScriptError, props.api, t]) + + const closeDuplicateMergeDialog = useCallback(() => { + setIsDuplicateMergeConfirmOpen(false) + setPendingDuplicateSessionIds([]) + setPendingDuplicateHapiSessionIds([]) + setDuplicateSessionGroups([]) + }, []) + + const handleBulkImportCodexSessions = useCallback(async (sessionIds: string[]) => { + if (isBulkImportingCodexSessions || isLoadingCodexImportSessions) return + + setIsBulkImportingCodexSessions(true) + try { + const result = await props.api.syncCodexSession({ + sessionIds, + cwd: trimmedDirectory || null, + machineId: codexImportMachineId ?? machineId + }) + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.failed.body'))) + } + + markCodexSessionsImported(sessionIds) + setSelectedCodexImportSessionId((current) => + clearBatchImportedCodexSelection(current, sessionIds) + ) + setIsCodexImportDialogOpen(false) + addToast({ + title: t('codexSync.success.title'), + body: t('codexSync.success.body', { n: result.syncedCount ?? sessionIds.length }), + sessionId: '', + url: '' + }) + await refetchSessions() + + closeDuplicateMergeDialog() + setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) + try { + const duplicateResult = await props.api.getCodexDuplicateSessions({ sessionIds }) + if (!duplicateResult.success) { + throw new Error(normalizeCodexScriptError( + duplicateResult.error, + t('codexSync.duplicates.detect.failed.body') + )) + } + if (duplicateResult.duplicates.length > 0) { + setPendingDuplicateSessionIds(sessionIds) + setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) + setDuplicateSessionGroups(duplicateResult.duplicates) + setIsDuplicateMergeConfirmOpen(true) + } + } catch (duplicateError) { + addToast({ + title: t('codexSync.duplicates.detect.failed.title'), + body: normalizeCodexScriptError( + duplicateError instanceof Error ? duplicateError.message : null, + t('codexSync.duplicates.detect.failed.body') + ), + sessionId: '', + url: '' + }) + } + } catch (importError) { + const reason = normalizeCodexScriptError( + importError instanceof Error ? importError.message : null, + t('dialog.error.default') + ) + addToast({ + title: t('codexSync.failed.title'), + body: formatCodexImportFailure(reason), + sessionId: '', + url: '' + }) + } finally { + setIsBulkImportingCodexSessions(false) + } + }, [ + addToast, + closeDuplicateMergeDialog, + codexImportMachineId, + formatCodexImportFailure, + isBulkImportingCodexSessions, + isLoadingCodexImportSessions, + machineId, + normalizeCodexScriptError, + props.api, + refetchSessions, + t, + trimmedDirectory + ]) + + const handleMergeDuplicateSessions = useCallback(async () => { + if (isMergingDuplicateSessions || pendingDuplicateSessionIds.length === 0) return + + setIsMergingDuplicateSessions(true) + try { + const result = await props.api.mergeCodexDuplicateSessions({ sessionIds: pendingDuplicateSessionIds }) + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.duplicates.merge.failed.body'))) + } + addToast({ + title: t('codexSync.duplicates.merge.success.title'), + body: t('codexSync.duplicates.merge.success.body'), + sessionId: '', + url: '' + }) + const redirectSessionId = resolveCodexImportRedirectSessionId( + result.merged, + pendingDuplicateHapiSessionIds + ) + closeDuplicateMergeDialog() + await refetchSessions() + if (redirectSessionId) props.onSuccess(redirectSessionId) + } catch (mergeError) { + addToast({ + title: t('codexSync.duplicates.merge.failed.title'), + body: normalizeCodexScriptError( + mergeError instanceof Error ? mergeError.message : null, + t('codexSync.duplicates.merge.failed.body') + ), + sessionId: '', + url: '' + }) + throw mergeError + } finally { + setIsMergingDuplicateSessions(false) + } + }, [ + addToast, + closeDuplicateMergeDialog, + isMergingDuplicateSessions, + normalizeCodexScriptError, + pendingDuplicateHapiSessionIds, + pendingDuplicateSessionIds, + props.api, + props.onSuccess, + refetchSessions, + t + ]) + const selectedCodexImportSession = useMemo( () => codexImportSessions.find((session) => session.id === selectedCodexImportSessionId) ?? null, [codexImportSessions, selectedCodexImportSessionId] @@ -1085,12 +1252,12 @@ export function NewSession(props: { onAgentChange={handleAgentChange} /> {agent === 'codex' ? ( - { + onChooseHistory={() => { setIsCodexImportDialogOpen(true) void loadCodexImportSessions() }} @@ -1237,18 +1404,34 @@ export function NewSession(props: { sessions={codexImportSessions} currentCodexSessionId={selectedCodexImportSessionId} currentWorkDirectory={trimmedDirectory} - selectionMode="single" - onSelectOnly={(session) => { - handleSelectCodexImportSession(session) - setIsCodexImportDialogOpen(false) + selectionMode="multiple" + onConfirm={async (sessionIds) => { + if (sessionIds.length === 1) { + const session = codexImportSessions.find((candidate) => candidate.id === sessionIds[0]) + if (session) { + handleSelectCodexImportSession(session) + setIsCodexImportDialogOpen(false) + } + return + } + await handleBulkImportCodexSessions(sessionIds) }} - onConfirm={async () => {}} - onRestartCodexDesktop={async () => { await loadCodexImportSessions() }} + onRestartCodexDesktop={handleRestartCodexDesktop} onArchiveSession={handleArchiveCodexImportSession} - isPending={false} - isRestartingCodexDesktop={false} + isPending={isBulkImportingCodexSessions} + isRestartingCodexDesktop={isRestartingCodexDesktop} isLoading={isLoadingCodexImportSessions} /> + 0} + onClose={closeDuplicateMergeDialog} + title={t('codexSync.duplicates.confirm.title')} + description={t('codexSync.duplicates.confirm.description')} + confirmLabel={t('codexSync.duplicates.confirm.confirm')} + confirmingLabel={t('codexSync.duplicates.confirm.confirming')} + onConfirm={handleMergeDuplicateSessions} + isPending={isMergingDuplicateSessions} + /> ) } diff --git a/web/src/router.tsx b/web/src/router.tsx index 1840e644..155da9d7 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -16,8 +16,6 @@ import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' -import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' -import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' import { LoadingState } from '@/components/LoadingState' @@ -47,8 +45,7 @@ import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' -import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' -import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' +import { clearCodexImportedSession } from '@/lib/codexImportedSessions' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -107,28 +104,6 @@ function PlusIcon(props: { className?: string }) { ) } -function CodexImportIcon(props: { className?: string }) { - return ( - - {/* 中文注释:导入图标使用“下载进托盘”样式,与刷新按钮的循环箭头区分开,避免两个相邻按钮看起来一样。 */} - - - - - ) -} - function RefreshIcon(props: { className?: string }) { return ( location.pathname }) const matchRoute = useMatchRoute() const { t } = useTranslation() const { addToast } = useToast() const { sessions, isLoading, error, refetch } = useSessions(api) const { machines } = useMachines(api, true) - const [isSyncingCodexSession, setIsSyncingCodexSession] = useState(false) - const [codexSessions, setCodexSessions] = useState([]) - const [codexImportMachineId, setCodexImportMachineId] = useState(null) - const [isLoadingCodexSessions, setIsLoadingCodexSessions] = useState(false) - const [isSyncConfirmOpen, setIsSyncConfirmOpen] = useState(false) - const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false) - const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState([]) - const [pendingDuplicateHapiSessionIds, setPendingDuplicateHapiSessionIds] = useState([]) - const [duplicateSessionGroups, setDuplicateSessionGroups] = useState([]) - const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) - const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) - const [codexImportWorkDirectoryOverride, setCodexImportWorkDirectoryOverride] = useState(null) - const handleRefresh = useCallback(() => { void (async () => { try { @@ -252,13 +213,6 @@ function SessionsPage() { } markSessionSeen(selectedSessionId, selectedSession.updatedAt) }, [selectedSessionId, selectedSession?.updatedAt]) - const currentCodexSessionId = selectedSession?.metadata?.flavor === 'codex' - ? (selectedSession.metadata.agentSessionId ?? null) - : null - const currentWorkDirectory = codexImportWorkDirectoryOverride - ?? selectedSession?.metadata?.worktree?.basePath - ?? selectedSession?.metadata?.path - ?? null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => { @@ -270,269 +224,6 @@ function SessionsPage() { }) }, [navigate]) - const isCodexScriptTimeout = useCallback((message: string | null | undefined): boolean => { - const raw = (message ?? '').trim() - return /执行超时|timed\s*out|timeout/i.test(raw) - }, []) - - const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => { - const raw = (message ?? '').trim() - if (!raw) return fallback - if (isCodexScriptTimeout(raw)) { - return t('codexSync.error.timeout') - } - if (/当前会话仍处于活跃状态,请等待会话结束后重试|Active Hapi process already has this Codex thread/i.test(raw)) { - return t('codexSync.error.active') - } - if (/未安装\/找不到codex客户端|unable to find codex launcher|找不到.*codex/i.test(raw)) { - return t('codexSync.restart.failed.notFound') - } - return raw - }, [isCodexScriptTimeout, t]) - - const formatCodexSyncFailureBody = useCallback((reason: string): string => { - if ( - reason === t('codexSync.error.timeout') || - reason === t('codexSync.error.active') || - reason === t('codexSync.restart.failed.notFound') - ) { - return reason - } - return t('codexSync.failed.bodyWithReason', { reason }) - }, [t]) - - const closeDuplicateMergeDialog = useCallback(() => { - // 中文注释:重复会话确认框关闭时一并清空“本次选中导入”的上下文,确保后续检测不会误用上一轮的 codexSessionId。 - setIsDuplicateMergeConfirmOpen(false) - setPendingDuplicateSessionIds([]) - setPendingDuplicateHapiSessionIds([]) - setDuplicateSessionGroups([]) - }, []) - - const handleRestartCodexDesktop = useCallback(async () => { - setIsRestartingCodexDesktop(true) - try { - const status = await api.getCodexDesktopStatus() - if (!status.codexClientAvailable) { - throw new Error(t('codexSync.restart.failed.notFound')) - } - - const result = await api.restartCodexDesktop() - if (!result.success) { - throw new Error(normalizeCodexScriptError(result.error, t('codexSync.restart.failed.body'))) - } - addToast({ - title: t('codexSync.restart.started.title'), - body: t('codexSync.restart.started.body'), - sessionId: '', - url: '' - }) - } catch (error) { - addToast({ - title: t('codexSync.restart.failed.title'), - body: normalizeCodexScriptError( - error instanceof Error ? error.message : null, - t('codexSync.restart.failed.body') - ), - sessionId: '', - url: '' - }) - } finally { - setIsRestartingCodexDesktop(false) - } - }, [addToast, api, normalizeCodexScriptError, t]) - - const handleMergeDuplicateSessions = useCallback(async () => { - if (isMergingDuplicateSessions || pendingDuplicateSessionIds.length === 0) return - - setIsMergingDuplicateSessions(true) - try { - const result = await api.mergeCodexDuplicateSessions({ sessionIds: pendingDuplicateSessionIds }) - if (!result.success) { - throw new Error(normalizeCodexScriptError(result.error, t('codexSync.duplicates.merge.failed.body'))) - } - - addToast({ - title: t('codexSync.duplicates.merge.success.title'), - body: t('codexSync.duplicates.merge.success.body'), - sessionId: '', - url: '' - }) - - const redirectTarget = selectedSessionId - ? result.merged.find((group) => group.removedSessionIds?.includes(selectedSessionId)) - ?? result.merged.find((group) => Boolean(group.canonicalSessionId)) - : result.merged.find((group) => Boolean(group.canonicalSessionId)) - const redirectSessionId = redirectTarget?.canonicalSessionId ?? pendingDuplicateHapiSessionIds[0] - - closeDuplicateMergeDialog() - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - selectedSessionId - ? queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) - : Promise.resolve(), - selectedSessionId - ? queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) }) - : Promise.resolve() - ]) - await refetch() - - if (redirectSessionId) { - navigate({ - to: '/sessions/$sessionId', - params: { sessionId: redirectSessionId } - }) - } - } catch (error) { - addToast({ - title: t('codexSync.duplicates.merge.failed.title'), - body: normalizeCodexScriptError( - error instanceof Error ? error.message : null, - t('codexSync.duplicates.merge.failed.body') - ), - sessionId: '', - url: '' - }) - throw error - } finally { - setIsMergingDuplicateSessions(false) - } - }, [ - addToast, - api, - closeDuplicateMergeDialog, - isMergingDuplicateSessions, - navigate, - normalizeCodexScriptError, - pendingDuplicateHapiSessionIds, - pendingDuplicateSessionIds, - queryClient, - refetch, - selectedSessionId, - t - ]) - - const openCodexImportDialog = useCallback(async (workDirectory?: string | null) => { - setCodexImportWorkDirectoryOverride(workDirectory?.trim() || null) - if (isLoadingCodexSessions) return - - setIsSyncConfirmOpen(true) - setIsLoadingCodexSessions(true) - try { - const result = await api.getCodexSessions(workDirectory) - setCodexSessions(result.sessions) - setCodexImportMachineId(result.machineId ?? null) - } catch (error) { - setCodexSessions([]) - setCodexImportMachineId(null) - const reason = normalizeCodexScriptError( - error instanceof Error ? error.message : null, - t('dialog.error.default') - ) - addToast({ - title: t('codexSync.failed.title'), - body: formatCodexSyncFailureBody(reason), - sessionId: '', - url: '' - }) - } finally { - setIsLoadingCodexSessions(false) - } - }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, normalizeCodexScriptError, t]) - - const handleArchiveCodexSession = useCallback(async (codexSession: import('@/types/api').CodexLocalSessionSummary) => { - if (!api) return - const result = await api.archiveCodexSession(codexSession.id, codexImportMachineId) - if (!result.success) { - throw new Error(result.error) - } - setCodexSessions((current) => current.filter((session) => session.id !== codexSession.id)) - }, [api, codexImportMachineId]) - - const handleImportCodexSessions = useCallback(async (sessionIds: string[]) => { - if (isSyncingCodexSession || isLoadingCodexSessions) return - - setIsSyncingCodexSession(true) - try { - // 中文注释:弹窗提交的是本地 Codex thread ID;后端会直接读取这些 transcript 并导入到 Hapi。 - const result = await api.syncCodexSession({ sessionIds, cwd: currentWorkDirectory, machineId: codexImportMachineId }) - if (!result.success) { - throw new Error(normalizeCodexScriptError(result.error, t('codexSync.failed.body'))) - } - - addToast({ - title: t('codexSync.success.title'), - body: t('codexSync.success.body', { n: result.syncedCount ?? sessionIds.length }), - sessionId: '', - url: '' - }) - // 中文注释:导入成功后先在浏览器侧记住这些 Codex thread 的导入时间,供左侧会话列表显示特殊时间文案。 - markCodexSessionsImported(sessionIds) - setIsSyncConfirmOpen(false) - await refetch() - - setPendingDuplicateSessionIds([]) - setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) - setDuplicateSessionGroups([]) - setIsDuplicateMergeConfirmOpen(false) - try { - // 中文注释:重复会话检测严格限定在这次用户勾选导入的 codexSessionId 范围内;未勾选的其它会话不参与检测,也不弹合并提示。 - const duplicateResult = await api.getCodexDuplicateSessions({ sessionIds }) - if (!duplicateResult.success) { - throw new Error(normalizeCodexScriptError( - duplicateResult.error, - t('codexSync.duplicates.detect.failed.body') - )) - } - - if (duplicateResult.duplicates.length > 0) { - setPendingDuplicateSessionIds(sessionIds) - setPendingDuplicateHapiSessionIds(result.hapiSessionIds ?? []) - setDuplicateSessionGroups(duplicateResult.duplicates) - setIsDuplicateMergeConfirmOpen(true) - } - } catch (duplicateError) { - addToast({ - title: t('codexSync.duplicates.detect.failed.title'), - body: normalizeCodexScriptError( - duplicateError instanceof Error ? duplicateError.message : null, - t('codexSync.duplicates.detect.failed.body') - ), - sessionId: '', - url: '' - }) - } - } catch (syncError) { - const reason = normalizeCodexScriptError( - syncError instanceof Error ? syncError.message : null, - t('dialog.error.default') - ) - addToast({ - title: t('codexSync.failed.title'), - body: formatCodexSyncFailureBody(reason), - sessionId: '', - url: '' - }) - } finally { - setIsSyncingCodexSession(false) - } - }, [ - addToast, - api, - formatCodexSyncFailureBody, - codexImportMachineId, - currentWorkDirectory, - isLoadingCodexSessions, - isSyncingCodexSession, - normalizeCodexScriptError, - refetch, - setDuplicateSessionGroups, - setIsDuplicateMergeConfirmOpen, - setPendingDuplicateHapiSessionIds, - setPendingDuplicateSessionIds, - t - ]) - return ( <>
@@ -543,17 +234,6 @@ function SessionsPage() {
-
- {/* 中文注释:这里展示的是本地 Codex transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Codex thread。 */} - { - setIsSyncConfirmOpen(false) - setCodexImportWorkDirectoryOverride(null) - setCodexImportMachineId(null) - }} - sessions={codexSessions} - currentCodexSessionId={currentCodexSessionId} - currentWorkDirectory={currentWorkDirectory} - onConfirm={handleImportCodexSessions} - onRestartCodexDesktop={handleRestartCodexDesktop} - onArchiveSession={handleArchiveCodexSession} - isPending={isSyncingCodexSession} - isRestartingCodexDesktop={isRestartingCodexDesktop} - isLoading={isLoadingCodexSessions} - /> - 0} - onClose={closeDuplicateMergeDialog} - title={t('codexSync.duplicates.confirm.title')} - description={t('codexSync.duplicates.confirm.description')} - confirmLabel={t('codexSync.duplicates.confirm.confirm')} - confirmingLabel={t('codexSync.duplicates.confirm.confirming')} - onConfirm={handleMergeDuplicateSessions} - isPending={isMergingDuplicateSessions} - /> ) }