Archived sessions had no way back to the active list: the only available action was "Delete permanently". Add restore per session (sidebar context menu, Archive page row) and in bulk (sidebar selection bar). The OpenCode server cannot clear time.archived over HTTP — session.update only applies the field for a finite number, so an omitted key is a no-op and null is silently ignored (verified against opencode 1.18.12). Restore therefore writes time.archived = 0: every client-side reader classifies archive state by truthiness, so 0 reads as active in the UI, the event reducer, and the OpenCode app/TUI. The server's time_archived IS NULL list filter still excludes such rows, so the global session cache no longer issues an archived:false request for its active list. Full and per-directory loads now fetch once with the inclusive flag and split client-side via splitGlobalSessionsByArchived, which also halves per-directory refresh requests. Directory bootstrap keeps the server filter because live child stores must not hold archived sessions; a restored session re-enters its live store through the authoritative session.updated event. unarchiveSession/unarchiveSessions follow the archiveSession contract: wait for server confirmation before reconciling stores, runtime-guard every reconciliation, preserve partial batch results, and fail loudly when the server keeps the session archived instead of toasting a successful no-op. Closes #2346
317 lines
12 KiB
TypeScript
317 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 type { MainTab } from '@/stores/useUIStore';
|
|
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;
|
|
setActiveMainTab: (tab: MainTab) => 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.setActiveMainTab('chat');
|
|
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,
|
|
};
|
|
};
|