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 (