mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 } })
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user