feat(ui): add session history navigation, permission keys, and review shortcuts

mod+alt+arrows step through this window's session-open history (or between
neighbouring tabs when session tabs are on), mod+k r renames the current
session inline, and mod+k a toggles permission auto-accept. Pending
permission cards respond to alt+enter / alt+shift+enter / alt+backspace with
the keys printed on the buttons. The commit message box commits on
mod+enter, alt+arrows step the diff review between changed files, and the
command palette gains search-only commands for rare actions so the initial
list stays short.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 15:46:44 +03:00
parent 7977842e1e
commit f2ec9b1003
36 changed files with 474 additions and 2 deletions
@@ -77,6 +77,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
import { togglePermissionAutoAccept } from './permissionAutoAccept';
import { useKeybind } from '@/hooks/useKeybind';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -2562,6 +2563,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
t,
]);
useKeybind('toggle_permission_auto_accept', () => {
if (!isPermissionAutoAcceptInteractive) return false;
handlePermissionAutoAcceptToggle();
});
React.useEffect(() => {
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
// Newest pending card owns the keyboard; older cards wait their turn.
const activePermissionCardIds: string[] = [];
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
@@ -126,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
}
};
const handleResponseRef = React.useRef(handleResponse);
handleResponseRef.current = handleResponse;
React.useEffect(() => {
if (hasResponded) return;
activePermissionCardIds.push(permission.id);
const handleKeyDown = (event: KeyboardEvent) => {
if (activePermissionCardIds.at(-1) !== permission.id) return;
if (!event.altKey || event.metaKey || event.ctrlKey) return;
const response = event.key === 'Enter'
? (event.shiftKey ? 'always' as const : 'once' as const)
: event.key === 'Backspace' && !event.shiftKey
? 'reject' as const
: null;
if (!response) return;
event.preventDefault();
event.stopPropagation();
void handleResponseRef.current(response);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
const index = activePermissionCardIds.lastIndexOf(permission.id);
if (index !== -1) activePermissionCardIds.splice(index, 1);
};
}, [hasResponded, permission.id]);
if (hasResponded) {
return null;
}
@@ -380,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Allow Once
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
</button>
{permission.always.length > 0 ? (
@@ -436,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Always Allow
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
</button>
)}
@@ -459,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Deny
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
</button>
{isResponding && (
@@ -1463,6 +1463,10 @@ export const Header: React.FC = () => {
useKeybinds({
rename_current_session: () => {
if (!currentSessionId || isMobile) return false;
beginHeaderSessionRename();
},
toggle_services_menu: () => {
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
@@ -50,6 +50,7 @@ import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import { truncatePathMiddle } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
@@ -59,6 +60,9 @@ type CommandEntry = {
icon: React.ReactNode;
shortcutId?: string;
searchText: string;
/** Search-only command: reachable by typing, hidden from the initial list
so the first screen stays scroll-free. */
secondary?: boolean;
onSelect: () => void;
};
@@ -90,9 +94,14 @@ export const CommandPalette: React.FC = () => {
const openContextSurface = useUIStore((s) => s.openContextSurface);
const openContextFile = useUIStore((s) => s.openContextFile);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const openMultiRunLauncher = useUIStore((s) => s.openMultiRunLauncher);
const setArchivePageOpen = useUIStore((s) => s.setArchivePageOpen);
const setProjectContextTab = useUIStore((s) => s.setProjectContextTab);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const togglePinnedSession = useSessionPinnedStore((s) => s.toggle);
const activeSessions = useGlobalSessionsStore(React.useCallback(
(state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS,
@@ -233,6 +242,7 @@ export const CommandPalette: React.FC = () => {
},
{
id: 'cycle-theme',
secondary: true,
title: t('commandPalette.item.cycleTheme'),
icon: <Icon name="palette" className="mr-2 h-4 w-4" />,
shortcutId: 'cycle_theme',
@@ -243,6 +253,7 @@ export const CommandPalette: React.FC = () => {
},
{
id: 'open-status',
secondary: true,
title: t('commandPalette.item.showOpenCodeStatus'),
icon: <Icon name="pulse" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.showOpenCodeStatus'),
@@ -259,8 +270,90 @@ export const CommandPalette: React.FC = () => {
onSelect: run(() => setSettingsDialogOpen(true)),
},
];
list.push(
{
id: 'pin-session',
secondary: true,
title: t('commandPalette.item.pinSession'),
icon: <Icon name="pushpin" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.pinSession'),
onSelect: run(() => {
if (currentSessionId && currentDirectory) {
togglePinnedSession({ directory: currentDirectory, sessionId: currentSessionId });
}
}),
},
{
id: 'copy-session-id',
secondary: true,
title: t('commandPalette.item.copySessionId'),
icon: <Icon name="file-copy" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.copySessionId'),
onSelect: run(() => {
if (!currentSessionId) return;
void copyTextToClipboard(currentSessionId)
.then((result) => {
if (result.ok) {
toast.success(t('sessions.sidebar.session.copyId.success'));
return;
}
toast.error(t('sessions.sidebar.session.copyId.error'));
})
.catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
}),
},
{
id: 'open-multi-run',
secondary: true,
title: t('commandPalette.item.openMultiRun'),
icon: <Icon name="checkbox-multiple" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openMultiRun'),
onSelect: run(() => {
setSessionSwitcherOpen(false);
openMultiRunLauncher();
}),
},
{
id: 'open-archive',
secondary: true,
title: t('commandPalette.item.openArchive'),
icon: <Icon name="archive" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openArchive'),
onSelect: run(() => {
setSessionSwitcherOpen(false);
setArchivePageOpen(true);
}),
},
{
id: 'open-notes',
secondary: true,
title: t('commandPalette.item.openNotes'),
icon: <Icon name="sticky-note" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openNotes'),
onSelect: run(() => {
if (currentDirectory) {
setProjectContextTab('notes');
openContextSurface(currentDirectory, 'notes');
}
}),
},
{
id: 'open-todos',
secondary: true,
title: t('commandPalette.item.openTodos'),
icon: <Icon name="checkbox-circle" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.openTodos'),
onSelect: run(() => {
if (currentDirectory) {
setProjectContextTab('todos');
openContextSurface(currentDirectory, 'notes');
}
}),
},
);
list.push({
id: 'toggle-memory-debug',
secondary: true,
title: t('commandPalette.item.toggleMemoryDebug'),
icon: <Icon name="bug" className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.toggleMemoryDebug'),
@@ -299,6 +392,11 @@ export const CommandPalette: React.FC = () => {
setSettingsDialogOpen,
activeProject?.id,
activeProject?.path,
currentSessionId,
togglePinnedSession,
openMultiRunLauncher,
setArchivePageOpen,
setProjectContextTab,
]);
// ---------------------------------------------------------------------------
@@ -407,7 +505,9 @@ export const CommandPalette: React.FC = () => {
const hasQuery = liveTrimmed.length > 0;
const scoredCommands = React.useMemo(() => {
if (!hasQuery) return commands.map((item) => ({ item, score: 0 }));
if (!hasQuery) {
return commands.filter((item) => !item.secondary).map((item) => ({ item, score: 0 }));
}
return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, {
limit: 7,
noFuzzy: true,
@@ -1768,6 +1768,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
scrollToFile(value);
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
// Step review to the adjacent changed file (alt+arrow): selects, expands
// a collapsed section, and scrolls to it. Window-level because the diff
// surface has no persistent focus target; guarded off editable fields.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const target = event.target;
if (target instanceof HTMLElement && (
target.isContentEditable
|| target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.closest('[role="dialog"]')
)) {
return;
}
if (changedFiles.length === 0) return;
const delta = event.key === 'ArrowDown' ? 1 : -1;
const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1;
const nextIndex = index === -1
? (delta > 0 ? 0 : changedFiles.length - 1)
: index + delta;
const next = changedFiles[nextIndex];
if (!next) return;
event.preventDefault();
handleSelectFileAndScroll(next.path);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [changedFiles, displayFile, handleSelectFileAndScroll]);
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
@@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n';
interface CommitInputProps {
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
placeholder?: string;
disabled?: boolean;
hasTouchInput?: boolean;
@@ -18,6 +19,7 @@ const MAX_HEIGHT = 200;
export const CommitInput: React.FC<CommitInputProps> = ({
value,
onChange,
onSubmit,
placeholder,
disabled = false,
hasTouchInput = false,
@@ -58,6 +60,12 @@ export const CommitInput: React.FC<CommitInputProps> = ({
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
onSubmit?.();
}
}}
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
rows={1}
disabled={disabled}
@@ -68,6 +68,9 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
onSubmit={() => {
if (canCommit && !isGeneratingMessage) onCommit();
}}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}