mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge 319a4730a2 into 8ed0bcf5ca
This commit is contained in:
commit
d9ab86e98e
7 changed files with 676 additions and 2 deletions
|
|
@ -67,6 +67,7 @@ function ModelSelectorContent() {
|
|||
description={localize('com_ui_select_model')}
|
||||
render={
|
||||
<button
|
||||
data-testid="model-selector-button"
|
||||
className="my-1 flex h-9 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
|
||||
aria-label={localize('com_ui_select_model')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useState, memo, useRef } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { FileText, LogOut } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { FileText, LogOut, Keyboard } from 'lucide-react';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Settings from './Settings';
|
||||
import store from '~/store';
|
||||
|
||||
function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -17,6 +19,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
|
|
@ -82,7 +85,15 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
|
|||
{localize('com_nav_help_faq')}
|
||||
</Menu.MenuItem>
|
||||
)}
|
||||
<Menu.MenuItem onClick={() => setShowSettings(true)} className="select-item text-sm">
|
||||
<Menu.MenuItem onClick={() => setShowShortcutsDialog(true)} className="select-item text-sm">
|
||||
<Keyboard className="icon-md" aria-hidden="true" />
|
||||
{localize('com_shortcut_keyboard_shortcuts')}
|
||||
</Menu.MenuItem>
|
||||
<Menu.MenuItem
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="select-item text-sm"
|
||||
data-testid="nav-settings"
|
||||
>
|
||||
<GearIcon className="icon-md" aria-hidden="true" />
|
||||
{localize('com_nav_settings')}
|
||||
</Menu.MenuItem>
|
||||
|
|
|
|||
148
client/src/components/Nav/KeyboardShortcutsDialog.tsx
Normal file
148
client/src/components/Nav/KeyboardShortcutsDialog.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogClose } from '@librechat/client';
|
||||
import type { ShortcutDefinition } from '~/hooks/useKeyboardShortcuts';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { shortcutDefinitions, isMac } from '~/hooks/useKeyboardShortcuts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type GroupedShortcuts = Record<string, Array<ShortcutDefinition & { id: string }>>;
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex h-[22px] min-w-[22px] items-center justify-center rounded-md border border-border-light bg-surface-secondary px-1.5 text-[11px] font-medium leading-none text-text-secondary shadow-[0_1px_0_0_rgba(0,0,0,0.08)] dark:shadow-none">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyCombo({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((key, idx) => (
|
||||
<Kbd key={`${key}-${idx}`}>{key}</Kbd>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutRow({ label, keys }: { label: string; keys: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-[5px]">
|
||||
<span className="truncate text-[13px] text-text-primary">{label}</span>
|
||||
<KeyCombo keys={keys} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseKeys(display: string): string[] {
|
||||
return display.split(/([+\s]+)/).filter((k) => k.trim().length > 0 && k !== '+');
|
||||
}
|
||||
|
||||
function ShortcutGroup({
|
||||
groupKey,
|
||||
shortcuts,
|
||||
isFirst,
|
||||
}: {
|
||||
groupKey: string;
|
||||
shortcuts: Array<ShortcutDefinition & { id: string }>;
|
||||
isFirst: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className={cn(!isFirst && 'mt-2 border-t border-border-light pt-2')}>
|
||||
<h3 className="mb-0.5 text-[11px] font-medium uppercase tracking-widest text-text-secondary">
|
||||
{localize(groupKey as TranslationKeys)}
|
||||
</h3>
|
||||
{shortcuts.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.id}
|
||||
label={localize(shortcut.labelKey as TranslationKeys)}
|
||||
keys={parseKeys(isMac ? shortcut.displayMac : shortcut.displayOther)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyboardShortcutsDialog() {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useRecoilState(store.showShortcutsDialog);
|
||||
|
||||
const grouped = useMemo<GroupedShortcuts>(() => {
|
||||
const groups: GroupedShortcuts = {};
|
||||
for (const [id, def] of Object.entries(shortcutDefinitions)) {
|
||||
const group = def.groupKey;
|
||||
if (!groups[group]) {
|
||||
groups[group] = [];
|
||||
}
|
||||
groups[group].push({ ...def, id });
|
||||
}
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
const groupEntries = useMemo(() => Object.entries(grouped), [grouped]);
|
||||
|
||||
const leftColumn = useMemo(
|
||||
() => groupEntries.filter(([key]) => key !== 'com_shortcut_group_chat'),
|
||||
[groupEntries],
|
||||
);
|
||||
const rightColumn = useMemo(
|
||||
() => groupEntries.filter(([key]) => key === 'com_shortcut_group_chat'),
|
||||
[groupEntries],
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 max-w-4xl overflow-hidden px-6 py-1"
|
||||
>
|
||||
<div className="flex items-center justify-between pb-0 pt-5">
|
||||
<OGDialogTitle>{localize('com_shortcut_keyboard_shortcuts')}</OGDialogTitle>
|
||||
<OGDialogClose>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 overflow-y-auto pb-4 pt-2">
|
||||
<div>
|
||||
{leftColumn.map(([groupKey, shortcuts], idx) => (
|
||||
<ShortcutGroup
|
||||
key={groupKey}
|
||||
groupKey={groupKey}
|
||||
shortcuts={shortcuts}
|
||||
isFirst={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{rightColumn.map(([groupKey, shortcuts], idx) => (
|
||||
<ShortcutGroup
|
||||
key={groupKey}
|
||||
groupKey={groupKey}
|
||||
shortcuts={shortcuts}
|
||||
isFirst={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-light py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_shortcut_show_shortcuts')}
|
||||
</span>
|
||||
<KeyCombo keys={[isMac ? '⌘' : 'Ctrl', '⇧', '/']} />
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(KeyboardShortcutsDialog);
|
||||
480
client/src/hooks/useKeyboardShortcuts.ts
Normal file
480
client/src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useArchiveConvoMutation, useDeleteConversationMutation } from '~/data-provider';
|
||||
import { mainTextareaId } from '~/common';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import useNewConvo from './useNewConvo';
|
||||
import store from '~/store';
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
export type ShortcutDefinition = {
|
||||
/** Translation key for the shortcut label */
|
||||
labelKey: string;
|
||||
/** Translation key for the shortcut group/category */
|
||||
groupKey: string;
|
||||
/** Human-readable key combo for display (Mac) */
|
||||
displayMac: string;
|
||||
/** Human-readable key combo for display (non-Mac) */
|
||||
displayOther: string;
|
||||
};
|
||||
|
||||
export const shortcutDefinitions: Record<string, ShortcutDefinition> = {
|
||||
newChat: {
|
||||
labelKey: 'com_ui_new_chat',
|
||||
groupKey: 'com_shortcut_group_general',
|
||||
displayMac: '⌘ ⇧ O',
|
||||
displayOther: 'Ctrl+Shift+O',
|
||||
},
|
||||
focusChat: {
|
||||
labelKey: 'com_shortcut_focus_chat_input',
|
||||
groupKey: 'com_shortcut_group_general',
|
||||
displayMac: '⇧ Esc',
|
||||
displayOther: 'Shift+Esc',
|
||||
},
|
||||
copyLastResponse: {
|
||||
labelKey: 'com_shortcut_copy_last_response',
|
||||
groupKey: 'com_shortcut_group_general',
|
||||
displayMac: '⌘ ⇧ ;',
|
||||
displayOther: 'Ctrl+Shift+;',
|
||||
},
|
||||
uploadFile: {
|
||||
labelKey: 'com_shortcut_upload_file',
|
||||
groupKey: 'com_shortcut_group_general',
|
||||
displayMac: '⌘ ⇧ U',
|
||||
displayOther: 'Ctrl+Shift+U',
|
||||
},
|
||||
toggleSidebar: {
|
||||
labelKey: 'com_shortcut_toggle_sidebar',
|
||||
groupKey: 'com_shortcut_group_navigation',
|
||||
displayMac: '⌘ ⇧ S',
|
||||
displayOther: 'Ctrl+Shift+S',
|
||||
},
|
||||
toggleRightSidebar: {
|
||||
labelKey: 'com_shortcut_toggle_right_sidebar',
|
||||
groupKey: 'com_shortcut_group_navigation',
|
||||
displayMac: '⌘ ⇧ R',
|
||||
displayOther: 'Ctrl+Shift+R',
|
||||
},
|
||||
openModelSelector: {
|
||||
labelKey: 'com_shortcut_open_model_selector',
|
||||
groupKey: 'com_shortcut_group_navigation',
|
||||
displayMac: '⌘ ⇧ M',
|
||||
displayOther: 'Ctrl+Shift+M',
|
||||
},
|
||||
focusSearch: {
|
||||
labelKey: 'com_shortcut_focus_search',
|
||||
groupKey: 'com_shortcut_group_navigation',
|
||||
displayMac: '⌘ /',
|
||||
displayOther: 'Ctrl+/',
|
||||
},
|
||||
openSettings: {
|
||||
labelKey: 'com_nav_settings',
|
||||
groupKey: 'com_shortcut_group_navigation',
|
||||
displayMac: '⌘ ⇧ ,',
|
||||
displayOther: 'Ctrl+Shift+,',
|
||||
},
|
||||
stopGenerating: {
|
||||
labelKey: 'com_nav_stop_generating',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ X',
|
||||
displayOther: 'Ctrl+Shift+X',
|
||||
},
|
||||
regenerateResponse: {
|
||||
labelKey: 'com_shortcut_regenerate_response',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ E',
|
||||
displayOther: 'Ctrl+Shift+E',
|
||||
},
|
||||
editLastMessage: {
|
||||
labelKey: 'com_shortcut_edit_last_message',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ I',
|
||||
displayOther: 'Ctrl+Shift+I',
|
||||
},
|
||||
copyLastCode: {
|
||||
labelKey: 'com_shortcut_copy_last_code',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ K',
|
||||
displayOther: 'Ctrl+Shift+K',
|
||||
},
|
||||
scrollToTop: {
|
||||
labelKey: 'com_shortcut_scroll_to_top',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ ↑',
|
||||
displayOther: 'Ctrl+Shift+↑',
|
||||
},
|
||||
scrollToBottom: {
|
||||
labelKey: 'com_shortcut_scroll_to_bottom',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ ↓',
|
||||
displayOther: 'Ctrl+Shift+↓',
|
||||
},
|
||||
toggleTemporaryChat: {
|
||||
labelKey: 'com_ui_temporary',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ T',
|
||||
displayOther: 'Ctrl+Shift+T',
|
||||
},
|
||||
archiveConversation: {
|
||||
labelKey: 'com_shortcut_archive_conversation',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ A',
|
||||
displayOther: 'Ctrl+Shift+A',
|
||||
},
|
||||
deleteConversation: {
|
||||
labelKey: 'com_shortcut_delete_conversation',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ ⌫',
|
||||
displayOther: 'Ctrl+Shift+Backspace',
|
||||
},
|
||||
};
|
||||
|
||||
function getMainScrollContainer(): Element | null {
|
||||
return document.querySelector('main[role="main"]');
|
||||
}
|
||||
|
||||
export default function useKeyboardShortcuts() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded);
|
||||
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
|
||||
const setIsTemporary = useSetRecoilState(store.isTemporary);
|
||||
|
||||
const archiveMutation = useArchiveConvoMutation();
|
||||
const deleteMutation = useDeleteConversationMutation();
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
}, [queryClient, conversation?.conversationId, newConversation]);
|
||||
|
||||
const handleFocusChatInput = useCallback(() => {
|
||||
const textarea = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
}, []);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
setSidebarExpanded((prev) => !prev);
|
||||
}, [setSidebarExpanded]);
|
||||
|
||||
const handleToggleRightSidebar = useCallback(() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>('[data-testid="parameters-button"]');
|
||||
btn?.click();
|
||||
}, []);
|
||||
|
||||
const handleOpenModelSelector = useCallback(() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>('[data-testid="model-selector-button"]');
|
||||
btn?.click();
|
||||
}, []);
|
||||
|
||||
const handleFocusSearch = useCallback(() => {
|
||||
if (!sidebarExpanded) {
|
||||
setSidebarExpanded(true);
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[aria-label][placeholder*="earch"]',
|
||||
);
|
||||
input?.focus();
|
||||
}, 350);
|
||||
} else {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[aria-label][placeholder*="earch"]',
|
||||
);
|
||||
input?.focus();
|
||||
}
|
||||
}, [sidebarExpanded, setSidebarExpanded]);
|
||||
|
||||
const handleShowShortcuts = useCallback(() => {
|
||||
setShowShortcutsDialog((prev) => !prev);
|
||||
}, [setShowShortcutsDialog]);
|
||||
|
||||
const handleCopyLastResponse = useCallback(() => {
|
||||
const turns = document.querySelectorAll('.agent-turn');
|
||||
if (turns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const last = turns[turns.length - 1];
|
||||
const markdown = last.querySelector('.markdown');
|
||||
const text = (markdown ?? last).textContent ?? '';
|
||||
if (text.trim()) {
|
||||
navigator.clipboard.writeText(text.trim());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyLastCode = useCallback(() => {
|
||||
const blocks = document.querySelectorAll('.agent-turn pre code');
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const last = blocks[blocks.length - 1];
|
||||
const text = last.textContent ?? '';
|
||||
if (text.trim()) {
|
||||
navigator.clipboard.writeText(text.trim());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStopGenerating = useCallback(() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>('[data-testid="stop-generation-button"]');
|
||||
btn?.click();
|
||||
}, []);
|
||||
|
||||
const handleRegenerateResponse = useCallback(() => {
|
||||
const btn = document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="regenerate-generation-button"]',
|
||||
);
|
||||
btn?.click();
|
||||
}, []);
|
||||
|
||||
const handleEditLastMessage = useCallback(() => {
|
||||
const userTurns = document.querySelectorAll('.user-turn');
|
||||
if (userTurns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const last = userTurns[userTurns.length - 1];
|
||||
const editBtn = last.querySelector<HTMLButtonElement>('button[id^="edit-"]');
|
||||
editBtn?.click();
|
||||
}, []);
|
||||
|
||||
const handleScrollToTop = useCallback(() => {
|
||||
const container = getMainScrollContainer();
|
||||
if (container) {
|
||||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const container = getMainScrollContainer();
|
||||
if (container) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
const btn = document.querySelector<HTMLElement>('[data-testid="nav-user"]');
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
btn.click();
|
||||
setTimeout(() => {
|
||||
const settingsItem = document.querySelector<HTMLElement>('[data-testid="nav-settings"]');
|
||||
settingsItem?.click();
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
const handleToggleTemporaryChat = useCallback(() => {
|
||||
setIsTemporary((prev) => !prev);
|
||||
}, [setIsTemporary]);
|
||||
|
||||
const handleUploadFile = useCallback(() => {
|
||||
const btn =
|
||||
document.querySelector<HTMLButtonElement>('#attach-file-menu-button') ??
|
||||
document.querySelector<HTMLButtonElement>('#attach-file');
|
||||
btn?.click();
|
||||
}, []);
|
||||
|
||||
const handleArchiveConversation = useCallback(() => {
|
||||
const convoId = conversation?.conversationId;
|
||||
if (!convoId || convoId === 'new') {
|
||||
return;
|
||||
}
|
||||
archiveMutation.mutate(
|
||||
{ conversationId: convoId, isArchived: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [conversation?.conversationId, currentConvoId, archiveMutation, newConversation, navigate]);
|
||||
|
||||
const handleDeleteConversation = useCallback(() => {
|
||||
const convoId = conversation?.conversationId;
|
||||
if (!convoId || convoId === 'new') {
|
||||
return;
|
||||
}
|
||||
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
|
||||
const lastMessage = messages?.[messages.length - 1];
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
conversationId: convoId,
|
||||
thread_id: lastMessage?.thread_id,
|
||||
endpoint: lastMessage?.endpoint,
|
||||
source: 'keyboard',
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
conversation?.conversationId,
|
||||
currentConvoId,
|
||||
queryClient,
|
||||
deleteMutation,
|
||||
newConversation,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const handler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tagName = target?.tagName;
|
||||
const isEditing =
|
||||
tagName === 'INPUT' || tagName === 'TEXTAREA' || target?.isContentEditable === true;
|
||||
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Shift + Escape → Focus Chat Input (works anywhere)
|
||||
if (e.shiftKey && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleFocusChatInput();
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining shortcuts require mod key
|
||||
if (!mod) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-shift shortcuts
|
||||
if (!e.shiftKey) {
|
||||
// Cmd/Ctrl + / → Focus Search
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
handleFocusSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Shift + / (?) → Show Keyboard Shortcuts (works even when editing)
|
||||
if (e.key === '?') {
|
||||
e.preventDefault();
|
||||
handleShowShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remaining Cmd/Ctrl+Shift shortcuts should not fire when editing text
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'O':
|
||||
e.preventDefault();
|
||||
handleNewChat();
|
||||
break;
|
||||
case 'S':
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
handleToggleSidebar();
|
||||
break;
|
||||
case 'R':
|
||||
e.preventDefault();
|
||||
handleToggleRightSidebar();
|
||||
break;
|
||||
case 'M':
|
||||
e.preventDefault();
|
||||
handleOpenModelSelector();
|
||||
break;
|
||||
case ':':
|
||||
case ';':
|
||||
e.preventDefault();
|
||||
handleCopyLastResponse();
|
||||
break;
|
||||
case 'U':
|
||||
e.preventDefault();
|
||||
handleUploadFile();
|
||||
break;
|
||||
case 'X':
|
||||
e.preventDefault();
|
||||
handleStopGenerating();
|
||||
break;
|
||||
case 'E':
|
||||
e.preventDefault();
|
||||
handleRegenerateResponse();
|
||||
break;
|
||||
case 'I':
|
||||
e.preventDefault();
|
||||
handleEditLastMessage();
|
||||
break;
|
||||
case 'K':
|
||||
e.preventDefault();
|
||||
handleCopyLastCode();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
handleScrollToTop();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
handleScrollToBottom();
|
||||
break;
|
||||
case '<':
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
handleOpenSettings();
|
||||
break;
|
||||
case 'T':
|
||||
e.preventDefault();
|
||||
handleToggleTemporaryChat();
|
||||
break;
|
||||
case 'A':
|
||||
e.preventDefault();
|
||||
handleArchiveConversation();
|
||||
break;
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
handleDeleteConversation();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
handleNewChat,
|
||||
handleFocusChatInput,
|
||||
handleToggleSidebar,
|
||||
handleToggleRightSidebar,
|
||||
handleOpenModelSelector,
|
||||
handleFocusSearch,
|
||||
handleShowShortcuts,
|
||||
handleCopyLastResponse,
|
||||
handleCopyLastCode,
|
||||
handleUploadFile,
|
||||
handleStopGenerating,
|
||||
handleRegenerateResponse,
|
||||
handleEditLastMessage,
|
||||
handleScrollToTop,
|
||||
handleScrollToBottom,
|
||||
handleOpenSettings,
|
||||
handleToggleTemporaryChat,
|
||||
handleArchiveConversation,
|
||||
handleDeleteConversation,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [handler]);
|
||||
}
|
||||
|
||||
export { isMac };
|
||||
|
|
@ -606,6 +606,25 @@
|
|||
"com_nav_user_msg_markdown": "Render user messages as markdown",
|
||||
"com_nav_user_name_display": "Display username in messages",
|
||||
"com_nav_voice_select": "Voice",
|
||||
"com_shortcut_archive_conversation": "Archive conversation",
|
||||
"com_shortcut_copy_last_code": "Copy last code block",
|
||||
"com_shortcut_copy_last_response": "Copy last response",
|
||||
"com_shortcut_delete_conversation": "Delete conversation",
|
||||
"com_shortcut_edit_last_message": "Edit last message",
|
||||
"com_shortcut_focus_chat_input": "Focus chat input",
|
||||
"com_shortcut_focus_search": "Focus search",
|
||||
"com_shortcut_group_chat": "Chat",
|
||||
"com_shortcut_group_general": "General",
|
||||
"com_shortcut_group_navigation": "Navigation",
|
||||
"com_shortcut_keyboard_shortcuts": "Keyboard Shortcuts",
|
||||
"com_shortcut_open_model_selector": "Open model selector",
|
||||
"com_shortcut_regenerate_response": "Regenerate response",
|
||||
"com_shortcut_scroll_to_bottom": "Scroll to bottom",
|
||||
"com_shortcut_scroll_to_top": "Scroll to top",
|
||||
"com_shortcut_show_shortcuts": "Show keyboard shortcuts",
|
||||
"com_shortcut_toggle_right_sidebar": "Toggle right sidebar",
|
||||
"com_shortcut_toggle_sidebar": "Toggle sidebar",
|
||||
"com_shortcut_upload_file": "Upload file",
|
||||
"com_show_examples": "Show Examples",
|
||||
"com_sidepanel_agent_builder": "Agent Builder",
|
||||
"com_sidepanel_assistant_builder": "Assistant Builder",
|
||||
|
|
|
|||
|
|
@ -18,10 +18,18 @@ import {
|
|||
FileMapContext,
|
||||
} from '~/Providers';
|
||||
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog';
|
||||
import { UnifiedSidebar } from '~/components/UnifiedSidebar';
|
||||
import { TermsAndConditionsModal } from '~/components/ui';
|
||||
import { useHealthCheck } from '~/data-provider';
|
||||
import { Banner } from '~/components/Banners';
|
||||
import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts';
|
||||
|
||||
/** Isolates keyboard shortcut listeners so they only mount after auth. */
|
||||
function KeyboardShortcutsProvider() {
|
||||
useKeyboardShortcuts();
|
||||
return <KeyboardShortcutsDialog />;
|
||||
}
|
||||
|
||||
export default function Root() {
|
||||
const [showTerms, setShowTerms] = useState(false);
|
||||
|
|
@ -98,6 +106,7 @@ export default function Root() {
|
|||
modalContent={config.interface.termsOfService.modalContent}
|
||||
/>
|
||||
)}
|
||||
<KeyboardShortcutsProvider />
|
||||
</AssistantsMapContext.Provider>
|
||||
</FileMapContext.Provider>
|
||||
</SetConvoProvider>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ const isEditingBadges = atom<boolean>({
|
|||
default: false,
|
||||
});
|
||||
|
||||
const showShortcutsDialog = atom<boolean>({
|
||||
key: 'showShortcutsDialog',
|
||||
default: false,
|
||||
});
|
||||
|
||||
const chatBadges = atomWithLocalStorage<Pick<BadgeItem, 'id'>[]>('chatBadges', [
|
||||
// When adding new badges, make sure to add them to useChatBadges.ts as well and add them as last item
|
||||
// DO NOT CHANGE THE ORDER OF THE BADGES ALREADY IN THE ARRAY
|
||||
|
|
@ -70,5 +75,6 @@ export default {
|
|||
conversationAttachmentsSelector,
|
||||
queriesEnabled,
|
||||
isEditingBadges,
|
||||
showShortcutsDialog,
|
||||
chatBadges,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue