import React from 'react'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine, RiGitBranchLine } from '@remixicon/react'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; 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 { archiveWorktree, getWorktreeStatus, } from '@/lib/git/worktreeService'; import { ensureOpenChamberIgnored } from '@/lib/gitApi'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { isDesktopRuntime } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; import { sessionEvents } from '@/lib/sessionEvents'; 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 [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); const ensuredIgnoreDirectories = React.useRef>(new Set()); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); const { deleteSession, deleteSessions, loadSessions, getWorktreeMetadata, } = useSessionStore(); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); const { projects, addProject, activeProjectId } = useProjectsStore(); const { requestAccess, startAccessing } = useFileSystemAccess(); 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 hasDirtyWorktrees = React.useMemo( () => (deleteDialog?.worktree?.status?.isDirty ?? false) || deleteDialogSummaries.some((entry) => entry.metadata.status?.isDirty), [deleteDialog?.worktree?.status?.isDirty, deleteDialogSummaries], ); 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; React.useEffect(() => { if (!projectDirectory) { return; } if (ensuredIgnoreDirectories.current.has(projectDirectory)) { return; } ensureOpenChamberIgnored(projectDirectory) .then(() => ensuredIgnoreDirectories.current.add(projectDirectory)) .catch((error) => { console.warn('Failed to ensure .openchamber directory is ignored:', error); ensuredIgnoreDirectories.current.delete(projectDirectory); }); }, [projectDirectory]); React.useEffect(() => { loadSessions(); }, [loadSessions, currentDirectory]); const projectsKey = React.useMemo( () => projects.map((project) => `${project.id}:${project.path}`).join('|'), [projects], ); const lastProjectsKeyRef = React.useRef(projectsKey); React.useEffect(() => { if (projectsKey === lastProjectsKeyRef.current) { return; } lastProjectsKeyRef.current = projectsKey; loadSessions(); }, [loadSessions, projectsKey]); React.useEffect(() => { if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { return; } setHasShownInitialDirectoryPrompt(true); if (isDesktopRuntime()) { requestAccess('') .then(async (result) => { if (!result.success || !result.path) { if (result.error && result.error !== 'Directory selection cancelled') { toast.error('Failed to select directory', { description: result.error, }); } return; } const accessResult = await startAccessing(result.path); if (!accessResult.success) { toast.error('Failed to open directory', { description: accessResult.error || 'Desktop could not grant file access.', }); return; } const added = addProject(result.path, { id: result.projectId }); if (!added) { toast.error('Failed to add project', { description: 'Please select a valid directory path.', }); } }) .catch((error) => { console.error('Desktop: Error selecting directory:', error); toast.error('Failed to select directory'); }); return; } setIsDirectoryDialogOpen(true); }, [ addProject, hasShownInitialDirectoryPrompt, isHomeReady, projects.length, requestAccess, startAccessing, ]); 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); setIsProcessingDelete(false); }, []); React.useEffect(() => { return sessionEvents.onDeleteRequest((payload) => { openDeleteDialog(payload); }); }, [openDeleteDialog]); React.useEffect(() => { return sessionEvents.onDirectoryRequest(() => { setIsDirectoryDialogOpen(true); }); }, []); React.useEffect(() => { if (!deleteDialog) { setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); 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); if (summaries.length === 0) { return; } let cancelled = false; (async () => { const statuses = await Promise.all( summaries.map(async ({ metadata }) => { if (metadata.status && typeof metadata.status.isDirty === 'boolean') { return metadata.status; } try { return await getWorktreeStatus(metadata.path); } catch { return metadata.status; } }) ).catch((error) => { console.warn('Failed to inspect worktree status before deletion:', error); return summaries.map(({ metadata }) => metadata.status); }); if (cancelled || !Array.isArray(statuses)) { return; } setDeleteDialogSummaries((prev) => prev.map((entry, index) => ({ session: entry.session, metadata: { ...entry.metadata, status: statuses[index] ?? entry.metadata.status }, })) ); })(); return () => { cancelled = true; }; }, [deleteDialog, getWorktreeMetadata]); React.useEffect(() => { if (!canRemoveRemoteBranches) { setDeleteDialogShouldRemoveRemote(false); } }, [canRemoveRemoteBranches]); const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; } setIsProcessingDelete(true); try { const shouldArchive = shouldArchiveWorktree; const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; await archiveWorktree({ projectDirectory: projectDirectory, path: deleteDialog.worktree.path, branch: deleteDialog.worktree.branch, force: true, deleteRemote: shouldRemoveRemote, }); const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; toast.success('Worktree removed', { description: renderToastDescription(archiveNote), }); closeDeleteDialog(); loadSessions(); return; } if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; const success = await deleteSession(target.id, { archiveWorktree: shouldArchive, deleteRemoteBranch: removeRemoteBranch, }); if (!success) { toast.error('Failed to delete session'); setIsProcessingDelete(false); return; } const archiveNote = shouldArchive ? removeRemoteBranch ? 'Worktree and remote branch removed.' : 'Attached worktree archived.' : undefined; toast.success('Session deleted', { description: renderToastDescription(archiveNote), }); } else { const ids = deleteDialog.sessions.map((session) => session.id); const { deletedIds, failedIds } = await deleteSessions(ids, { archiveWorktree: shouldArchive, deleteRemoteBranch: removeRemoteBranch, }); if (deletedIds.length > 0) { const archiveNote = shouldArchive ? removeRemoteBranch ? 'Archived worktrees and removed remote branches.' : 'Attached worktrees archived.' : undefined; const successDescription = failedIds.length > 0 ? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.` : deleteDialog.dateLabel ? `Removed all sessions from ${deleteDialog.dateLabel}.` : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription(combinedDescription || undefined), }); } if (failedIds.length > 0) { toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription('Please try again in a moment.'), }); if (deletedIds.length === 0) { setIsProcessingDelete(false); return; } } } closeDeleteDialog(); } finally { setIsProcessingDelete(false); } }, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; const deleteDialogDescription = deleteDialog ? deleteDialog.mode === 'worktree' ? deleteDialog.sessions.length === 0 ? 'This removes the selected worktree.' : `This removes the selected worktree and ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.` : `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : '' }.` : ''; const deleteDialogBody = deleteDialog ? (
{deleteDialog.sessions.length > 0 && (
{isWorktreeDelete && (
{deleteDialog.sessions.length === 1 ? 'Linked session' : 'Linked sessions'} {deleteDialog.sessions.length}
)}
    {deleteDialog.sessions.slice(0, 5).map((session) => (
  • {session.title || 'Untitled Session'}
  • ))} {deleteDialog.sessions.length > 5 && (
  • +{deleteDialog.sessions.length - 5} more
  • )}
)} {isWorktreeDelete ? (
Worktree {targetWorktree?.label ? ( {targetWorktree.label} ) : null}

{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : 'Worktree path unavailable.'}

{hasDirtyWorktrees && (

Uncommitted changes will be discarded.

)}
) : (

Worktree directories stay intact. Subsessions linked to the selected sessions will also be removed.

)}
) : null; const deleteRemoteBranchAction = isWorktreeDelete ? ( canRemoveRemoteBranches ? ( ) : ( Remote branch info unavailable ) ) : null; const deleteDialogActions = isWorktreeDelete ? (
{deleteRemoteBranchAction}
) : ( <> ); const deleteDialogTitle = isWorktreeDelete ? 'Delete worktree' : deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'; 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}
)} ); };