Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion apps/web/src/commandPaletteContext.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<OpenAddProjectCommandPaletteContext value={props.openAddProject}>
{props.children}
<SidebarProjectRevealRequestContext value={props.sidebarProjectRevealRequest}>
<RequestSidebarProjectRevealContext value={props.requestSidebarProjectReveal}>
<CompleteSidebarProjectRevealContext value={props.completeSidebarProjectReveal}>
{props.children}
</CompleteSidebarProjectRevealContext>
</RequestSidebarProjectRevealContext>
</SidebarProjectRevealRequestContext>
</OpenAddProjectCommandPaletteContext>
);
}
Expand All @@ -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 (
Expand Down
52 changes: 48 additions & 4 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type FilesystemBrowseResult,
type ProjectId,
ProviderInstanceId,
type ScopedProjectRef,
type SourceControlDiscoveryResult,
type SourceControlProviderKind,
type SourceControlRepositoryInfo,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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"] = [];

Expand Down Expand Up @@ -368,6 +373,21 @@ export function CommandPalette({ children }: { children: ReactNode }) {
const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const composerHandleRef = useRef<ChatComposerHandle | null>(null);
const nextSidebarProjectRevealRequestIdRef = useRef(0);
const [sidebarProjectRevealRequest, setSidebarProjectRevealRequest] =
useState<SidebarProjectRevealRequest | null>(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),
Expand Down Expand Up @@ -400,7 +420,12 @@ export function CommandPalette({ children }: { children: ReactNode }) {
}, [keybindings, terminalOpen, toggleOpen]);

return (
<OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}>
<OpenAddProjectCommandPaletteProvider
openAddProject={openAddProject}
sidebarProjectRevealRequest={sidebarProjectRevealRequest}
requestSidebarProjectReveal={requestSidebarProjectReveal}
completeSidebarProjectReveal={completeSidebarProjectReveal}
>
<ComposerHandleContext value={composerHandleRef}>
<CommandDialog open={state.open} onOpenChange={setOpen}>
{children}
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -635,6 +668,7 @@ function OpenCommandPaletteDialog(props: {
/>
),
runProject: async (project) => {
requestSidebarProjectReveal(scopeProjectRef(project.environmentId, project.id));
await startNewThreadInProjectFromContext(
{
activeDraftThread,
Expand All @@ -646,7 +680,14 @@ function OpenCommandPaletteDialog(props: {
);
},
}),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
[
activeDraftThread,
activeThread,
defaultProjectRef,
handleNewThread,
projects,
requestSidebarProjectReveal,
],
);

const allThreadItems = useMemo(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1110,6 +1152,7 @@ function OpenCommandPaletteDialog(props: {
return;
}

requestSidebarProjectReveal(scopeProjectRef(browseEnvironmentId, projectId));
const navigationResult = await settlePromise(() =>
handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)),
);
Expand All @@ -1134,6 +1177,7 @@ function OpenCommandPaletteDialog(props: {
createProject,
navigate,
projects,
requestSidebarProjectReveal,
setOpen,
clientSettings.sidebarThreadSortOrder,
threads,
Expand Down
89 changes: 87 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -217,6 +221,10 @@ import {
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
import {
resolveSidebarProjectRevealKey,
scrollSidebarProjectIntoView,
} from "../sidebarProjectReveal";
const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
Expand Down Expand Up @@ -1065,6 +1073,8 @@ interface SidebarProjectItemProps {
suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
isManualProjectSorting: boolean;
dragHandleProps: SortableProjectHandleProps | null;
projectRevealRequestId: number | null;
completeProjectReveal: (requestId: number) => void;
}

const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
Expand All @@ -1085,7 +1095,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
suppressProjectClickForContextMenuRef,
isManualProjectSorting,
dragHandleProps,
projectRevealRequestId,
completeProjectReveal,
} = props;
const projectHeaderRef = useRef<HTMLDivElement | null>(null);
const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
(settings) => settings.sidebarThreadSortOrder,
);
Expand Down Expand Up @@ -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]);
Comment thread
cursor[bot] marked this conversation as resolved.
const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
const sidebarThreadByKey = useMemo(
() =>
Expand Down Expand Up @@ -2186,7 +2240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec

return (
<>
<div className="group/project-header relative">
<div ref={projectHeaderRef} className="group/project-header relative">
<SidebarMenuButton
ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
size="sm"
Expand Down Expand Up @@ -2773,6 +2827,8 @@ interface SidebarProjectsContentProps {
suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
projectsLength: number;
projectRevealTarget: { readonly projectKey: string; readonly requestId: number } | null;
completeProjectReveal: (requestId: number) => void;
}

const SidebarProjectsContent = memo(function SidebarProjectsContent(
Expand Down Expand Up @@ -2814,6 +2870,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
suppressProjectClickForContextMenuRef,
attachProjectListAutoAnimateRef,
projectsLength,
projectRevealTarget,
completeProjectReveal,
} = props;

const handleProjectSortOrderChange = useCallback(
Expand Down Expand Up @@ -2962,6 +3020,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
}
isManualProjectSorting={isManualProjectSorting}
dragHandleProps={dragHandleProps}
projectRevealRequestId={
projectRevealTarget?.projectKey === project.projectKey
? projectRevealTarget.requestId
: null
}
completeProjectReveal={completeProjectReveal}
/>
)}
</SortableProjectItem>
Expand Down Expand Up @@ -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}
/>
))}
</SidebarMenu>
Expand Down Expand Up @@ -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<string>
>(() => new Set());
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reveal requests never complete

Medium Severity

The project reveal mechanism can leave requests pending indefinitely. This happens if the target project isn't found, or if the sidebar component responsible for completing the reveal unmounts before it can execute.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6693a8. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mobile reveal stays hidden

Medium Severity

Command palette selections call requestSidebarProjectReveal, but nothing opens the mobile sidebar sheet. On mobile the thread sidebar lives in a closed Sheet with default keepMounted={false}, so project rows are often unmounted and the new scroll-into-view logic cannot run or be seen until the user manually opens the drawer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d6693a8. Configure here.

const sidebarThreadByKey = useMemo(
() =>
new Map(
Expand Down Expand Up @@ -3629,6 +3712,8 @@ export default function Sidebar() {
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
projectsLength={projects.length}
projectRevealTarget={projectRevealTarget}
completeProjectReveal={completeSidebarProjectReveal}
/>

<SidebarSeparator />
Expand Down
Loading
Loading