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
@@ -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;
}
@@ -6,6 +6,7 @@ import { Icon } from "@/components/icon/Icon";
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
formatShortcutForDisplay,
getCustomizableShortcutActions,
@@ -54,7 +55,13 @@ export const KeyboardShortcutsSettings: React.FC = () => {
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
const actions = React.useMemo(() => getCustomizableShortcutActions(), []);
const actions = React.useMemo(() => {
const all = getCustomizableShortcutActions();
if (!isVSCodeRuntime()) {
return all;
}
return all.filter((action) => action.id !== 'toggle_prompt_navigator');
}, []);
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
const translated = tUnsafe(key);
@@ -145,7 +145,37 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['sessionGoal', 'sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'codeBlockLineWrap', 'splitAssistantMessageActions', 'subagentReadOnlyBanner', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
const isVSCode = isVSCodeRuntime();
return (
<OpenChamberVisualSettings
visibleSettings={[
'sessionGoal',
'sessionAssist',
'chatRenderMode',
'messageTransport',
'activityRenderMode',
'userMessageRendering',
'mermaidRendering',
'reasoning',
'showToolFileIcons',
'showTurnChangedFiles',
'expandedTools',
'collapsibleUserMessages',
'stickyUserHeader',
...(!isVSCode ? ['promptNavigatorEnabled' as const] : []),
'wideChatLayout',
'codeBlockLineWrap',
'splitAssistantMessageActions',
'subagentReadOnlyBanner',
'diffLayout',
'dotfiles',
'fileViewerPreview',
'followUpBehavior',
'persistDraft',
'inputSpellcheck',
]}
/>
);
};
// Sessions section: Default model & agent, Session retention
@@ -245,7 +245,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -280,7 +280,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const collapsibleUserMessages = useUIStore(state => state.collapsibleUserMessages);
const setCollapsibleUserMessages = useUIStore(state => state.setCollapsibleUserMessages);
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled);
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
@@ -441,6 +443,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ stickyUserHeader: enabled });
}, [setStickyUserHeader]);
const handlePromptNavigatorEnabledChange = React.useCallback((enabled: boolean) => {
setPromptNavigatorEnabled(enabled);
void updateDesktopSettings({ promptNavigatorEnabled: enabled });
}, [setPromptNavigatorEnabled]);
const handleExpandedEditorToolbarChange = React.useCallback((enabled: boolean) => {
setExpandedEditorToolbar(enabled);
void updateDesktopSettings({ expandedEditorToolbar: enabled });
@@ -572,9 +579,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('collapsibleUserMessages')
|| shouldShow('stickyUserHeader')
|| (shouldShow('promptNavigatorEnabled') && !isVSCode)
|| shouldShow('wideChatLayout')
|| shouldShow('codeBlockLineWrap')
|| shouldShow('splitAssistantMessageActions')
|| shouldShow('subagentReadOnlyBanner')
|| shouldShow('diffLayout')
|| shouldShow('dotfiles')
|| shouldShow('fileViewerPreview')
@@ -1901,7 +1910,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('codeBlockLineWrap') || shouldShow('splitAssistantMessageActions') || shouldShow('subagentReadOnlyBanner') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('codeBlockLineWrap') || shouldShow('splitAssistantMessageActions') || shouldShow('subagentReadOnlyBanner') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
<div className="space-y-6">
{(shouldShow('sessionAssist') || shouldShow('subagentReadOnlyBanner')) && (
<section className="p-2 space-y-0.5">
@@ -2029,7 +2038,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<section className="p-2 space-y-0.5">
<h3 data-settings-item="chat.message-appearance" className="typography-ui-header font-medium text-foreground py-1.5">{t('settings.openchamber.visual.section.messageAppearance')}</h3>
{shouldShow('collapsibleUserMessages') && (
@@ -2080,6 +2089,30 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{shouldShow('promptNavigatorEnabled') && !isVSCode && (
<div
data-settings-item="chat.prompt-navigator"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={promptNavigatorEnabled}
onClick={() => handlePromptNavigatorEnabledChange(!promptNavigatorEnabled)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handlePromptNavigatorEnabledChange(!promptNavigatorEnabled);
}
}}
>
<Checkbox
checked={promptNavigatorEnabled}
onChange={handlePromptNavigatorEnabledChange}
ariaLabel={t('settings.openchamber.visual.field.promptNavigatorEnabledAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.promptNavigatorEnabled')}</span>
</div>
)}
{shouldShow('wideChatLayout') && (
<div
data-settings-item="chat.wide-layout"
+11 -1
View File
@@ -15,6 +15,7 @@ import {
formatShortcutForDisplay,
} from "@/lib/shortcuts";
import { useI18n, type I18nKey } from "@/lib/i18n";
import { isVSCodeRuntime } from "@/lib/desktop";
import type { IconName } from "@/components/icon/icons";
type ShortcutItem = {
@@ -40,6 +41,7 @@ export const HelpDialog: React.FC = () => {
const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const mod = getModifierLabel();
const isVSCode = isVSCodeRuntime();
const shortcuts: ShortcutSection[] = [
{
@@ -114,6 +116,12 @@ export const HelpDialog: React.FC = () => {
keys: '',
},
{ id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' },
{
id: 'toggle_prompt_navigator',
descriptionKey: "helpDialog.item.togglePromptNavigator",
icon: "list-unordered",
keys: '',
},
{
id: 'abort_run',
descriptionKey: "helpDialog.item.abortActiveRun",
@@ -226,7 +234,9 @@ export const HelpDialog: React.FC = () => {
{t(section.categoryKey)}
</h3>
<div className="space-y-1">
{section.items.map((shortcut) => {
{section.items
.filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator'))
.map((shortcut) => {
const displayKeys = shortcut.id
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));