diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx index 8dae5fed3b5..bbb118efefa 100644 --- a/apps/web/src/commandPaletteContext.tsx +++ b/apps/web/src/commandPaletteContext.tsx @@ -1,14 +1,34 @@ +import type { ScopedProjectRef } from "@t3tools/contracts"; import { createContext, use, type ReactNode } from "react"; +import type { SidebarProjectRevealRequest } from "./sidebarProjectReveal"; const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); +const SidebarProjectRevealRequestContext = createContext< + SidebarProjectRevealRequest | null | undefined +>(undefined); +const RequestSidebarProjectRevealContext = createContext< + ((projectRef: ScopedProjectRef) => void) | null +>(null); +const CompleteSidebarProjectRevealContext = createContext<((requestId: number) => void) | null>( + null, +); export function OpenAddProjectCommandPaletteProvider(props: { readonly children: ReactNode; readonly openAddProject: () => void; + readonly sidebarProjectRevealRequest: SidebarProjectRevealRequest | null; + readonly requestSidebarProjectReveal: (projectRef: ScopedProjectRef) => void; + readonly completeSidebarProjectReveal: (requestId: number) => void; }) { return ( - {props.children} + + + + {props.children} + + + ); } @@ -21,6 +41,30 @@ export function useOpenAddProjectCommandPalette(): () => void { return openAddProject; } +export function useSidebarProjectRevealRequest(): SidebarProjectRevealRequest | null { + const request = use(SidebarProjectRevealRequestContext); + if (request === undefined) { + throw new Error("Command palette actions must be used inside CommandPalette"); + } + return request; +} + +export function useRequestSidebarProjectReveal(): (projectRef: ScopedProjectRef) => void { + const requestSidebarProjectReveal = use(RequestSidebarProjectRevealContext); + if (!requestSidebarProjectReveal) { + throw new Error("Command palette actions must be used inside CommandPalette"); + } + return requestSidebarProjectReveal; +} + +export function useCompleteSidebarProjectReveal(): (requestId: number) => void { + const completeSidebarProjectReveal = use(CompleteSidebarProjectRevealContext); + if (!completeSidebarProjectReveal) { + throw new Error("Command palette actions must be used inside CommandPalette"); + } + return completeSidebarProjectReveal; +} + /** Read at event time so the chat tree does not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a11d6c4cb07..7b666277a99 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -12,6 +12,7 @@ import { type FilesystemBrowseResult, type ProjectId, ProviderInstanceId, + type ScopedProjectRef, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -43,7 +44,10 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; +import { + OpenAddProjectCommandPaletteProvider, + useRequestSidebarProjectReveal, +} from "../commandPaletteContext"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; @@ -116,6 +120,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import type { SidebarProjectRevealRequest } from "../sidebarProjectReveal"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -368,6 +373,21 @@ export function CommandPalette({ children }: { children: ReactNode }) { const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); + const nextSidebarProjectRevealRequestIdRef = useRef(0); + const [sidebarProjectRevealRequest, setSidebarProjectRevealRequest] = + useState(null); + const requestSidebarProjectReveal = useCallback((projectRef: ScopedProjectRef): void => { + nextSidebarProjectRevealRequestIdRef.current += 1; + setSidebarProjectRevealRequest({ + requestId: nextSidebarProjectRevealRequestIdRef.current, + projectRef, + }); + }, []); + const completeSidebarProjectReveal = useCallback((requestId: number): void => { + setSidebarProjectRevealRequest((current) => + current?.requestId === requestId ? null : current, + ); + }, []); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -400,7 +420,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleOpen]); return ( - + {children} @@ -441,6 +466,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); + const requestSidebarProjectReveal = useRequestSidebarProjectReveal(); const { clearOpenIntent, openIntent, setOpen } = props; const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); @@ -584,6 +610,7 @@ function OpenCommandPaletteDialog(props: { const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { + requestSidebarProjectReveal(scopeProjectRef(project.environmentId, project.id)); const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === project.environmentId), project.id, @@ -601,7 +628,13 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }, - [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads], + [ + handleNewThread, + navigate, + requestSidebarProjectReveal, + clientSettings.sidebarThreadSortOrder, + threads, + ], ); const projectSearchItems = useMemo( @@ -635,6 +668,7 @@ function OpenCommandPaletteDialog(props: { /> ), runProject: async (project) => { + requestSidebarProjectReveal(scopeProjectRef(project.environmentId, project.id)); await startNewThreadInProjectFromContext( { activeDraftThread, @@ -646,7 +680,14 @@ function OpenCommandPaletteDialog(props: { ); }, }), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + activeDraftThread, + activeThread, + defaultProjectRef, + handleNewThread, + projects, + requestSidebarProjectReveal, + ], ); const allThreadItems = useMemo( @@ -1050,6 +1091,7 @@ function OpenCommandPaletteDialog(props: { cwd, ); if (existing) { + requestSidebarProjectReveal(scopeProjectRef(existing.environmentId, existing.id)); const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, @@ -1110,6 +1152,7 @@ function OpenCommandPaletteDialog(props: { return; } + requestSidebarProjectReveal(scopeProjectRef(browseEnvironmentId, projectId)); const navigationResult = await settlePromise(() => handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), ); @@ -1134,6 +1177,7 @@ function OpenCommandPaletteDialog(props: { createProject, navigate, projects, + requestSidebarProjectReveal, setOpen, clientSettings.sidebarThreadSortOrder, threads, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3ed88bd3b9..8cb32fcf680 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -178,7 +178,11 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { + useCompleteSidebarProjectReveal, + useOpenAddProjectCommandPalette, + useSidebarProjectRevealRequest, +} from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -217,6 +221,10 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { + resolveSidebarProjectRevealKey, + scrollSidebarProjectIntoView, +} from "../sidebarProjectReveal"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1065,6 +1073,8 @@ interface SidebarProjectItemProps { suppressProjectClickForContextMenuRef: React.RefObject; isManualProjectSorting: boolean; dragHandleProps: SortableProjectHandleProps | null; + projectRevealRequestId: number | null; + completeProjectReveal: (requestId: number) => void; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { @@ -1085,7 +1095,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, + projectRevealRequestId, + completeProjectReveal, } = props; + const projectHeaderRef = useRef(null); const threadSortOrder = useClientSettings( (settings) => settings.sidebarThreadSortOrder, ); @@ -1160,6 +1173,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); const openPrLink = useOpenPrLink(); + + useEffect(() => { + if (projectRevealRequestId === null) { + return; + } + + const requestId = projectRevealRequestId; + let cancelled = false; + let frame: number | null = null; + let attempts = 0; + + const tryReveal = () => { + if (cancelled) { + return; + } + + const projectHeader = projectHeaderRef.current; + if (projectHeader) { + scrollSidebarProjectIntoView(projectHeader); + completeProjectReveal(requestId); + return; + } + + attempts += 1; + if (attempts >= 20) { + completeProjectReveal(requestId); + return; + } + + frame = window.requestAnimationFrame(tryReveal); + }; + + frame = window.requestAnimationFrame(tryReveal); + + return () => { + cancelled = true; + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + }; + }, [completeProjectReveal, projectRevealRequestId]); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); const sidebarThreadByKey = useMemo( () => @@ -2186,7 +2240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
+
; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + projectRevealTarget: { readonly projectKey: string; readonly requestId: number } | null; + completeProjectReveal: (requestId: number) => void; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2814,6 +2870,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + projectRevealTarget, + completeProjectReveal, } = props; const handleProjectSortOrderChange = useCallback( @@ -2962,6 +3020,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } isManualProjectSorting={isManualProjectSorting} dragHandleProps={dragHandleProps} + projectRevealRequestId={ + projectRevealTarget?.projectKey === project.projectKey + ? projectRevealTarget.requestId + : null + } + completeProjectReveal={completeProjectReveal} /> )} @@ -2992,6 +3056,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} isManualProjectSorting={isManualProjectSorting} dragHandleProps={null} + projectRevealRequestId={ + projectRevealTarget?.projectKey === project.projectKey + ? projectRevealTarget.requestId + : null + } + completeProjectReveal={completeProjectReveal} /> ))} @@ -3037,6 +3107,8 @@ export default function Sidebar() { ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const sidebarProjectRevealRequest = useSidebarProjectRevealRequest(); + const completeSidebarProjectReveal = useCompleteSidebarProjectReveal(); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -3103,6 +3175,17 @@ export default function Sidebar() { () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], ); + const projectRevealKey = sidebarProjectRevealRequest + ? resolveSidebarProjectRevealKey({ + projectRef: sidebarProjectRevealRequest.projectRef, + physicalProjectKeyByScopedRef: projectPhysicalKeyByScopedRef, + physicalToLogicalKey, + }) + : null; + const projectRevealTarget = + projectRevealKey && sidebarProjectRevealRequest + ? { projectKey: projectRevealKey, requestId: sidebarProjectRevealRequest.requestId } + : null; const sidebarThreadByKey = useMemo( () => new Map( @@ -3629,6 +3712,8 @@ export default function Sidebar() { suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projects.length} + projectRevealTarget={projectRevealTarget} + completeProjectReveal={completeSidebarProjectReveal} /> diff --git a/apps/web/src/sidebarProjectReveal.test.ts b/apps/web/src/sidebarProjectReveal.test.ts new file mode 100644 index 00000000000..15ffd5a36a2 --- /dev/null +++ b/apps/web/src/sidebarProjectReveal.test.ts @@ -0,0 +1,62 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + resolveSidebarProjectRevealKey, + scrollSidebarProjectIntoView, +} from "./sidebarProjectReveal"; + +const projectRef = scopeProjectRef( + EnvironmentId.make("environment-local"), + ProjectId.make("project-1"), +); + +describe("resolveSidebarProjectRevealKey", () => { + it("resolves a physical project to its grouped sidebar row", () => { + expect( + resolveSidebarProjectRevealKey({ + projectRef, + physicalProjectKeyByScopedRef: new Map([ + [scopedProjectKey(projectRef), "physical-project"], + ]), + physicalToLogicalKey: new Map([["physical-project", "logical-project"]]), + }), + ).toBe("logical-project"); + }); + + it("uses the physical row key when the project is not grouped", () => { + expect( + resolveSidebarProjectRevealKey({ + projectRef, + physicalProjectKeyByScopedRef: new Map([ + [scopedProjectKey(projectRef), "physical-project"], + ]), + physicalToLogicalKey: new Map(), + }), + ).toBe("physical-project"); + }); + + it("waits until the requested project is available in the sidebar", () => { + expect( + resolveSidebarProjectRevealKey({ + projectRef, + physicalProjectKeyByScopedRef: new Map(), + physicalToLogicalKey: new Map(), + }), + ).toBeNull(); + }); +}); + +describe("scrollSidebarProjectIntoView", () => { + it("smoothly centers the selected project header", () => { + const scrollIntoView = vi.fn(); + + scrollSidebarProjectIntoView({ scrollIntoView }); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + }); +}); diff --git a/apps/web/src/sidebarProjectReveal.ts b/apps/web/src/sidebarProjectReveal.ts new file mode 100644 index 00000000000..49d21eef715 --- /dev/null +++ b/apps/web/src/sidebarProjectReveal.ts @@ -0,0 +1,30 @@ +import { scopedProjectKey } from "@t3tools/client-runtime/environment"; +import type { ScopedProjectRef } from "@t3tools/contracts"; + +export interface SidebarProjectRevealRequest { + readonly requestId: number; + readonly projectRef: ScopedProjectRef; +} + +export function scrollSidebarProjectIntoView( + projectHeader: Pick, +): void { + projectHeader.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); +} + +export function resolveSidebarProjectRevealKey(input: { + readonly projectRef: ScopedProjectRef; + readonly physicalProjectKeyByScopedRef: ReadonlyMap; + readonly physicalToLogicalKey: ReadonlyMap; +}): string | null { + const physicalKey = input.physicalProjectKeyByScopedRef.get(scopedProjectKey(input.projectRef)); + if (!physicalKey) { + return null; + } + + return input.physicalToLogicalKey.get(physicalKey) ?? physicalKey; +}