import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { streamPerfMark } from '@/stores/utils/streamDebug'; import { useSessionUIStore } from '@/sync/session-ui-store'; type DeleteSessionConfirmSetter = React.Dispatch>; type DeleteSessionSource = { archivedBucket?: boolean; hardDelete?: boolean; /** Bypass the confirmation dialog and delete/archive immediately. */ skipConfirm?: boolean; }; type Args = { mobileVariant: boolean; allowReselect: boolean; onSessionSelected?: (sessionId: string) => void; isSessionSearchOpen: boolean; sessionSearchQuery: string; setSessionSearchQuery: (value: string) => void; setIsSessionSearchOpen: (open: boolean) => void; setSessionSwitcherOpen: (open: boolean) => void; setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; deleteSession: (id: string) => Promise; deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; archiveSession: (id: string) => Promise; archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; unarchiveSession: (id: string) => Promise; childrenMap: Map; showDeletionDialog: boolean; setDeleteSessionConfirm: DeleteSessionConfirmSetter; deleteSessionConfirm: { session: Session; descendantCount: number; descendantIds: string[]; archivedBucket: boolean } | null; setEditingId: (id: string | null) => void; setEditTitle: (value: string) => void; editingId: string | null; editTitle: string; }; export const useSessionActions = (args: Args) => { const { t } = useI18n(); const [copiedSessionId, setCopiedSessionId] = React.useState(null); const copyTimeout = React.useRef(null); React.useEffect(() => { return () => { if (copyTimeout.current) { clearTimeout(copyTimeout.current); } }; }, []); const handleSessionSelect = React.useCallback( (sessionId: string, sessionDirectory?: string | null) => { streamPerfMark('navigation.session_select'); // Selecting a session always leaves any full-page surface, even when // the session is already the current one (no store transition fires). useUIStore.getState().closeMainSurfaces(); const resetSessionSearch = () => { if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) { return; } args.setSessionSearchQuery(''); args.setIsSessionSearchOpen(false); }; if (args.mobileVariant) { args.setSessionSwitcherOpen(false); } if (sessionId === useSessionUIStore.getState().currentSessionId) { if (args.allowReselect) { args.onSessionSelected?.(sessionId); } resetSessionSearch(); return; } streamPerfMark('navigation.session_state_set'); args.setCurrentSession(sessionId, sessionDirectory ?? null); args.onSessionSelected?.(sessionId); resetSessionSearch(); }, [args], ); const handleSessionDoubleClick = React.useCallback((sessionId: string, sessionTitle: string) => { args.setEditingId(sessionId); args.setEditTitle(sessionTitle); }, [args]); const handleSaveEdit = React.useCallback(async (titleOverride?: string) => { if (!args.editingId) return; const trimmed = (titleOverride ?? args.editTitle).trim(); if (trimmed) { await args.updateSessionTitle(args.editingId, trimmed); } args.setEditingId(null); args.setEditTitle(''); }, [args]); const handleCancelEdit = React.useCallback(() => { args.setEditingId(null); args.setEditTitle(''); }, [args]); const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise => { try { const result = await copyTextToClipboard(url); if (!result.ok) return false; setCopiedSessionId(sessionId); if (copyTimeout.current) clearTimeout(copyTimeout.current); copyTimeout.current = window.setTimeout(() => { setCopiedSessionId(null); copyTimeout.current = null; }, 2000); return true; } catch { return false; } }, []); const handleShareSession = React.useCallback(async (session: Session) => { const result = await args.shareSession(session.id); if (!result?.share?.url) { toast.error(t('sessions.sidebar.session.share.error')); return; } const copied = await copyShareUrl(result.share.url, session.id); toast[copied ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), { description: t(copied ? 'sessions.sidebar.session.share.successDescription' : 'sessions.sidebar.session.share.copyUrlError'), }); }, [args, copyShareUrl, t]); const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { void copyShareUrl(url, sessionId).then((copied) => { if (!copied) toast.error(t('sessions.sidebar.session.share.copyUrlError')); }); }, [copyShareUrl, t]); const handleCopySessionId = React.useCallback((sessionId: string) => { void copyTextToClipboard(sessionId) .then((result) => { if (result.ok) { toast.success(t('sessions.sidebar.session.copyId.success')); return; } toast.error(t('sessions.sidebar.session.copyId.error')); }) .catch(() => toast.error(t('sessions.sidebar.session.copyId.error'))); }, [t]); const handleUnshareSession = React.useCallback(async (sessionId: string) => { const result = await args.unshareSession(sessionId); if (result) { toast.success(t('sessions.sidebar.session.unshare.success')); } else { toast.error(t('sessions.sidebar.session.unshare.error')); } }, [args, t]); const collectDescendants = React.useCallback((sessionId: string): Session[] => { const collected: Session[] = []; const visit = (id: string) => { const children = args.childrenMap.get(id) ?? []; children.forEach((child) => { collected.push(child); visit(child.id); }); }; visit(sessionId); return collected; }, [args.childrenMap]); // Archive cascades to subagents that aren't already archived; hard-delete // cascades to every descendant unconditionally. We collect once and filter // per-action so the dialog count and the executed ID list always agree. const filterDescendantsForAction = React.useCallback( (descendants: Session[], shouldHardDelete: boolean): Session[] => { if (shouldHardDelete) return descendants; return descendants.filter((s) => !s.time?.archived); }, [], ); const executeDeleteSession = React.useCallback( async ( session: Session, source?: DeleteSessionSource, precomputed?: { descendantIds: string[] }, ) => { const shouldHardDelete = source?.archivedBucket === true || source?.hardDelete === true; // Use the snapshot taken when the dialog opened (if any) so the // executed list matches what the user was told. Fall back to a fresh // collection for direct-execute (no-dialog) callers. const descendantIds = precomputed?.descendantIds ?? filterDescendantsForAction(collectDescendants(session.id), shouldHardDelete).map((s) => s.id); if (descendantIds.length === 0) { const success = shouldHardDelete ? await args.deleteSession(session.id) : await args.archiveSession(session.id); if (success) { toast.success(shouldHardDelete ? t('sessions.sidebar.session.delete.success') : t('sessions.sidebar.session.archive.success')); } else { toast.error(shouldHardDelete ? t('sessions.sidebar.session.delete.error') : t('sessions.sidebar.session.archive.error')); } return; } const ids = [session.id, ...descendantIds]; if (shouldHardDelete) { // Delete root + all descendants individually. If the server // cascade-deletes some children before we get to them, 404 is // treated as success by deleteSession and no rollback occurs. const { deletedIds, failedIds } = await args.deleteSessions(ids); if (failedIds.length === 0) { const totalDeleted = deletedIds.length; toast.success(totalDeleted === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: totalDeleted }) : t('sessions.sidebar.bulkActions.deletedPlural', { count: totalDeleted })); } else { toast.error(t('sessions.sidebar.session.delete.error')); } return; } const { archivedIds, failedIds } = await args.archiveSessions(ids); if (archivedIds.length > 0) { toast.success(archivedIds.length === 1 ? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length }) : t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length })); } if (failedIds.length > 0) { toast.error(failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length })); } }, [args, collectDescendants, filterDescendantsForAction, t], ); const handleDeleteSession = React.useCallback( (session: Session, source?: DeleteSessionSource) => { const shouldHardDelete = source?.archivedBucket === true || source?.hardDelete === true; const effectiveDescendantIds = filterDescendantsForAction( collectDescendants(session.id), shouldHardDelete, ).map((s) => s.id); if (!args.showDeletionDialog || source?.skipConfirm === true) { void executeDeleteSession(session, source, { descendantIds: effectiveDescendantIds }); return; } args.setDeleteSessionConfirm({ session, descendantCount: effectiveDescendantIds.length, descendantIds: effectiveDescendantIds, archivedBucket: shouldHardDelete, }); }, [args, collectDescendants, executeDeleteSession, filterDescendantsForAction], ); const confirmDeleteSession = React.useCallback(async () => { if (!args.deleteSessionConfirm) return; const { session, archivedBucket, descendantIds } = args.deleteSessionConfirm; args.setDeleteSessionConfirm(null); await executeDeleteSession(session, { archivedBucket }, { descendantIds }); }, [args, executeDeleteSession]); const handleRestoreSession = React.useCallback( async (session: Session) => { const success = await args.unarchiveSession(session.id); if (success) { toast.success(t('sessions.sidebar.session.restore.success')); } else { toast.error(t('sessions.sidebar.session.restore.error')); } }, [args, t], ); return { copiedSessionId, handleSessionSelect, handleSessionDoubleClick, handleSaveEdit, handleCancelEdit, handleShareSession, handleCopyShareUrl, handleCopySessionId, handleUnshareSession, handleDeleteSession, handleRestoreSession, confirmDeleteSession, }; };