diff --git a/web/src/components/CodexSessionSyncDialog.test.tsx b/web/src/components/CodexSessionSyncDialog.test.tsx new file mode 100644 index 00000000..a90e167b --- /dev/null +++ b/web/src/components/CodexSessionSyncDialog.test.tsx @@ -0,0 +1,150 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { CodexSessionSyncDialog } from './CodexSessionSyncDialog' +import type { CodexLocalSessionSummary } from '@/types/api' + +function renderDialog( + sessions: CodexLocalSessionSummary[], + onConfirm = vi.fn(async () => {}), + currentCodexSessionId: string | null = null +) { + const view = render( + + + + ) + return { ...view, onConfirm } +} + +describe('CodexSessionSyncDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the working directory for local Codex sessions', () => { + renderDialog([ + { + id: 'codex-session-1', + title: 'Session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.codex/sessions/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'codex_cli', + cliVersion: '0.124.0' + } + ]) + + expect(screen.getByText('Working directory')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project')).toHaveLength(2) + }) + + it('filters sessions by working directory', () => { + renderDialog([ + { + id: 'codex-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.codex/sessions/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'codex-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.codex/sessions/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ]) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + + expect(screen.queryByText('Project one')).not.toBeInTheDocument() + expect(screen.getByText('Project two')).toBeInTheDocument() + }) + + it('selects only filtered sessions when selecting all', async () => { + const onConfirm = vi.fn(async () => {}) + renderDialog([ + { + id: 'codex-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.codex/sessions/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'codex-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.codex/sessions/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], onConfirm) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Select all' })) + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(onConfirm).toHaveBeenCalledWith(['codex-session-2']) + }) + + it('replaces hidden selections when selecting all filtered sessions', async () => { + const onConfirm = vi.fn(async () => {}) + renderDialog([ + { + id: 'codex-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.codex/sessions/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'codex-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.codex/sessions/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], onConfirm, 'codex-session-1') + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Select all' })) + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(onConfirm).toHaveBeenCalledWith(['codex-session-2']) + }) +}) diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index 31c699cb..c7eefe60 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -10,6 +10,8 @@ import { import { Button } from '@/components/ui/button' import { useTranslation } from '@/lib/use-translation' +const ALL_WORKDIR_FILTER = '__all__' + function formatCodexSessionTime(value: number): string | null { if (!Number.isFinite(value)) return null return new Date(value).toLocaleString() @@ -24,6 +26,11 @@ function getCodexSessionPreview(session: CodexLocalSessionSummary): string { return parts.join(' · ') } +function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null { + const cwd = session.cwd?.trim() + return cwd ? cwd : null +} + export function CodexSessionSyncDialog(props: { isOpen: boolean onClose: () => void @@ -49,6 +56,7 @@ export function CodexSessionSyncDialog(props: { } = props const [selectedSessionIds, setSelectedSessionIds] = useState([]) const [hasInitializedSelection, setHasInitializedSelection] = useState(false) + const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) const wasOpenRef = useRef(false) const sessionIdSet = useMemo( @@ -59,12 +67,25 @@ export function CodexSessionSyncDialog(props: { () => new Set(selectedSessionIds), [selectedSessionIds] ) + const workdirOptions = useMemo(() => { + const directories = new Set() + for (const session of sessions) { + const cwd = getCodexSessionCwd(session) + if (cwd) directories.add(cwd) + } + return Array.from(directories).sort((a, b) => a.localeCompare(b)) + }, [sessions]) + const filteredSessions = useMemo(() => { + if (workdirFilter === ALL_WORKDIR_FILTER) return sessions + return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter) + }, [sessions, workdirFilter]) useEffect(() => { if (isOpen && !wasOpenRef.current) { wasOpenRef.current = true setSelectedSessionIds([]) setHasInitializedSelection(false) + setWorkdirFilter(ALL_WORKDIR_FILTER) return } @@ -72,9 +93,16 @@ export function CodexSessionSyncDialog(props: { wasOpenRef.current = false setSelectedSessionIds([]) setHasInitializedSelection(false) + setWorkdirFilter(ALL_WORKDIR_FILTER) } }, [isOpen]) + useEffect(() => { + if (workdirFilter === ALL_WORKDIR_FILTER) return + if (workdirOptions.includes(workdirFilter)) return + setWorkdirFilter(ALL_WORKDIR_FILTER) + }, [workdirFilter, workdirOptions]) + useEffect(() => { if (!isOpen || isLoading || hasInitializedSelection) return @@ -96,7 +124,7 @@ export function CodexSessionSyncDialog(props: { } const selectAll = () => { - setSelectedSessionIds(sessions.map((session) => session.id)) + setSelectedSessionIds(filteredSessions.map((session) => session.id)) } const clearAll = () => { @@ -155,13 +183,34 @@ export function CodexSessionSyncDialog(props: { variant="secondary" size="sm" onClick={selectAll} - disabled={isPending || isLoading || sessions.length === 0} + disabled={isPending || isLoading || filteredSessions.length === 0} > {t('codexSync.confirm.selectAll')} + {sessions.length > 0 ? ( + + ) : null} +
{isLoading ? (
@@ -171,11 +220,17 @@ export function CodexSessionSyncDialog(props: {
{t('codexSync.confirm.empty')}
+ ) : filteredSessions.length === 0 ? ( +
+ {t('codexSync.confirm.emptyForWorkdir')} +
) : (
- {sessions.map((session) => { + {filteredSessions.map((session) => { const checked = selectedSessionIdSet.has(session.id) const time = formatCodexSessionTime(session.modifiedAt) + const preview = getCodexSessionPreview(session) + const cwd = getCodexSessionCwd(session) return (
- {getCodexSessionPreview(session) ? ( + {preview ? (
- {getCodexSessionPreview(session)} + {preview} +
+ ) : null} + {cwd ? ( +
+ {t('codexSync.confirm.cwd')} + {cwd}
) : null} {time ? ( diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 70966908..33b280b5 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -62,6 +62,10 @@ export default { 'codexSync.confirm.selectedCount': '{n} sessions selected', 'codexSync.confirm.empty': 'No local Codex sessions found', 'codexSync.confirm.current': 'Linked', + 'codexSync.confirm.cwd': 'Working directory', + 'codexSync.confirm.cwdFilter': 'Work directory', + 'codexSync.confirm.cwdFilterAll': 'All work directories', + 'codexSync.confirm.emptyForWorkdir': 'No Codex sessions in this work directory', 'codexSync.confirm.confirm': 'Import', 'codexSync.confirm.confirming': 'Importing…', 'codexSync.confirm.loading': 'Loading local Codex sessions…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 41dab7a4..d6118dd4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -62,6 +62,10 @@ export default { 'codexSync.confirm.selectedCount': '已选择 {n} 个会话', 'codexSync.confirm.empty': '未找到本地 Codex 会话', 'codexSync.confirm.current': '当前关联', + 'codexSync.confirm.cwd': '工作目录', + 'codexSync.confirm.cwdFilter': '工作目录', + 'codexSync.confirm.cwdFilterAll': '全部工作目录', + 'codexSync.confirm.emptyForWorkdir': '此工作目录下没有 Codex 会话', 'codexSync.confirm.confirm': '导入', 'codexSync.confirm.confirming': '导入中…', 'codexSync.confirm.loading': '正在读取本地 Codex 会话…',