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