Skip to content

Commit 0d22c57

Browse files
feat(chat): desktop prompt navigator rail (openchamber#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>
1 parent 484325a commit 0d22c57

36 files changed

Lines changed: 754 additions & 21 deletions

packages/ui/src/components/chat/ChatContainer.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { QuestionCard } from './QuestionCard';
1616
import { StatusRowContainer } from './StatusRowContainer';
1717
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
1818
import ScrollToBottomButton from './components/ScrollToBottomButton';
19+
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
1920
import { ScrollShadow } from '@/components/ui/ScrollShadow';
2021
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
2122
import { useChatTimelineController } from './hooks/useChatTimelineController';
@@ -162,6 +163,13 @@ type ChatViewportProps = {
162163
isProgrammaticFollowActive: boolean;
163164
showLoadOlderButton: boolean;
164165
onLoadOlder: () => void;
166+
turnIds: string[];
167+
activeTurnId: string | null;
168+
onSelectTurn: (turnId: string) => void;
169+
showPromptNavigator: boolean;
170+
canLoadEarlierPrompts: boolean;
171+
isLoadingOlderPrompts: boolean;
172+
onLoadEarlierPrompts: () => void;
165173
};
166174

167175
const ChatViewport = React.memo(({
@@ -188,8 +196,40 @@ const ChatViewport = React.memo(({
188196
isProgrammaticFollowActive,
189197
showLoadOlderButton,
190198
onLoadOlder,
199+
turnIds,
200+
activeTurnId,
201+
onSelectTurn,
202+
showPromptNavigator,
203+
canLoadEarlierPrompts,
204+
isLoadingOlderPrompts,
205+
onLoadEarlierPrompts,
191206
}: ChatViewportProps) => {
192207
const { t } = useI18n();
208+
const promptPreviewsByTurnIdRef = React.useRef<Map<string, Part[]>>(new Map());
209+
const promptPreviewsByTurnId = React.useMemo(() => {
210+
const next = new Map<string, Part[]>();
211+
for (const message of renderedMessages) {
212+
if (message.info.role !== 'user') {
213+
continue;
214+
}
215+
next.set(message.info.id, message.parts);
216+
}
217+
const prev = promptPreviewsByTurnIdRef.current;
218+
if (prev.size === next.size) {
219+
let unchanged = true;
220+
for (const [id, parts] of next) {
221+
if (prev.get(id) !== parts) {
222+
unchanged = false;
223+
break;
224+
}
225+
}
226+
if (unchanged) {
227+
return prev;
228+
}
229+
}
230+
promptPreviewsByTurnIdRef.current = next;
231+
return next;
232+
}, [renderedMessages]);
193233
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
194234
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
195235
return;
@@ -278,6 +318,17 @@ const ChatViewport = React.memo(({
278318
</div>
279319
</ScrollShadow>
280320
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
321+
{showPromptNavigator ? (
322+
<PromptNavigatorRail
323+
turnIds={turnIds}
324+
previewsByTurnId={promptPreviewsByTurnId}
325+
activeTurnId={activeTurnId}
326+
onSelectTurn={onSelectTurn}
327+
canLoadEarlier={canLoadEarlierPrompts}
328+
isLoadingOlder={isLoadingOlderPrompts}
329+
onLoadEarlier={onLoadEarlierPrompts}
330+
/>
331+
) : null}
281332
</div>
282333
</div>
283334
);
@@ -304,7 +355,14 @@ const ChatViewport = React.memo(({
304355
&& prev.sessionPermissions === next.sessionPermissions
305356
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
306357
&& prev.showLoadOlderButton === next.showLoadOlderButton
307-
&& prev.onLoadOlder === next.onLoadOlder;
358+
&& prev.onLoadOlder === next.onLoadOlder
359+
&& prev.turnIds === next.turnIds
360+
&& prev.activeTurnId === next.activeTurnId
361+
&& prev.onSelectTurn === next.onSelectTurn
362+
&& prev.showPromptNavigator === next.showPromptNavigator
363+
&& prev.canLoadEarlierPrompts === next.canLoadEarlierPrompts
364+
&& prev.isLoadingOlderPrompts === next.isLoadingOlderPrompts
365+
&& prev.onLoadEarlierPrompts === next.onLoadEarlierPrompts;
308366
});
309367

310368
ChatViewport.displayName = 'ChatViewport';
@@ -405,6 +463,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
405463
// UI store
406464
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
407465
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
466+
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
408467
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
409468
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
410469
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -706,6 +765,21 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
706765
scrollToMessage: timelineController.scrollToMessage,
707766
resumeToBottom: timelineController.resumeToBottomInstant,
708767
});
768+
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
769+
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
770+
}, [navigation]);
771+
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
772+
const showPromptNavigator = !isMobile
773+
&& !isVSCode
774+
&& !isDesktopExpandedInput
775+
&& promptNavigatorEnabled
776+
&& timelineController.turnIds.length >= 2;
777+
778+
React.useEffect(() => {
779+
if (!showPromptNavigator) {
780+
useUIStore.getState().setPromptNavigatorPanelOpen(false);
781+
}
782+
}, [showPromptNavigator]);
709783

710784
React.useEffect(() => {
711785
if (typeof window === 'undefined' || !currentSessionId) return;
@@ -1012,6 +1086,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
10121086
isProgrammaticFollowActive={isFollowingProgrammatically}
10131087
showLoadOlderButton={showLoadOlderButton}
10141088
onLoadOlder={handleLoadOlderClick}
1089+
turnIds={timelineController.turnIds}
1090+
activeTurnId={timelineController.activeTurnId}
1091+
onSelectTurn={handlePromptNavigatorSelect}
1092+
showPromptNavigator={showPromptNavigator}
1093+
canLoadEarlierPrompts={canLoadEarlierPrompts}
1094+
isLoadingOlderPrompts={timelineController.isLoadingOlder}
1095+
onLoadEarlierPrompts={handleLoadOlderClick}
10151096
/>
10161097

10171098
<div

packages/ui/src/components/chat/TimelineDialog.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
1212
import { useSessionMessageRecords } from '@/sync/sync-context';
1313
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
1414
import { Icon } from "@/components/icon/Icon";
15-
import type { Part } from '@opencode-ai/sdk/v2';
1615
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
16+
import { getFullText, getMessagePreview } from './lib/messagePreview';
1717
import { useDeviceInfo } from '@/lib/device';
1818
import { cn } from '@/lib/utils';
1919

@@ -417,19 +417,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
417417
);
418418
};
419419

420-
function getFullText(parts: Part[]): string {
421-
return parts
422-
.filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string')
423-
.map((p) => p.text)
424-
.join('\n');
425-
}
426-
427-
function getMessagePreview(parts: Part[]): string {
428-
const full = getFullText(parts);
429-
const singleLine = full.replace(/\n/g, ' ');
430-
return singleLine.length > 80 ? singleLine.slice(0, 80) : singleLine;
431-
}
432-
433420
function getSearchSnippet(text: string, query: string, contextChars: number = 30): string | null {
434421
const lowerText = text.toLowerCase();
435422
const lowerQuery = query.toLowerCase();

0 commit comments

Comments
 (0)