feat(chat): desktop prompt navigator rail (#2054)

* feat(chat): add desktop prompt navigator rail

Add a ChatGPT-style right-center prompt marker rail for web/desktop chat
with hover/keyboard preview panel, load-more for partial history (panel only),
Chat setting, and mod+alt+p shortcut. Disabled in VS Code across rail,
shortcut, settings, help, and search surfaces.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(ui): read promptNavigatorEnabled from getState in shortcut handler

Match the file convention used by other shortcut handlers so the toggle
does not rely on a hook-level selector closure.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(ui): drop use-no-memo and default prompt navigator off

Remove the project-unprecedented React Compiler opt-out, and ship the
prompt navigator as opt-in to match other recent chat UI toggles.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-13 09:02:14 +03:00
committed by GitHub
co-authored by Serhii Dziupin Cursor Agent
parent 502c96630e
commit 1fb448d827
36 changed files with 754 additions and 21 deletions
@@ -16,6 +16,7 @@ import { QuestionCard } from './QuestionCard';
import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useChatTimelineController } from './hooks/useChatTimelineController';
@@ -162,6 +163,13 @@ type ChatViewportProps = {
isProgrammaticFollowActive: boolean;
showLoadOlderButton: boolean;
onLoadOlder: () => void;
turnIds: string[];
activeTurnId: string | null;
onSelectTurn: (turnId: string) => void;
showPromptNavigator: boolean;
canLoadEarlierPrompts: boolean;
isLoadingOlderPrompts: boolean;
onLoadEarlierPrompts: () => void;
};
const ChatViewport = React.memo(({
@@ -188,8 +196,40 @@ const ChatViewport = React.memo(({
isProgrammaticFollowActive,
showLoadOlderButton,
onLoadOlder,
turnIds,
activeTurnId,
onSelectTurn,
showPromptNavigator,
canLoadEarlierPrompts,
isLoadingOlderPrompts,
onLoadEarlierPrompts,
}: ChatViewportProps) => {
const { t } = useI18n();
const promptPreviewsByTurnIdRef = React.useRef<Map<string, Part[]>>(new Map());
const promptPreviewsByTurnId = React.useMemo(() => {
const next = new Map<string, Part[]>();
for (const message of renderedMessages) {
if (message.info.role !== 'user') {
continue;
}
next.set(message.info.id, message.parts);
}
const prev = promptPreviewsByTurnIdRef.current;
if (prev.size === next.size) {
let unchanged = true;
for (const [id, parts] of next) {
if (prev.get(id) !== parts) {
unchanged = false;
break;
}
}
if (unchanged) {
return prev;
}
}
promptPreviewsByTurnIdRef.current = next;
return next;
}, [renderedMessages]);
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
return;
@@ -278,6 +318,17 @@ const ChatViewport = React.memo(({
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator ? (
<PromptNavigatorRail
turnIds={turnIds}
previewsByTurnId={promptPreviewsByTurnId}
activeTurnId={activeTurnId}
onSelectTurn={onSelectTurn}
canLoadEarlier={canLoadEarlierPrompts}
isLoadingOlder={isLoadingOlderPrompts}
onLoadEarlier={onLoadEarlierPrompts}
/>
) : null}
</div>
</div>
);
@@ -304,7 +355,14 @@ const ChatViewport = React.memo(({
&& prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
&& prev.showLoadOlderButton === next.showLoadOlderButton
&& prev.onLoadOlder === next.onLoadOlder;
&& prev.onLoadOlder === next.onLoadOlder
&& prev.turnIds === next.turnIds
&& prev.activeTurnId === next.activeTurnId
&& prev.onSelectTurn === next.onSelectTurn
&& prev.showPromptNavigator === next.showPromptNavigator
&& prev.canLoadEarlierPrompts === next.canLoadEarlierPrompts
&& prev.isLoadingOlderPrompts === next.isLoadingOlderPrompts
&& prev.onLoadEarlierPrompts === next.onLoadEarlierPrompts;
});
ChatViewport.displayName = 'ChatViewport';
@@ -405,6 +463,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// UI store
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -706,6 +765,21 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
scrollToMessage: timelineController.scrollToMessage,
resumeToBottom: timelineController.resumeToBottomInstant,
});
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
}, [navigation]);
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
const showPromptNavigator = !isMobile
&& !isVSCode
&& !isDesktopExpandedInput
&& promptNavigatorEnabled
&& timelineController.turnIds.length >= 2;
React.useEffect(() => {
if (!showPromptNavigator) {
useUIStore.getState().setPromptNavigatorPanelOpen(false);
}
}, [showPromptNavigator]);
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) return;
@@ -1012,6 +1086,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
isProgrammaticFollowActive={isFollowingProgrammatically}
showLoadOlderButton={showLoadOlderButton}
onLoadOlder={handleLoadOlderClick}
turnIds={timelineController.turnIds}
activeTurnId={timelineController.activeTurnId}
onSelectTurn={handlePromptNavigatorSelect}
showPromptNavigator={showPromptNavigator}
canLoadEarlierPrompts={canLoadEarlierPrompts}
isLoadingOlderPrompts={timelineController.isLoadingOlder}
onLoadEarlierPrompts={handleLoadOlderClick}
/>
<div