diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..d9d8fc81e6f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -444,15 +444,22 @@ export function resolveProjectStatusIndicator( export function getVisibleThreadsForProject>(input: { threads: readonly T[]; - activeThreadId: T["id"] | undefined; + activeThreadId: string | undefined; isThreadListExpanded: boolean; previewLimit: number; + getThreadId?: (thread: T) => string; }): { hasHiddenThreads: boolean; visibleThreads: T[]; hiddenThreads: T[]; } { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; + const { + activeThreadId, + getThreadId = (thread) => thread.id, + isThreadListExpanded, + previewLimit, + threads, + } = input; const hasHiddenThreads = threads.length > previewLimit; if (!hasHiddenThreads || isThreadListExpanded) { @@ -464,7 +471,7 @@ export function getVisibleThreadsForProject>(input: } const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { + if (!activeThreadId || previewThreads.some((thread) => getThreadId(thread) === activeThreadId)) { return { hasHiddenThreads: true, hiddenThreads: threads.slice(previewLimit), @@ -472,7 +479,7 @@ export function getVisibleThreadsForProject>(input: }; } - const activeThread = threads.find((thread) => thread.id === activeThreadId); + const activeThread = threads.find((thread) => getThreadId(thread) === activeThreadId); if (!activeThread) { return { hasHiddenThreads: true, @@ -481,12 +488,14 @@ export function getVisibleThreadsForProject>(input: }; } - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); + const visibleThreadIds = new Set( + [...previewThreads, activeThread].map((thread) => getThreadId(thread)), + ); return { hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), + hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(getThreadId(thread))), + visibleThreads: threads.filter((thread) => visibleThreadIds.has(getThreadId(thread))), }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..11c8ddae296 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -24,7 +24,15 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + memo, + useMemo, + useRef, + useState, +} from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -108,7 +116,7 @@ import { import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -185,6 +193,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, + getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -673,6 +682,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr @@ -1278,19 +1288,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { - const activeThreadKey = activeRouteThreadKey ?? undefined; - if (!activeThreadKey || projectExpanded) { - return null; - } - return ( - visibleProjectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null - ); - }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); - const { hasOverflowingThreads, hiddenThreadStatus, @@ -1315,37 +1312,37 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? visibleProjectThreads - : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); - const visibleThreadKeys = new Set( - [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads.filter((thread) => - visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const hiddenThreads = visibleProjectThreads.filter( - (thread) => - !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); + const visibleThreadsResult = getVisibleThreadsForProject({ + activeThreadId: activeRouteThreadKey ?? undefined, + getThreadId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + threads: visibleProjectThreads, + }); + const activeThread = activeRouteThreadKey + ? (visibleThreadsResult.visibleThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeRouteThreadKey, + ) ?? null) + : null; + const renderedThreads = projectExpanded + ? visibleThreadsResult.visibleThreads + : activeThread + ? [activeThread] + : []; return { - hasOverflowingThreads, + hasOverflowingThreads: visibleThreadsResult.hasHiddenThreads, hiddenThreadStatus: resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + visibleThreadsResult.hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, - shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, + shouldShowThreadPanel: projectExpanded || activeThread !== null, }; }, [ + activeRouteThreadKey, isThreadListExpanded, - pinnedCollapsedThread, projectExpanded, projectThreads, sidebarThreadPreviewCount, @@ -2196,10 +2193,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec startThreadRename, ], ); - return ( <> -
+
resolveThreadRouteRef(params), }); + const routeDraftId = useParams({ + strict: false, + select: (params) => (params.draftId ? DraftId.make(params.draftId) : null), + }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const activeRouteDraftSession = useComposerDraftStore((store) => + routeDraftId ? store.getDraftSession(routeDraftId) : null, + ); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3227,17 +3230,30 @@ export default function Sidebar() { // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { - if (!routeThreadKey) { - return null; + let activeScopedProjectKey: string | null = null; + if (routeThreadKey) { + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + activeScopedProjectKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + } else if (activeRouteDraftSession) { + activeScopedProjectKey = scopedProjectKey( + scopeProjectRef(activeRouteDraftSession.environmentId, activeRouteDraftSession.projectId), + ); } - const activeThread = sidebarThreadByKey.get(routeThreadKey); - if (!activeThread) return null; + + if (!activeScopedProjectKey) return null; const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); + projectPhysicalKeyByScopedRef.get(activeScopedProjectKey) ?? activeScopedProjectKey; return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [ + activeRouteDraftSession, + routeThreadKey, + sidebarThreadByKey, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + ]); // Group threads by logical project key so all threads from grouped projects // are displayed together. @@ -3364,6 +3380,10 @@ export default function Sidebar() { autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); animatedThreadListsRef.current.add(node); }, []); + const lastSidebarScrollTargetKeyRef = useRef(null); + const pendingInstantSidebarScrollTargetKeyRef = useRef(null); + const sidebarIsVisible = isMobile ? openMobile : open; + const sidebarWasVisibleRef = useRef(false); const visibleThreads = useMemo( () => sidebarThreads.filter((thread) => thread.archivedAt === null), @@ -3414,29 +3434,26 @@ export default function Sidebar() { projectExpandedById, projectExpansionPreferenceKeys(project), ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + const visibleThreadsResult = getVisibleThreadsForProject({ + activeThreadId: routeThreadKey ?? undefined, + getThreadId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + threads: projectThreads, + }); + if (projectExpanded) { + return visibleThreadsResult.visibleThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + } + return routeThreadKey && + visibleThreadsResult.visibleThreads.some( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ) + ? [routeThreadKey] + : []; }), [ sidebarThreadSortOrder, @@ -3448,6 +3465,93 @@ export default function Sidebar() { threadsByProjectKey, ], ); + const sidebarScrollTargetKey = activeRouteProjectKey + ? routeThreadKey + ? `thread:${routeThreadKey}` + : `project:${activeRouteProjectKey}` + : null; + const scrollSidebarTarget = useCallback( + (behavior: ScrollBehavior, force = false) => { + if ( + !sidebarScrollTargetKey || + !activeRouteProjectKey || + (!force && lastSidebarScrollTargetKeyRef.current === sidebarScrollTargetKey) + ) { + return false; + } + if (!sidebarProjectByKey.has(activeRouteProjectKey)) { + return false; + } + if (routeThreadKey) { + const projectThreads = threadsByProjectKey.get(activeRouteProjectKey) ?? []; + if ( + !projectThreads.some( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ) + ) { + return false; + } + } + const target = routeThreadKey + ? document.querySelector( + `[data-sidebar-thread-key="${globalThis.CSS.escape(routeThreadKey)}"]`, + ) + : document.querySelector( + `[data-sidebar-project-key="${globalThis.CSS.escape(activeRouteProjectKey)}"]`, + ); + if (!target) { + return false; + } + target.scrollIntoView({ behavior, block: "center" }); + lastSidebarScrollTargetKeyRef.current = sidebarScrollTargetKey; + return true; + }, + [ + activeRouteProjectKey, + routeThreadKey, + sidebarProjectByKey, + sidebarScrollTargetKey, + threadsByProjectKey, + ], + ); + useLayoutEffect(() => { + const wasSidebarVisible = sidebarWasVisibleRef.current; + sidebarWasVisibleRef.current = sidebarIsVisible; + if (!sidebarScrollTargetKey) { + pendingInstantSidebarScrollTargetKeyRef.current = null; + return; + } + if (!sidebarIsVisible) { + return; + } + if (pendingInstantSidebarScrollTargetKeyRef.current !== sidebarScrollTargetKey) { + pendingInstantSidebarScrollTargetKeyRef.current = null; + } + if (!wasSidebarVisible) { + pendingInstantSidebarScrollTargetKeyRef.current = sidebarScrollTargetKey; + } + if ( + pendingInstantSidebarScrollTargetKeyRef.current === sidebarScrollTargetKey && + scrollSidebarTarget("instant", true) + ) { + pendingInstantSidebarScrollTargetKeyRef.current = null; + } + }, [scrollSidebarTarget, sidebarIsVisible, sidebarScrollTargetKey]); + useEffect(() => { + if (!sidebarScrollTargetKey) { + lastSidebarScrollTargetKeyRef.current = null; + pendingInstantSidebarScrollTargetKeyRef.current = null; + return; + } + if (!sidebarIsVisible) { + return; + } + if (pendingInstantSidebarScrollTargetKeyRef.current === sidebarScrollTargetKey) { + return; + } + scrollSidebarTarget("smooth"); + }, [scrollSidebarTarget, sidebarIsVisible, sidebarScrollTargetKey]); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {