fix(web): consolidate Codex import in new session (#1240)

* fix(web): consolidate Codex import in new session

* fix(web): clear imported Codex history selection
This commit is contained in:
Ananovo
2026-07-31 18:12:30 +08:00
committed by GitHub
parent 567a6c9499
commit c5f1928eb0
7 changed files with 373 additions and 401 deletions
@@ -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(
<CodexImportActions
selectedSession={null}
isLoading={false}
isDisabled={false}
error={null}
onChooseHistory={onChooseHistory}
onClear={vi.fn()}
/>
)
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(
<CodexImportActions
selectedSession={null}
isLoading={true}
isDisabled={false}
error={null}
onChooseHistory={vi.fn()}
onClear={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: 'codexSync.confirm.loading' })).toBeDisabled()
})
})
@@ -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 (
<div className="flex flex-col gap-2 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-medium text-[var(--app-hint)]">{t('codexSync.newSessionInline.title')}</div>
<div className="truncate text-[11px] text-[var(--app-hint)]">
{props.selectedSession ? props.selectedSession.title : t('codexSync.newSessionInline.description')}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{props.selectedSession ? (
<button type="button" className="text-xs text-[var(--app-link)]" onClick={props.onClear} disabled={props.isDisabled}>
{t('codexSync.newSessionInline.clear')}
</button>
) : null}
<button
type="button"
className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50"
onClick={props.onChooseHistory}
disabled={props.isDisabled || props.isLoading}
>
{props.isLoading ? t('codexSync.confirm.loading') : t('codexSync.newSessionInline.choose')}
</button>
</div>
</div>
{props.error ? <div className="text-xs text-red-600">{props.error}</div> : null}
</div>
)
}
@@ -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')
})
})
@@ -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
}
@@ -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 } })
}))
+235 -52
View File
@@ -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 (
<div className="flex flex-col gap-2 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-medium text-[var(--app-hint)]">{t('codexSync.newSessionInline.title')}</div>
<div className="truncate text-[11px] text-[var(--app-hint)]">
{props.selectedSession ? props.selectedSession.title : t('codexSync.newSessionInline.description')}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{props.selectedSession ? (
<button type="button" className="text-xs text-[var(--app-link)]" onClick={props.onClear} disabled={props.isDisabled}>
{t('codexSync.newSessionInline.clear')}
</button>
) : null}
<button
type="button"
className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50"
onClick={props.onOpen}
disabled={props.isDisabled || props.isLoading}
>
{props.isLoading ? t('codexSync.confirm.loading') : t('codexSync.newSessionInline.choose')}
</button>
</div>
</div>
{props.error ? <div className="text-xs text-red-600">{props.error}</div> : null}
</div>
)
}
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<string | null>(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<string[]>([])
const [pendingDuplicateHapiSessionIds, setPendingDuplicateHapiSessionIds] = useState<string[]>([])
const [duplicateSessionGroups, setDuplicateSessionGroups] = useState<CodexDuplicateSessionGroup[]>([])
const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false)
const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false)
const isFormDisabled = Boolean(isCreating || isPending || props.isLoading || isImportingCodexSession || isBulkImportingCodexSessions)
const worktreeInputRef = useRef<HTMLInputElement>(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' ? (
<CodexImportSelectButton
<CodexImportActions
selectedSession={selectedCodexImportSession}
isLoading={isLoadingCodexImportSessions}
isDisabled={isFormDisabled}
error={codexImportError}
onOpen={() => {
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}
/>
<ConfirmDialog
isOpen={isDuplicateMergeConfirmOpen && duplicateSessionGroups.length > 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}
/>
</div>
)
}
+1 -349
View File
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
{/* 中文注释:导入图标使用“下载进托盘”样式,与刷新按钮的循环箭头区分开,避免两个相邻按钮看起来一样。 */}
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
function RefreshIcon(props: { className?: string }) {
return (
<svg
@@ -191,26 +166,12 @@ function SettingsIcon(props: { className?: string }) {
function SessionsPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const queryClient = useQueryClient()
const pathname = useLocation({ select: location => 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<CodexLocalSessionSummary[]>([])
const [codexImportMachineId, setCodexImportMachineId] = useState<string | null>(null)
const [isLoadingCodexSessions, setIsLoadingCodexSessions] = useState(false)
const [isSyncConfirmOpen, setIsSyncConfirmOpen] = useState(false)
const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false)
const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState<string[]>([])
const [pendingDuplicateHapiSessionIds, setPendingDuplicateHapiSessionIds] = useState<string[]>([])
const [duplicateSessionGroups, setDuplicateSessionGroups] = useState<CodexDuplicateSessionGroup[]>([])
const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false)
const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false)
const [codexImportWorkDirectoryOverride, setCodexImportWorkDirectoryOverride] = useState<string | null>(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 (
<>
<div className="flex h-full min-h-0">
@@ -543,17 +234,6 @@ function SessionsPage() {
<div className="session-list-scrollbar-offset shrink-0 bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto flex w-full max-w-content items-center justify-end px-2 py-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void openCodexImportDialog()}
disabled={isSyncingCodexSession || isLoadingCodexSessions}
aria-label={t('codexSync.tooltip')}
aria-busy={isSyncingCodexSession || isLoadingCodexSessions}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors disabled:opacity-60 disabled:cursor-wait"
title={t('codexSync.tooltip')}
>
<CodexImportIcon className={`h-5 w-5 ${isLoadingCodexSessions ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={handleRefresh}
@@ -632,34 +312,6 @@ function SessionsPage() {
</div>
</div>
</div>
{/* 中文注释:这里展示的是本地 Codex transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Codex thread。 */}
<CodexSessionSyncDialog
isOpen={isSyncConfirmOpen}
onClose={() => {
setIsSyncConfirmOpen(false)
setCodexImportWorkDirectoryOverride(null)
setCodexImportMachineId(null)
}}
sessions={codexSessions}
currentCodexSessionId={currentCodexSessionId}
currentWorkDirectory={currentWorkDirectory}
onConfirm={handleImportCodexSessions}
onRestartCodexDesktop={handleRestartCodexDesktop}
onArchiveSession={handleArchiveCodexSession}
isPending={isSyncingCodexSession}
isRestartingCodexDesktop={isRestartingCodexDesktop}
isLoading={isLoadingCodexSessions}
/>
<ConfirmDialog
isOpen={isDuplicateMergeConfirmOpen && duplicateSessionGroups.length > 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}
/>
</>
)
}