fix(sessions): align archive-cascade count with executed list (#1298)

* fix(sessions): align archive-cascade count with executed list

When archiving a parent session, the dialog showed the total descendant
count but the actual archive request skipped already-archived
descendants — so the toast/result didn't match what the user was told.

Collect descendants once, then filter per-action: hard-delete cascades
to every descendant; archive skips already-archived ones. Dialog count
and the ID list passed to archiveSessions/deleteSessions now agree.

* fix(sessions): snapshot descendant IDs at dialog open

Address Greptile review: handleDeleteSession populated the dialog
count from a fresh collectDescendants() call, then confirmDeleteSession
re-collected at confirm time — so a background sync that mutated
childrenMap while the dialog was open could re-introduce the same
count-vs-executed mismatch this PR fixed.

Store the computed descendant IDs in deleteSessionConfirm and pass
them through to executeDeleteSession, so the executed list is always
the snapshot the user was shown. The no-dialog direct-execute path
also routes through the snapshot for consistency.

---------

Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com>
This commit is contained in:
vhqtvn
2026-05-18 18:13:43 +03:00
committed by GitHub
co-authored by vhqtvn
parent 86f039d3fd
commit cbe6335ed9
2 changed files with 45 additions and 12 deletions
@@ -7,6 +7,10 @@ import { useI18n } from '@/lib/i18n';
export type DeleteSessionConfirmState = {
session: Session;
descendantCount: number;
// Snapshot of the descendant IDs computed when the dialog opened, so the
// executed list matches the count shown to the user even if childrenMap
// changes while the dialog is open.
descendantIds: string[];
archivedBucket: boolean;
} | null;
@@ -7,6 +7,7 @@ import { useI18n } from '@/lib/i18n';
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
session: Session;
descendantCount: number;
descendantIds: string[];
archivedBucket: boolean;
} | null>>;
@@ -36,7 +37,7 @@ type Args = {
childrenMap: Map<string, Session[]>;
showDeletionDialog: boolean;
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
deleteSessionConfirm: { session: Session; descendantCount: number; archivedBucket: boolean } | null;
deleteSessionConfirm: { session: Session; descendantCount: number; descendantIds: string[]; archivedBucket: boolean } | null;
setEditingId: (id: string | null) => void;
setEditTitle: (value: string) => void;
editingId: string | null;
@@ -168,11 +169,30 @@ export const useSessionActions = (args: Args) => {
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?: { archivedBucket?: boolean }) => {
const descendants = collectDescendants(session.id);
async (
session: Session,
source?: { archivedBucket?: boolean },
precomputed?: { descendantIds: string[] },
) => {
const shouldHardDelete = source?.archivedBucket === true;
if (descendants.length === 0) {
// 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);
@@ -188,7 +208,7 @@ export const useSessionActions = (args: Args) => {
return;
}
const ids = [session.id, ...descendants.map((s) => s.id)];
const ids = [session.id, ...descendantIds];
if (shouldHardDelete) {
const { deletedIds, failedIds } = await args.deleteSessions(ids);
if (deletedIds.length > 0) {
@@ -216,26 +236,35 @@ export const useSessionActions = (args: Args) => {
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
},
[args, collectDescendants, t],
[args, collectDescendants, filterDescendantsForAction, t],
);
const handleDeleteSession = React.useCallback(
(session: Session, source?: { archivedBucket?: boolean }) => {
const descendants = collectDescendants(session.id);
const shouldHardDelete = source?.archivedBucket === true;
const effectiveDescendantIds = filterDescendantsForAction(
collectDescendants(session.id),
shouldHardDelete,
).map((s) => s.id);
if (!args.showDeletionDialog) {
void executeDeleteSession(session, source);
void executeDeleteSession(session, source, { descendantIds: effectiveDescendantIds });
return;
}
args.setDeleteSessionConfirm({ session, descendantCount: descendants.length, archivedBucket: source?.archivedBucket === true });
args.setDeleteSessionConfirm({
session,
descendantCount: effectiveDescendantIds.length,
descendantIds: effectiveDescendantIds,
archivedBucket: shouldHardDelete,
});
},
[args, collectDescendants, executeDeleteSession],
[args, collectDescendants, executeDeleteSession, filterDescendantsForAction],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!args.deleteSessionConfirm) return;
const { session, archivedBucket } = args.deleteSessionConfirm;
const { session, archivedBucket, descendantIds } = args.deleteSessionConfirm;
args.setDeleteSessionConfirm(null);
await executeDeleteSession(session, { archivedBucket });
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
}, [args, executeDeleteSession]);
return {