The previous system layered three hooks (useScrollEngine, useChatScrollManager, useChatTimelineController) with overlapping responsibilities, four parallel ResizeObservers/MutationObservers, and six entry points to "scroll to bottom" (force-flag combinations, persistent follow loops, materialization recovery). This produced bugs where users could not break free of auto-follow during streaming: scrollbar drag, keyboard scrolling and find-in-page were not detected as user intent, and observers kept restarting the follow loop on every DOM mutation. The new architecture replaces the two low-level hooks with a single useChatAutoFollow that owns scroll behaviour end to end: - One state: 'following' or 'released'. No follow modes, no pin flags, no marker pixels. - One scroll writer: a lerp loop that runs only while the session is streaming and state is 'following'. Idle sessions never write scrollTop programmatically. - One user-intent detector: wheel up, touch drag down, keyboard (PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb, and explicit releaseAutoFollow() calls all flip the state to 'released'. - A 1.2s grace period after explicit release: re-pin will not auto-engage inside this window, so a small wheel up cannot snap the user back even while they remain near the bottom spacer. - Re-pin and the scroll-to-bottom button share the same threshold: the height of the empty bottom spacer (10vh on desktop, 40px on mobile). Released users see the button only after they have scrolled past the spacer that already exists at the end of the chat. - Save/restore of scroll position uses ratio mapping, debounced at 150ms on user-driven scroll events; programmatic writes are masked via a short window so they never persist as user positions. - Container reattachment is detected via a useLayoutEffect probe over scrollRef.current. Listeners and observers re-bind when ChatViewport mounts after hydration or after the first message promotes a draft session into a real chat. - A pending-restore queue replays restoreSnapshot once the scroll container appears, fixing the case where a hydrating session landed at the top instead of the bottom. Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent follow loop with its own ResizeObserver+MutationObserver pair, the materialization-recovery .finally resume that yanked idle users to the bottom on transient sync gaps, and the openchamber:session-reselected event (re-select still works through the existing onSessionSelected callback). The openchamber:chat-force-scroll-bottom event remains for synthetic-message paths like git-message generation. Net change: ~1300 lines removed, two hooks replaced with one, one observer pair instead of four.
254 lines
9.0 KiB
TypeScript
254 lines
9.0 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';
|
|
|
|
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
|
session: Session;
|
|
descendantCount: number;
|
|
archivedBucket: boolean;
|
|
} | null>>;
|
|
|
|
type Args = {
|
|
activeProjectId: string | null;
|
|
currentDirectory: string | null;
|
|
currentSessionId: string | null;
|
|
mobileVariant: boolean;
|
|
allowReselect: boolean;
|
|
onSessionSelected?: (sessionId: string) => void;
|
|
isSessionSearchOpen: boolean;
|
|
sessionSearchQuery: string;
|
|
setSessionSearchQuery: (value: string) => void;
|
|
setIsSessionSearchOpen: (open: boolean) => void;
|
|
setActiveProjectIdOnly: (id: string) => void;
|
|
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
|
|
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => 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[] }>;
|
|
childrenMap: Map<string, Session[]>;
|
|
showDeletionDialog: boolean;
|
|
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
|
deleteSessionConfirm: { session: Session; descendantCount: number; 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, disabled?: boolean, projectId?: string | null) => {
|
|
if (disabled) {
|
|
return;
|
|
}
|
|
|
|
const resetSessionSearch = () => {
|
|
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
|
return;
|
|
}
|
|
args.setSessionSearchQuery('');
|
|
args.setIsSessionSearchOpen(false);
|
|
};
|
|
|
|
if (projectId && projectId !== args.activeProjectId) {
|
|
args.setActiveProjectIdOnly(projectId);
|
|
}
|
|
|
|
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
|
|
args.setDirectory(sessionDirectory, { showOverlay: false });
|
|
}
|
|
|
|
if (args.mobileVariant) {
|
|
args.setActiveMainTab('chat');
|
|
args.setSessionSwitcherOpen(false);
|
|
}
|
|
|
|
if (sessionId === args.currentSessionId) {
|
|
if (args.allowReselect) {
|
|
args.onSessionSelected?.(sessionId);
|
|
}
|
|
resetSessionSearch();
|
|
return;
|
|
}
|
|
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
|
args.onSessionSelected?.(sessionId);
|
|
resetSessionSearch();
|
|
},
|
|
[args],
|
|
);
|
|
|
|
const handleSessionDoubleClick = React.useCallback(() => {
|
|
args.setActiveMainTab('chat');
|
|
}, [args]);
|
|
|
|
const handleSaveEdit = React.useCallback(async () => {
|
|
if (args.editingId && args.editTitle.trim()) {
|
|
await args.updateSessionTitle(args.editingId, args.editTitle.trim());
|
|
args.setEditingId(null);
|
|
args.setEditTitle('');
|
|
}
|
|
}, [args]);
|
|
|
|
const handleCancelEdit = React.useCallback(() => {
|
|
args.setEditingId(null);
|
|
args.setEditTitle('');
|
|
}, [args]);
|
|
|
|
const handleShareSession = React.useCallback(async (session: Session) => {
|
|
const result = await args.shareSession(session.id);
|
|
if (result && result.share?.url) {
|
|
toast.success(t('sessions.sidebar.session.share.successTitle'), {
|
|
description: t('sessions.sidebar.session.share.successDescription'),
|
|
});
|
|
} else {
|
|
toast.error(t('sessions.sidebar.session.share.error'));
|
|
}
|
|
}, [args, t]);
|
|
|
|
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
|
void copyTextToClipboard(url)
|
|
.then((result) => {
|
|
if (!result.ok) {
|
|
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
|
|
return;
|
|
}
|
|
setCopiedSessionId(sessionId);
|
|
if (copyTimeout.current) {
|
|
clearTimeout(copyTimeout.current);
|
|
}
|
|
copyTimeout.current = window.setTimeout(() => {
|
|
setCopiedSessionId(null);
|
|
copyTimeout.current = null;
|
|
}, 2000);
|
|
})
|
|
.catch(() => {
|
|
toast.error(t('sessions.sidebar.session.share.copyUrlError'));
|
|
});
|
|
}, [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]);
|
|
|
|
const executeDeleteSession = React.useCallback(
|
|
async (session: Session, source?: { archivedBucket?: boolean }) => {
|
|
const descendants = collectDescendants(session.id);
|
|
const shouldHardDelete = source?.archivedBucket === true;
|
|
if (descendants.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, ...descendants.map((s) => s.id)];
|
|
if (shouldHardDelete) {
|
|
const { deletedIds, failedIds } = await args.deleteSessions(ids);
|
|
if (deletedIds.length > 0) {
|
|
toast.success(deletedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
|
|
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
|
|
}
|
|
if (failedIds.length > 0) {
|
|
toast.error(failedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
|
|
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
|
|
}
|
|
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, t],
|
|
);
|
|
|
|
const handleDeleteSession = React.useCallback(
|
|
(session: Session, source?: { archivedBucket?: boolean }) => {
|
|
const descendants = collectDescendants(session.id);
|
|
if (!args.showDeletionDialog) {
|
|
void executeDeleteSession(session, source);
|
|
return;
|
|
}
|
|
args.setDeleteSessionConfirm({ session, descendantCount: descendants.length, archivedBucket: source?.archivedBucket === true });
|
|
},
|
|
[args, collectDescendants, executeDeleteSession],
|
|
);
|
|
|
|
const confirmDeleteSession = React.useCallback(async () => {
|
|
if (!args.deleteSessionConfirm) return;
|
|
const { session, archivedBucket } = args.deleteSessionConfirm;
|
|
args.setDeleteSessionConfirm(null);
|
|
await executeDeleteSession(session, { archivedBucket });
|
|
}, [args, executeDeleteSession]);
|
|
|
|
return {
|
|
copiedSessionId,
|
|
handleSessionSelect,
|
|
handleSessionDoubleClick,
|
|
handleSaveEdit,
|
|
handleCancelEdit,
|
|
handleShareSession,
|
|
handleCopyShareUrl,
|
|
handleUnshareSession,
|
|
handleDeleteSession,
|
|
confirmDeleteSession,
|
|
};
|
|
};
|