Files
openchamber/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts
T
Bohdan Triapitsyn c82f188fc8 refactor(surface): remove the main-area surface concept entirely
activeSurface was permanently 'chat' after the legacy mobile layout
removal, so the whole concept is gone: the store field, surfaceGuard,
setActiveSurface/setSurfaceGuard, the per-runtime surface memory in
prepare/restoreForRuntimeSwitch, and WorkspaceSurface itself. All ~30
setActiveSurface('chat') call sites were no-ops and are deleted;
always-true 'is the chat active' checks in keyboard shortcuts, Header
and ChatContainer are unconditional now. FilesView's dirty-file guard
kept its file-switch and close protection but drops the surface-switch
branch nothing could trigger. TerminalView visibility comes only from
its callers. The router keeps parsing legacy ?tab= links (they open the
matching context-panel surface) via its own RouteTab type and no longer
serializes a tab or diff file into URLs — desktop URLs never carried
them anyway.
2026-08-24 16:36:41 +03:00

314 lines
12 KiB
TypeScript

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<React.SetStateAction<{
session: Session;
descendantCount: number;
descendantIds: string[];
archivedBucket: boolean;
} | null>>;
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<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
deleteSession: (id: string) => Promise<boolean>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
archiveSession: (id: string) => Promise<boolean>;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
unarchiveSession: (id: string) => Promise<boolean>;
childrenMap: Map<string, Session[]>;
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<string | null>(null);
const copyTimeout = React.useRef<number | null>(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<boolean> => {
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,
};
};