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:
committed by
GitHub
co-authored by
Serhii Dziupin
Cursor Agent
parent
502c96630e
commit
1fb448d827
@@ -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
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { getFullText, getMessagePreview } from './lib/messagePreview';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -417,19 +417,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function getFullText(parts: Part[]): string {
|
||||
return parts
|
||||
.filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string')
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function getMessagePreview(parts: Part[]): string {
|
||||
const full = getFullText(parts);
|
||||
const singleLine = full.replace(/\n/g, ' ');
|
||||
return singleLine.length > 80 ? singleLine.slice(0, 80) : singleLine;
|
||||
}
|
||||
|
||||
function getSearchSnippet(text: string, query: string, contextChars: number = 30): string | null {
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getMessagePreview } from '../lib/messagePreview';
|
||||
|
||||
type PromptEntry = {
|
||||
turnId: string;
|
||||
preview: string;
|
||||
};
|
||||
|
||||
type PromptNavigatorRailProps = {
|
||||
turnIds: string[];
|
||||
previewsByTurnId: Map<string, Part[]>;
|
||||
activeTurnId: string | null;
|
||||
onSelectTurn: (turnId: string) => void;
|
||||
canLoadEarlier: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadEarlier: () => void;
|
||||
};
|
||||
|
||||
const LINE_HIT_HEIGHT_PX = 8;
|
||||
const HOVER_CLOSE_DELAY_MS = 120;
|
||||
const COMPACT_BACKDROP_MAX_WIDTH_PX = 1280;
|
||||
|
||||
const buildPromptEntries = (
|
||||
turnIds: string[],
|
||||
previewsByTurnId: Map<string, Part[]>,
|
||||
): PromptEntry[] => {
|
||||
return turnIds.map((turnId) => {
|
||||
const parts = previewsByTurnId.get(turnId) ?? [];
|
||||
return {
|
||||
turnId,
|
||||
preview: getMessagePreview(parts, 120),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveLineGapClass = (count: number): string => {
|
||||
if (count > 24) {
|
||||
return 'gap-px';
|
||||
}
|
||||
if (count > 12) {
|
||||
return 'gap-0.5';
|
||||
}
|
||||
return 'gap-1';
|
||||
};
|
||||
|
||||
type LineRailProps = {
|
||||
prompts: PromptEntry[];
|
||||
activeTurnId: string | null;
|
||||
lineGapClass: string;
|
||||
needsBackdrop: boolean;
|
||||
emptyPreviewLabel: string;
|
||||
onSelectTurn: (turnId: string) => void;
|
||||
};
|
||||
|
||||
/** Compact marker stack only — never renders load-more. Markers stay out of tab order. */
|
||||
function LineRail({
|
||||
prompts,
|
||||
activeTurnId,
|
||||
lineGapClass,
|
||||
needsBackdrop,
|
||||
emptyPreviewLabel,
|
||||
onSelectTurn,
|
||||
}: LineRailProps) {
|
||||
const activeButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
activeButtonRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeTurnId, prompts.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-full px-1 py-1.5',
|
||||
needsBackdrop
|
||||
? 'border border-[var(--interactive-border)]/40 bg-[var(--surface-background)]/90 shadow-sm backdrop-blur-sm'
|
||||
: 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-h-[40vh] min-h-0 flex-col items-center overflow-y-auto overflow-x-hidden',
|
||||
lineGapClass,
|
||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
)}
|
||||
>
|
||||
{prompts.map((prompt) => {
|
||||
const isActive = prompt.turnId === activeTurnId;
|
||||
const preview = prompt.preview.trim() || emptyPreviewLabel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={prompt.turnId}
|
||||
ref={isActive ? activeButtonRef : undefined}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-full',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focusRing)]',
|
||||
)}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: `${LINE_HIT_HEIGHT_PX}px`,
|
||||
}}
|
||||
aria-label={preview}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
onSelectTurn(prompt.turnId);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'block h-0.5 rounded-full transition-colors',
|
||||
isActive
|
||||
? 'w-3.5 bg-[var(--surface-foreground)]'
|
||||
: 'w-3 bg-[var(--surface-foreground)]/40',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PromptMenuPanelProps = {
|
||||
prompts: PromptEntry[];
|
||||
activeTurnId: string | null;
|
||||
canLoadEarlier: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
emptyPreviewLabel: string;
|
||||
currentPromptLabel: string;
|
||||
loadMoreLabel: string;
|
||||
onSelectTurn: (turnId: string) => void;
|
||||
onLoadEarlier: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
focusOnMount: boolean;
|
||||
};
|
||||
|
||||
/** Hover/keyboard menu — the only place load-more is allowed. */
|
||||
function PromptMenuPanel({
|
||||
prompts,
|
||||
activeTurnId,
|
||||
canLoadEarlier,
|
||||
isLoadingOlder,
|
||||
emptyPreviewLabel,
|
||||
currentPromptLabel,
|
||||
loadMoreLabel,
|
||||
onSelectTurn,
|
||||
onLoadEarlier,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
focusOnMount,
|
||||
}: PromptMenuPanelProps) {
|
||||
const activeItemRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focusOnMount) {
|
||||
return;
|
||||
}
|
||||
activeItemRef.current?.focus();
|
||||
}, [focusOnMount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-full top-1/2 z-30 mr-3 w-[min(18rem,calc(100vw-5rem))] -translate-y-1/2',
|
||||
'rounded-xl border border-[var(--interactive-border)]/60 bg-[var(--surface-elevated)] p-1 shadow-md',
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<ul className="max-h-[min(24rem,70vh)] overflow-y-auto">
|
||||
{canLoadEarlier ? (
|
||||
<li className="border-b border-[var(--interactive-border)]/40 px-1 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-lg px-2.5 py-2',
|
||||
'typography-meta text-[var(--surface-mutedForeground)] transition-colors',
|
||||
'hover:bg-[var(--interactive-hover)]/60 hover:text-[var(--surface-foreground)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focusRing)]',
|
||||
isLoadingOlder ? 'cursor-wait opacity-70' : undefined,
|
||||
)}
|
||||
disabled={isLoadingOlder}
|
||||
onClick={onLoadEarlier}
|
||||
>
|
||||
{isLoadingOlder ? (
|
||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-up-s" className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span>{loadMoreLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{prompts.map((prompt) => {
|
||||
const isActive = prompt.turnId === activeTurnId;
|
||||
const preview = prompt.preview.trim() || emptyPreviewLabel;
|
||||
|
||||
return (
|
||||
<li key={prompt.turnId}>
|
||||
<button
|
||||
ref={isActive ? activeItemRef : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-start rounded-lg px-2.5 py-2 text-left transition-colors',
|
||||
'hover:bg-[var(--interactive-hover)]/60',
|
||||
isActive
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
|
||||
: 'text-[var(--surface-foreground)]',
|
||||
)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
onSelectTurn(prompt.turnId);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="typography-meta line-clamp-2">{preview}</span>
|
||||
{isActive ? (
|
||||
<span className="mt-0.5 block typography-micro text-[var(--interactive-selection-foreground)]/80">
|
||||
{currentPromptLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptNavigatorRail({
|
||||
turnIds,
|
||||
previewsByTurnId,
|
||||
activeTurnId,
|
||||
onSelectTurn,
|
||||
canLoadEarlier,
|
||||
isLoadingOlder,
|
||||
onLoadEarlier,
|
||||
}: PromptNavigatorRailProps) {
|
||||
const { t } = useI18n();
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const isPanelOpen = useUIStore((state) => state.isPromptNavigatorPanelOpen);
|
||||
const setPromptNavigatorPanelOpen = useUIStore((state) => state.setPromptNavigatorPanelOpen);
|
||||
const closeTimeoutRef = React.useRef<number | null>(null);
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const openedByPointerRef = React.useRef(false);
|
||||
const [focusActiveOnOpen, setFocusActiveOnOpen] = React.useState(false);
|
||||
|
||||
const prompts = React.useMemo(
|
||||
() => buildPromptEntries(turnIds, previewsByTurnId),
|
||||
[previewsByTurnId, turnIds],
|
||||
);
|
||||
|
||||
const needsBackdrop = screenWidth < COMPACT_BACKDROP_MAX_WIDTH_PX;
|
||||
const lineGapClass = resolveLineGapClass(prompts.length);
|
||||
const emptyPreviewLabel = t('chat.timeline.noTextContent');
|
||||
const currentPromptLabel = t('chat.promptNavigator.currentPrompt');
|
||||
const loadMoreLabel = t('chat.promptNavigator.loadMore');
|
||||
|
||||
const clearCloseTimeout = React.useCallback(() => {
|
||||
if (closeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openPanel = React.useCallback(() => {
|
||||
clearCloseTimeout();
|
||||
openedByPointerRef.current = true;
|
||||
setFocusActiveOnOpen(false);
|
||||
setPromptNavigatorPanelOpen(true);
|
||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
||||
|
||||
const scheduleClosePanel = React.useCallback(() => {
|
||||
clearCloseTimeout();
|
||||
closeTimeoutRef.current = window.setTimeout(() => {
|
||||
// Keep keyboard-opened panel alive while focus is still inside the rail.
|
||||
if (rootRef.current?.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
openedByPointerRef.current = false;
|
||||
setFocusActiveOnOpen(false);
|
||||
setPromptNavigatorPanelOpen(false);
|
||||
}, HOVER_CLOSE_DELAY_MS);
|
||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
clearCloseTimeout();
|
||||
setPromptNavigatorPanelOpen(false);
|
||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
||||
|
||||
// Keyboard shortcut flips the store open with focus outside the rail.
|
||||
// Pointer open sets openedByPointerRef so we don't steal focus on hover.
|
||||
React.useEffect(() => {
|
||||
if (!isPanelOpen) {
|
||||
setFocusActiveOnOpen(false);
|
||||
return;
|
||||
}
|
||||
if (openedByPointerRef.current) {
|
||||
openedByPointerRef.current = false;
|
||||
setFocusActiveOnOpen(false);
|
||||
return;
|
||||
}
|
||||
setFocusActiveOnOpen(true);
|
||||
}, [isPanelOpen]);
|
||||
|
||||
const handleSelectPrompt = React.useCallback((turnId: string) => {
|
||||
onSelectTurn(turnId);
|
||||
openedByPointerRef.current = false;
|
||||
setFocusActiveOnOpen(false);
|
||||
setPromptNavigatorPanelOpen(false);
|
||||
}, [onSelectTurn, setPromptNavigatorPanelOpen]);
|
||||
|
||||
const handleLoadEarlier = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isLoadingOlder) {
|
||||
return;
|
||||
}
|
||||
onLoadEarlier();
|
||||
}, [isLoadingOlder, onLoadEarlier]);
|
||||
|
||||
const handleWrapperBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
||||
const next = event.relatedTarget;
|
||||
if (next instanceof Node && event.currentTarget.contains(next)) {
|
||||
return;
|
||||
}
|
||||
scheduleClosePanel();
|
||||
}, [scheduleClosePanel]);
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={rootRef}
|
||||
aria-label={t('chat.promptNavigator.aria')}
|
||||
className="pointer-events-none absolute right-3 top-1/2 z-20 -translate-y-1/2"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative"
|
||||
onMouseEnter={openPanel}
|
||||
onMouseLeave={scheduleClosePanel}
|
||||
onFocus={openPanel}
|
||||
onBlur={handleWrapperBlur}
|
||||
>
|
||||
<LineRail
|
||||
prompts={prompts}
|
||||
activeTurnId={activeTurnId}
|
||||
lineGapClass={lineGapClass}
|
||||
needsBackdrop={needsBackdrop}
|
||||
emptyPreviewLabel={emptyPreviewLabel}
|
||||
onSelectTurn={handleSelectPrompt}
|
||||
/>
|
||||
|
||||
{isPanelOpen ? (
|
||||
<PromptMenuPanel
|
||||
prompts={prompts}
|
||||
activeTurnId={activeTurnId}
|
||||
canLoadEarlier={canLoadEarlier}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
emptyPreviewLabel={emptyPreviewLabel}
|
||||
currentPromptLabel={currentPromptLabel}
|
||||
loadMoreLabel={loadMoreLabel}
|
||||
onSelectTurn={handleSelectPrompt}
|
||||
onLoadEarlier={handleLoadEarlier}
|
||||
onMouseEnter={openPanel}
|
||||
onMouseLeave={scheduleClosePanel}
|
||||
focusOnMount={focusActiveOnOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { Part } from '@opencode-ai/sdk/v2'
|
||||
import { getFullText, getMessagePreview } from './messagePreview'
|
||||
|
||||
const textPart = (text: string): Part => ({ type: 'text', text } as Part)
|
||||
|
||||
describe('messagePreview', () => {
|
||||
test('joins text parts for full text', () => {
|
||||
expect(getFullText([textPart('hello'), textPart('world')])).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
test('collapses newlines and truncates previews', () => {
|
||||
expect(getMessagePreview([textPart('line one\nline two')], 80)).toBe('line one line two')
|
||||
expect(getMessagePreview([textPart('abcdefghijklmnopqrstuvwxyz')], 10)).toBe('abcdefghij…')
|
||||
})
|
||||
|
||||
test('returns empty string when there is no text', () => {
|
||||
expect(getMessagePreview([])).toBe('')
|
||||
expect(getFullText([{ type: 'file' } as Part])).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export function getFullText(parts: Part[]): string {
|
||||
return parts
|
||||
.filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string')
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function getMessagePreview(parts: Part[], maxLength = 80): string {
|
||||
const full = getFullText(parts);
|
||||
const singleLine = full.replace(/\n/g, ' ');
|
||||
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}…` : singleLine;
|
||||
}
|
||||
Reference in New Issue
Block a user