import React from 'react'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { DirectoryExplorerDialog } from './DirectoryExplorerDialog'; import { cn, formatPathForDisplay } from '@/lib/utils'; import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager'; import { useSessionUIStore } from '@/sync/session-ui-store'; import * as sessionActions from '@/sync/session-actions'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { sessionEvents } from '@/lib/sessionEvents'; import { useI18n } from '@/lib/i18n'; const renderToastDescription = (text?: string) => text ? {text} : undefined; const normalizeProjectDirectory = (path: string | null | undefined): string => { if (!path) { return ''; } const replaced = path.replace(/\\/g, '/'); if (replaced === '/') { return '/'; } return replaced.replace(/\/+$/, ''); }; type DeleteDialogState = { sessions: Session[]; dateLabel?: string; mode: 'session' | 'worktree'; worktree?: WorktreeMetadata | null; }; export const SessionDialogs: React.FC = () => { const { t } = useI18n(); const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState>(new Set()); const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); const deleteSession = sessionActions.deleteSession; const archiveSession = sessionActions.archiveSession; const deleteSessions = useSessionUIStore((s) => s.deleteSessions); const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const homeDirectory = useDirectoryStore((s) => s.homeDirectory); const isHomeReady = useDirectoryStore((s) => s.isHomeReady); const projects = useProjectsStore((s) => s.projects); const activeProjectId = useProjectsStore((s) => s.activeProjectId); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const useMobileOverlay = isMobile || isTablet || hasTouchInput; const projectDirectory = React.useMemo(() => { const targetProject = activeProjectId ? projects.find((project) => project.id === activeProjectId) ?? null : null; const targetPath = targetProject?.path ?? currentDirectory; return normalizeProjectDirectory(targetPath); }, [activeProjectId, currentDirectory, projects]); const getProjectRefForWorktree = React.useCallback((worktree: WorktreeMetadata) => { const normalized = normalizeProjectDirectory(worktree.projectDirectory); const fallbackPath = normalized || projectDirectory; const match = projects.find((project) => normalizeProjectDirectory(project.path) === fallbackPath) ?? null; return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath }; }, [projectDirectory, projects]); const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0; const canRemoveRemoteBranches = React.useMemo( () => { const targetWorktree = deleteDialog?.worktree; if (targetWorktree && typeof targetWorktree.branch === 'string' && targetWorktree.branch.trim().length > 0) { return true; } return ( deleteDialogSummaries.length > 0 && deleteDialogSummaries.every(({ metadata }) => typeof metadata.branch === 'string' && metadata.branch.trim().length > 0) ); }, [deleteDialog?.worktree, deleteDialogSummaries], ); const isWorktreeDelete = deleteDialog?.mode === 'worktree'; const shouldArchiveWorktree = isWorktreeDelete; const removeRemoteOptionDisabled = isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete; // Session loading is handled by sync bootstrap — no manual loadSessions needed. React.useEffect(() => { if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { return; } setHasShownInitialDirectoryPrompt(true); setIsDirectoryDialogOpen(true); }, [ hasShownInitialDirectoryPrompt, isHomeReady, projects.length, ]); const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => { setDeleteDialog({ sessions: payload.sessions, dateLabel: payload.dateLabel, mode: payload.mode ?? 'session', worktree: payload.worktree ?? null, }); }, []); const closeDeleteDialog = React.useCallback(() => { setDeleteDialog(null); setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldDeleteLocalBranch(false); setIsProcessingDelete(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); }, []); const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => { if (payload.sessions.length === 0) { return; } if (payload.sessions.length === 1) { const target = payload.sessions[0]; const success = await deleteSession(target.id); if (success) { toast.success(t('sessions.sidebar.session.delete.success')); } else { toast.error(t('sessions.sidebar.session.delete.error')); } return; } const ids = payload.sessions.map((session) => session.id); const { deletedIds, failedIds } = await deleteSessions(ids); if (deletedIds.length > 0) { const successDescription = failedIds.length > 0 ? (failedIds.length === 1 ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length }) : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length })) : payload.dateLabel ? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: payload.dateLabel }) : undefined; toast.success(deletedIds.length === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length }) : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }), { description: renderToastDescription(successDescription), }); } if (failedIds.length > 0) { toast.error(failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length }) : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }), { description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); } }, [deleteSession, deleteSessions, t]); React.useEffect(() => { return sessionEvents.onDeleteRequest((payload) => { if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') { void deleteSessionsWithoutDialog(payload); return; } openDeleteDialog(payload); }); }, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]); React.useEffect(() => { return sessionEvents.onDirectoryRequest(() => { setIsDirectoryDialogOpen(true); }); }, []); React.useEffect(() => { if (!deleteDialog) { setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldDeleteLocalBranch(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); return; } const summaries = deleteDialog.sessions .map((session) => { const metadata = getWorktreeMetadata(session.id); return metadata ? { session, metadata } : null; }) .filter((entry): entry is { session: Session; metadata: WorktreeMetadata } => Boolean(entry)); setDeleteDialogSummaries(summaries); setDeleteDialogShouldRemoveRemote(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); const metadataByPath = new Map(); if (deleteDialog.worktree?.path) { metadataByPath.set(normalizeProjectDirectory(deleteDialog.worktree.path), deleteDialog.worktree); } summaries.forEach(({ metadata }) => { if (metadata.path) { metadataByPath.set(normalizeProjectDirectory(metadata.path), metadata); } }); if (metadataByPath.size === 0) { setHasCompletedDirtyCheck(true); return; } let cancelled = false; (async () => { const statusByPath = new Map(); const nextDirtyPaths = new Set(); await Promise.all( Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => { try { const status = await getWorktreeStatus(metadata.path); statusByPath.set(pathKey, status); if (status?.isDirty) { nextDirtyPaths.add(pathKey); } } catch { if (metadata.status) { statusByPath.set(pathKey, metadata.status); if (metadata.status.isDirty) { nextDirtyPaths.add(pathKey); } } } }) ).catch((error) => { console.warn('Failed to inspect worktree status before deletion:', error); }); if (cancelled) { return; } setDirtyWorktreePaths(nextDirtyPaths); setHasCompletedDirtyCheck(true); setDeleteDialog((prev) => { if (!prev?.worktree?.path) { return prev; } const pathKey = normalizeProjectDirectory(prev.worktree.path); const nextStatus = statusByPath.get(pathKey); if (!nextStatus) { return prev; } const prevStatus = prev.worktree.status; if ( prevStatus?.isDirty === nextStatus.isDirty && prevStatus?.ahead === nextStatus.ahead && prevStatus?.behind === nextStatus.behind && prevStatus?.upstream === nextStatus.upstream ) { return prev; } return { ...prev, worktree: { ...prev.worktree, status: nextStatus, }, }; }); setDeleteDialogSummaries((prev) => prev.map((entry) => { const pathKey = normalizeProjectDirectory(entry.metadata.path); const nextStatus = statusByPath.get(pathKey); if (!nextStatus) { return entry; } return { session: entry.session, metadata: { ...entry.metadata, status: nextStatus }, }; }) ); })(); return () => { cancelled = true; }; }, [deleteDialog, getWorktreeMetadata]); React.useEffect(() => { if (!canRemoveRemoteBranches) { setDeleteDialogShouldRemoveRemote(false); } }, [canRemoveRemoteBranches]); const removeSelectedWorktree = React.useCallback(async ( worktree: WorktreeMetadata, deleteLocalBranch: boolean ): Promise => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const projectRef = getProjectRefForWorktree(worktree); const normalizedWorktreePath = normalizeProjectDirectory(worktree.path); const normalizedProjectPath = normalizeProjectDirectory(projectRef.path); try { await removeProjectWorktree( projectRef, worktree, { deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch } ); const draftDirectory = normalizeProjectDirectory(newSessionDraft?.directoryOverride); if ( newSessionDraft?.open && normalizedWorktreePath && draftDirectory === normalizedWorktreePath && normalizedProjectPath ) { setDraftBootstrapPendingDirectory(null); setNewSessionDraftTarget({ projectId: projectRef.id, directoryOverride: normalizedProjectPath, }, { force: true }); } if (normalizeProjectDirectory(currentDirectory) === normalizedWorktreePath && normalizedProjectPath) { useDirectoryStore.getState().setDirectory(normalizedProjectPath, { showOverlay: false }); } return true; } catch (error) { toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); return false; } }, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]); const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; } setIsProcessingDelete(true); try { const shouldArchive = shouldArchiveWorktree; const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); if (!removed) { closeDeleteDialog(); return; } const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const archiveNote = shouldRemoveRemote ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') : t('sessions.sidebar.sessionDialogs.worktree.removed'); toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { description: renderToastDescription(archiveNote), }); closeDeleteDialog(); return; } if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; const success = isWorktreeDelete ? await archiveSession(target.id) : await deleteSession(target.id, { // In "worktree" mode, remove the selected worktree explicitly below. // Don't try to derive worktree removal from per-session metadata (may be missing). archiveWorktree: false, deleteRemoteBranch: removeRemoteBranch, deleteLocalBranch, }); if (!success) { toast.error(isWorktreeDelete ? t('sessions.sidebar.session.archive.error') : t('sessions.sidebar.session.delete.error')); setIsProcessingDelete(false); return; } const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') : t('sessions.sidebar.sessionDialogs.worktree.attachedArchived') : undefined; toast.success(isWorktreeDelete ? t('sessions.sidebar.session.archive.success') : t('sessions.sidebar.session.delete.success'), { description: renderToastDescription(archiveNote), action: { label: t('sessions.sidebar.sessionDialogs.ok'), onClick: () => { }, }, }); } else { const ids = deleteDialog.sessions.map((session) => session.id); let deletedIds: string[] = []; let failedIds: string[] = []; if (isWorktreeDelete) { const result = await archiveSessions(ids); deletedIds = result.archivedIds; failedIds = result.failedIds; } else { const result = await deleteSessions(ids); deletedIds = result.deletedIds; failedIds = result.failedIds; } if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); // sync handles session refresh automatically } if (deletedIds.length > 0) { const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? t('sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved') : t('sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural') : undefined; const successDescription = failedIds.length > 0 ? (isWorktreeDelete ? (failedIds.length === 1 ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToArchive', { count: failedIds.length }) : t('sessions.sidebar.dialogs.deleteResult.manyFailedToArchive', { count: failedIds.length })) : (failedIds.length === 1 ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length }) : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length }))) : deleteDialog.dateLabel ? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: deleteDialog.dateLabel }) : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); toast.success(isWorktreeDelete ? (deletedIds.length === 1 ? t('sessions.sidebar.bulkActions.archivedSingle', { count: deletedIds.length }) : t('sessions.sidebar.bulkActions.archivedPlural', { count: deletedIds.length })) : (deletedIds.length === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length }) : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length })), { description: renderToastDescription(combinedDescription || undefined), action: { label: t('sessions.sidebar.sessionDialogs.ok'), onClick: () => { }, }, }); } if (failedIds.length > 0) { toast.error(isWorktreeDelete ? (failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length })) : (failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length }) : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length })), { description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); if (deletedIds.length === 0) { setIsProcessingDelete(false); return; } } } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); // sync bootstrap refreshes sessions automatically } closeDeleteDialog(); } finally { setIsProcessingDelete(false); } }, [ deleteDialog, deleteDialogShouldRemoveRemote, deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, archiveSession, archiveSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, removeSelectedWorktree, t, ]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; const deleteDialogDescription = deleteDialog ? deleteDialog.mode === 'worktree' ? deleteDialog.sessions.length === 0 ? t('sessions.sidebar.dialogs.worktreeDelete.descriptionNoLinked') : (deleteDialog.sessions.length === 1 ? t('sessions.sidebar.dialogs.worktreeDelete.descriptionOneLinked', { count: deleteDialog.sessions.length }) : t('sessions.sidebar.dialogs.worktreeDelete.descriptionManyLinked', { count: deleteDialog.sessions.length })) : (deleteDialog.sessions.length === 1 ? (deleteDialog.dateLabel ? t('sessions.sidebar.dialogs.sessionDelete.descriptionOneWithDate', { dateLabel: deleteDialog.dateLabel, }) : t('sessions.sidebar.dialogs.sessionDelete.descriptionOne')) : (deleteDialog.dateLabel ? t('sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate', { count: deleteDialog.sessions.length, dateLabel: deleteDialog.dateLabel, }) : t('sessions.sidebar.dialogs.sessionDelete.descriptionMany', { count: deleteDialog.sessions.length, }))) : ''; const deleteDialogBody = deleteDialog ? (
{deleteDialog.sessions.length > 0 && (
{isWorktreeDelete && (
{deleteDialog.sessions.length === 1 ? t('sessions.sidebar.sessionDialogs.linkedSessionSingle') : t('sessions.sidebar.sessionDialogs.linkedSessionPlural')} {deleteDialog.sessions.length}
)}
    {deleteDialog.sessions.slice(0, 5).map((session) => (
  • {session.title || t('sessions.sidebar.session.untitled')}
  • ))} {deleteDialog.sessions.length > 5 && (
  • {t('sessions.sidebar.dialogs.sessionList.more', { count: deleteDialog.sessions.length - 5 })}
  • )}
)} {isWorktreeDelete ? (
{t('sessions.sidebar.sessionDialogs.worktree.label')} {targetWorktree?.label ? ( {targetWorktree.label} ) : null}

{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : t('sessions.sidebar.sessionDialogs.worktree.pathUnavailable')}

{hasDirtyWorktrees && (

{t('sessions.sidebar.sessionDialogs.worktree.uncommittedWarning')}

)}
) : (

{t('sessions.sidebar.sessionDialogs.delete.note')}

)}
) : null; const deleteRemoteBranchAction = isWorktreeDelete ? ( canRemoveRemoteBranches ? ( ) : ( {t('sessions.sidebar.sessionDialogs.actions.remoteBranchInfoUnavailable')} ) ) : null; const deleteLocalBranchAction = isWorktreeDelete ? ( ) : null; const deleteDialogActions = isWorktreeDelete ? (
{deleteLocalBranchAction} {deleteRemoteBranchAction}
) : (
); const deleteDialogTitle = isWorktreeDelete ? t('sessions.sidebar.sessionDialogs.actions.deleteWorktree') : deleteDialog?.sessions.length === 1 ? t('sessions.sidebar.dialogs.deleteSession.titleAction') : t('sessions.sidebar.dialogs.deleteSessions.titleAction'); return ( <> {useMobileOverlay ? ( { if (isProcessingDelete) { return; } closeDeleteDialog(); }} title={deleteDialogTitle} footer={
{deleteDialogActions}
} >
{deleteDialogDescription && (

{deleteDialogDescription}

)} {deleteDialogBody}
) : ( { if (!open) { if (isProcessingDelete) { return; } closeDeleteDialog(); } }} > {isWorktreeDelete && } {deleteDialogTitle} {deleteDialogDescription && {deleteDialogDescription}}
{deleteDialogBody}
{deleteDialogActions}
)} ); };