From cd7d0e61060de5d14caf5780ec7aee92394db94e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 21 Jul 2026 18:12:48 +0200 Subject: [PATCH 1/5] feat: add keyboard shortcuts and hints (#1138, #1222) --- apps/frontend/src/components/command-menu.tsx | 49 ++++++++----- .../components/keyboard-shortcuts-dialog.tsx | 40 +++++++++++ .../src/components/settings/file-viewer.tsx | 5 +- apps/frontend/src/components/sidebar.tsx | 22 ++---- apps/frontend/src/components/ui/kbd.tsx | 27 +++++++ .../src/hooks/use-keyboard-shortcuts.ts | 32 +++++++++ apps/frontend/src/lib/keyboard-shortcuts.ts | 72 +++++++++++++++++++ apps/frontend/src/lib/platform.ts | 67 +++++++++++++++++ ...debar-layout.automations.$automationId.tsx | 6 +- apps/frontend/src/routes/_sidebar-layout.tsx | 51 +++++++++++-- 10 files changed, 323 insertions(+), 48 deletions(-) create mode 100644 apps/frontend/src/components/keyboard-shortcuts-dialog.tsx create mode 100644 apps/frontend/src/components/ui/kbd.tsx create mode 100644 apps/frontend/src/hooks/use-keyboard-shortcuts.ts create mode 100644 apps/frontend/src/lib/keyboard-shortcuts.ts create mode 100644 apps/frontend/src/lib/platform.ts diff --git a/apps/frontend/src/components/command-menu.tsx b/apps/frontend/src/components/command-menu.tsx index 7f20cc070..23a51980c 100644 --- a/apps/frontend/src/components/command-menu.tsx +++ b/apps/frontend/src/components/command-menu.tsx @@ -1,6 +1,14 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { MessageSquareIcon, MessageSquarePlusIcon, MoonIcon, SunIcon, UserIcon } from 'lucide-react'; +import { + BookOpenIcon, + KeyboardIcon, + MessageSquareIcon, + MessageSquarePlusIcon, + MoonIcon, + SunIcon, + UserIcon, +} from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { @@ -18,6 +26,7 @@ import { useSearchChatsQuery } from '@/queries/use-search-chats-query'; import { useDebouncedValue } from '@/hooks/use-debounced-value'; import { usePermissions } from '@/hooks/use-permissions'; import { TextShimmer } from '@/components/ui/text-shimmer'; +import { getShortcutLabel } from '@/lib/keyboard-shortcuts'; type CommandConfig = { id: string; @@ -30,7 +39,7 @@ type CommandConfig = { visible?: boolean; }; -export function CommandMenu() { +export function CommandMenu({ onOpenKeyboardShortcuts }: { onOpenKeyboardShortcuts: () => void }) { const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); const debouncedSearch = useDebouncedValue(searchValue, 300); @@ -57,10 +66,19 @@ export function CommandMenu() { keywords: ['start chat', 'new conversation'], icon: MessageSquarePlusIcon, action: () => navigate({ to: '/' }), - shortcut: '⇧⌘O', + shortcut: getShortcutLabel('new-chat'), group: 'Jump to', visible: canStartNewChat, }, + { + id: 'go-to-stories', + label: 'Go to Stories', + keywords: ['stories'], + icon: BookOpenIcon, + action: () => navigate({ to: '/stories', search: { folderId: null } }), + shortcut: getShortcutLabel('go-to-stories'), + group: 'Jump to', + }, { id: 'open-settings', label: 'Open Account Settings', @@ -79,8 +97,17 @@ export function CommandMenu() { }, group: 'Actions', }, + { + id: 'keyboard-help', + label: 'Keyboard shortcuts', + keywords: ['hotkeys', 'key bindings'], + icon: KeyboardIcon, + action: onOpenKeyboardShortcuts, + shortcut: getShortcutLabel('keyboard-help'), + group: 'Actions', + }, ], - [navigate, theme, setTheme, canStartNewChat], + [navigate, theme, setTheme, canStartNewChat, onOpenKeyboardShortcuts], ); const visibleCommands = useMemo(() => commands.filter((cmd) => cmd.visible ?? true), [commands]); @@ -92,18 +119,6 @@ export function CommandMenu() { const jumpToCommands = displayedCommands.filter((cmd) => cmd.group === 'Jump to'); const actionCommands = displayedCommands.filter((cmd) => cmd.group === 'Actions'); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((prev) => !prev); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, []); - const handleOpenChange = useCallback((isOpen: boolean) => { setOpen(isOpen); if (!isOpen) { diff --git a/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx b/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx new file mode 100644 index 000000000..ed4267adf --- /dev/null +++ b/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,40 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Kbd } from '@/components/ui/kbd'; +import { SHORTCUTS } from '@/lib/keyboard-shortcuts'; + +type KeyboardShortcutsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcutsDialogProps) { + const groups = Array.from(new Set(SHORTCUTS.map((entry) => entry.group))); + + return ( + + + + Keyboard shortcuts + +
+ {groups.map((group) => ( +
+

{group}

+
+ {SHORTCUTS.filter((entry) => entry.group === group).map((entry) => ( +
+ {entry.label} + +
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/settings/file-viewer.tsx b/apps/frontend/src/components/settings/file-viewer.tsx index d8c9d4740..2585f27a7 100644 --- a/apps/frontend/src/components/settings/file-viewer.tsx +++ b/apps/frontend/src/components/settings/file-viewer.tsx @@ -4,6 +4,7 @@ import type { Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { Spinner } from '@/components/ui/spinner'; import { useEditorTheme } from '@/hooks/use-editor-theme'; +import { isMac } from '@/lib/platform'; interface FileViewerProps { filePath: string | null; @@ -86,8 +87,8 @@ export function FileViewer({ filePath, content, isLoading, isError }: FileViewer new KeyboardEvent('keydown', { key: 'k', code: 'KeyK', - metaKey: navigator.platform.includes('Mac'), - ctrlKey: !navigator.platform.includes('Mac'), + metaKey: isMac, + ctrlKey: !isMac, bubbles: true, }), ); diff --git a/apps/frontend/src/components/sidebar.tsx b/apps/frontend/src/components/sidebar.tsx index 73d1cef61..f116ac227 100644 --- a/apps/frontend/src/components/sidebar.tsx +++ b/apps/frontend/src/components/sidebar.tsx @@ -30,6 +30,7 @@ import { useChatViewPreferences } from '@/hooks/use-chat-view-preferences'; import { useSidebarSectionOpen } from '@/hooks/use-sidebar-section-open'; import { useTimeAgo } from '@/hooks/use-time-ago'; import { getActiveProjectId, setActiveProjectId } from '@/lib/active-project'; +import { getShortcutLabel } from '@/lib/keyboard-shortcuts'; import { cn, hideIf } from '@/lib/utils'; import { trpc } from '@/main'; import { usePermissions } from '@/hooks/use-permissions'; @@ -97,21 +98,6 @@ export function Sidebar() { } }, [openCommandMenu, isMobile, closeMobile]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (isViewer) { - return; - } - if (e.shiftKey && e.metaKey && e.key.toLowerCase() === 'o') { - e.preventDefault(); - handleNavigateHome(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleNavigateHome, isViewer]); - useEffect(() => { if (!project.data?.id) { return; @@ -203,7 +189,7 @@ export function Sidebar() { @@ -211,14 +197,14 @@ export function Sidebar() { diff --git a/apps/frontend/src/components/ui/kbd.tsx b/apps/frontend/src/components/ui/kbd.tsx new file mode 100644 index 000000000..b8b476a5a --- /dev/null +++ b/apps/frontend/src/components/ui/kbd.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from 'react'; +import type { Shortcut } from '@/lib/platform'; + +import { formatShortcut } from '@/lib/platform'; +import { cn } from '@/lib/utils'; + +type KbdProps = HTMLAttributes & { + shortcut?: Shortcut; + keys?: string[]; +}; + +export function Kbd({ shortcut, keys, className, ...props }: KbdProps) { + const tokens = keys ?? (shortcut ? formatShortcut(shortcut) : []); + + return ( + + {tokens.map((token, index) => ( + + {token} + + ))} + + ); +} diff --git a/apps/frontend/src/hooks/use-keyboard-shortcuts.ts b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 000000000..1a7920dba --- /dev/null +++ b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; + +import type { ShortcutId } from '@/lib/keyboard-shortcuts'; +import { isTypingTarget, SHORTCUTS } from '@/lib/keyboard-shortcuts'; +import { matchesShortcut } from '@/lib/platform'; + +type ShortcutHandlers = Partial void>>; + +export function useKeyboardShortcuts(handlers: ShortcutHandlers) { + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isTypingTarget(event)) { + return; + } + + for (const entry of SHORTCUTS) { + const handler = handlersRef.current[entry.id]; + if (handler && matchesShortcut(event, entry.shortcut)) { + event.preventDefault(); + handler(); + return; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); +} diff --git a/apps/frontend/src/lib/keyboard-shortcuts.ts b/apps/frontend/src/lib/keyboard-shortcuts.ts new file mode 100644 index 000000000..6bbc52038 --- /dev/null +++ b/apps/frontend/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,72 @@ +import type { Shortcut } from '@/lib/platform'; +import { formatShortcut, formatShortcutLabel } from '@/lib/platform'; + +export type ShortcutId = 'toggle-sidebar' | 'command-menu' | 'new-chat' | 'go-to-stories' | 'keyboard-help'; +export type ShortcutGroup = 'General' | 'Navigation'; + +export type ShortcutDefinition = { + id: ShortcutId; + label: string; + group: ShortcutGroup; + shortcut: Shortcut; +}; + +export const SHORTCUTS: readonly ShortcutDefinition[] = [ + { + id: 'toggle-sidebar', + label: 'Toggle sidebar', + group: 'General', + shortcut: { mod: true, key: 'b' }, + }, + { + id: 'command-menu', + label: 'Command menu', + group: 'General', + shortcut: { mod: true, key: 'k' }, + }, + { + id: 'keyboard-help', + label: 'Keyboard shortcuts', + group: 'General', + shortcut: { mod: true, key: '/' }, + }, + { + id: 'new-chat', + label: 'New chat', + group: 'Navigation', + shortcut: { mod: true, shift: true, key: 'o' }, + }, + { + id: 'go-to-stories', + label: 'Go to Stories', + group: 'Navigation', + shortcut: { mod: true, shift: true, key: 's' }, + }, +]; + +export function getShortcut(id: ShortcutId): ShortcutDefinition { + const shortcut = SHORTCUTS.find((entry) => entry.id === id); + if (!shortcut) { + throw new Error(`Unknown keyboard shortcut: ${id}`); + } + return shortcut; +} + +export function getShortcutTokens(id: ShortcutId): string[] { + return formatShortcut(getShortcut(id).shortcut); +} + +export function getShortcutLabel(id: ShortcutId): string { + return formatShortcutLabel(getShortcut(id).shortcut); +} + +export function isTypingTarget(event: KeyboardEvent): boolean { + const target = event.target instanceof HTMLElement ? event.target : null; + const activeElement = typeof document !== 'undefined' ? document.activeElement : null; + + return isEditableElement(target) || (activeElement instanceof HTMLElement && isEditableElement(activeElement)); +} + +function isEditableElement(element: HTMLElement | null): boolean { + return element?.tagName === 'INPUT' || element?.tagName === 'TEXTAREA' || Boolean(element?.isContentEditable); +} diff --git a/apps/frontend/src/lib/platform.ts b/apps/frontend/src/lib/platform.ts new file mode 100644 index 000000000..10899eebd --- /dev/null +++ b/apps/frontend/src/lib/platform.ts @@ -0,0 +1,67 @@ +export type Shortcut = { + mod?: boolean; + shift?: boolean; + alt?: boolean; + key: string; +}; + +export const isMac = + typeof navigator !== 'undefined' && + (navigator.platform.includes('Mac') || /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent)); + +export function formatShortcut(shortcut: Shortcut): string[] { + const tokens: string[] = []; + + if (isMac) { + if (shortcut.alt) { + tokens.push('⌥'); + } + if (shortcut.shift) { + tokens.push('⇧'); + } + if (shortcut.mod) { + tokens.push('⌘'); + } + } else { + if (shortcut.mod) { + tokens.push('Ctrl'); + } + if (shortcut.alt) { + tokens.push('Alt'); + } + if (shortcut.shift) { + tokens.push('Shift'); + } + } + + tokens.push(formatKey(shortcut.key)); + return tokens; +} + +export function formatShortcutLabel(shortcut: Shortcut): string { + return formatShortcut(shortcut).join(isMac ? '' : '+'); +} + +export function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean { + const modPressed = event.metaKey || event.ctrlKey; + + if (modPressed !== Boolean(shortcut.mod)) { + return false; + } + if (event.shiftKey !== Boolean(shortcut.shift) || event.altKey !== Boolean(shortcut.alt)) { + return false; + } + + if (shortcut.key === '/') { + return event.code === 'Slash' || event.key === '/'; + } + + return event.key.toLowerCase() === shortcut.key.toLowerCase(); +} + +function formatKey(key: string): string { + if (key.toLowerCase() === 'escape') { + return 'Esc'; + } + return key.length === 1 ? key.toUpperCase() : key; +} diff --git a/apps/frontend/src/routes/_sidebar-layout.automations.$automationId.tsx b/apps/frontend/src/routes/_sidebar-layout.automations.$automationId.tsx index dcd3665e0..90b3fa177 100644 --- a/apps/frontend/src/routes/_sidebar-layout.automations.$automationId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.automations.$automationId.tsx @@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { isMac } from '@/lib/platform'; import { requireAutomationsEnabled } from '@/lib/require-admin'; import { cn } from '@/lib/utils'; import { trpc } from '@/main'; @@ -189,10 +190,7 @@ function AutomationDetailPage() { } function getSaveShortcutLabel() { - if (typeof navigator !== 'undefined' && navigator.platform.includes('Mac')) { - return '⌘S'; - } - return 'Ctrl+S'; + return isMac ? '⌘S' : 'Ctrl+S'; } type AutomationRun = { diff --git a/apps/frontend/src/routes/_sidebar-layout.tsx b/apps/frontend/src/routes/_sidebar-layout.tsx index f863d3d1c..30a37a916 100644 --- a/apps/frontend/src/routes/_sidebar-layout.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.tsx @@ -1,8 +1,13 @@ -import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { Sidebar } from '@/components/sidebar'; +import { useCallback, useState } from 'react'; +import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'; + import { CommandMenu } from '@/components/command-menu'; -import { SidebarProvider } from '@/contexts/sidebar'; -import { CommandMenuCallbackProvider } from '@/contexts/command-menu-callback'; +import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'; +import { Sidebar } from '@/components/sidebar'; +import { CommandMenuCallbackProvider, useCommandMenuCallback } from '@/contexts/command-menu-callback'; +import { SidebarProvider, useSidebar } from '@/contexts/sidebar'; +import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; +import { usePermissions } from '@/hooks/use-permissions'; export const Route = createFileRoute('/_sidebar-layout')({ component: RouteComponent, @@ -12,10 +17,42 @@ function RouteComponent() { return ( - - - + ); } + +function SidebarLayoutContent() { + const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = useState(false); + + return ( + <> + setKeyboardShortcutsOpen(true)} /> + + setKeyboardShortcutsOpen(true)} /> + + + + ); +} + +function GlobalShortcuts({ onOpenKeyboardShortcuts }: { onOpenKeyboardShortcuts: () => void }) { + const navigate = useNavigate(); + const { toggle } = useSidebar(); + const { fire: openCommandMenu } = useCommandMenuCallback(); + const { canStartNewChat } = usePermissions(); + + const navigateHome = useCallback(() => navigate({ to: '/' }), [navigate]); + const navigateStories = useCallback(() => navigate({ to: '/stories', search: { folderId: null } }), [navigate]); + + useKeyboardShortcuts({ + 'toggle-sidebar': toggle, + 'command-menu': openCommandMenu, + 'new-chat': canStartNewChat ? navigateHome : undefined, + 'go-to-stories': navigateStories, + 'keyboard-help': onOpenKeyboardShortcuts, + }); + + return null; +} From 79830ea35fb2f8b142f8d5d54b4ec16affff22c4 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 22 Jul 2026 09:52:02 +0200 Subject: [PATCH 2/5] feat: Added missing toggle sidebar hint --- apps/frontend/src/components/sidebar.tsx | 43 +++++++++++++++++------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/frontend/src/components/sidebar.tsx b/apps/frontend/src/components/sidebar.tsx index f116ac227..4795e8ccf 100644 --- a/apps/frontend/src/components/sidebar.tsx +++ b/apps/frontend/src/components/sidebar.tsx @@ -23,6 +23,7 @@ import type { LucideIcon } from 'lucide-react'; import NaoLogo from '@/components/icons/nao-logo.svg'; import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useCommandMenuCallback } from '@/contexts/command-menu-callback'; import { useSidebar } from '@/contexts/sidebar'; import { brandingAssetUrl, useBranding } from '@/hooks/use-branding'; @@ -40,6 +41,7 @@ export function Sidebar() { const queryClient = useQueryClient(); const matchRoute = useMatchRoute(); const { isCollapsed, isMobile, isMobileOpen, closeMobile, toggle: toggleSidebar } = useSidebar(); + const [toggleHintOpen, setToggleHintOpen] = useState(false); const { fire: openCommandMenu } = useCommandMenuCallback(); const project = useQuery(trpc.project.getCurrent.queryOptions()); const projects = useQuery(trpc.project.listForCurrentUser.queryOptions()); @@ -56,6 +58,10 @@ export function Sidebar() { const isInSettings = matchRoute({ to: '/settings', fuzzy: true }); const effectiveIsCollapsed = isMobile ? false : isCollapsed; + useEffect(() => { + setToggleHintOpen(false); + }, [effectiveIsCollapsed]); + useEffect(() => { if (isMobile && isMobileOpen) { closeMobile(); @@ -168,18 +174,31 @@ export function Sidebar() { ) : ( - + + + + + + + Toggle sidebar + + {getShortcutLabel('toggle-sidebar')} + + + + )} {!isInSettings && ( From 43be7b818047d1bc46adf3c60175f1393c260980 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 08:31:19 +0000 Subject: [PATCH 3/5] Fixed Cubic complaints --- apps/frontend/src/components/command-menu.tsx | 10 ++++++++-- .../src/components/keyboard-shortcuts-dialog.tsx | 5 ++++- apps/frontend/src/components/sidebar.tsx | 10 ++-------- apps/frontend/src/hooks/use-keyboard-shortcuts.ts | 4 +++- apps/frontend/src/lib/platform.ts | 5 +++-- apps/frontend/src/lib/stories-cache.ts | 14 ++++++++++++++ 6 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 apps/frontend/src/lib/stories-cache.ts diff --git a/apps/frontend/src/components/command-menu.tsx b/apps/frontend/src/components/command-menu.tsx index 23a51980c..697bc8a3b 100644 --- a/apps/frontend/src/components/command-menu.tsx +++ b/apps/frontend/src/components/command-menu.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { BookOpenIcon, @@ -27,6 +28,7 @@ import { useDebouncedValue } from '@/hooks/use-debounced-value'; import { usePermissions } from '@/hooks/use-permissions'; import { TextShimmer } from '@/components/ui/text-shimmer'; import { getShortcutLabel } from '@/lib/keyboard-shortcuts'; +import { invalidateStoriesCaches } from '@/lib/stories-cache'; type CommandConfig = { id: string; @@ -44,6 +46,7 @@ export function CommandMenu({ onOpenKeyboardShortcuts }: { onOpenKeyboardShortcu const [searchValue, setSearchValue] = useState(''); const debouncedSearch = useDebouncedValue(searchValue, 300); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { theme, setTheme } = useTheme(); const { canStartNewChat } = usePermissions(); @@ -75,7 +78,10 @@ export function CommandMenu({ onOpenKeyboardShortcuts }: { onOpenKeyboardShortcu label: 'Go to Stories', keywords: ['stories'], icon: BookOpenIcon, - action: () => navigate({ to: '/stories', search: { folderId: null } }), + action: () => { + invalidateStoriesCaches(queryClient); + navigate({ to: '/stories', search: { folderId: null } }); + }, shortcut: getShortcutLabel('go-to-stories'), group: 'Jump to', }, @@ -107,7 +113,7 @@ export function CommandMenu({ onOpenKeyboardShortcuts }: { onOpenKeyboardShortcu group: 'Actions', }, ], - [navigate, theme, setTheme, canStartNewChat, onOpenKeyboardShortcuts], + [navigate, queryClient, theme, setTheme, canStartNewChat, onOpenKeyboardShortcuts], ); const visibleCommands = useMemo(() => commands.filter((cmd) => cmd.visible ?? true), [commands]); diff --git a/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx b/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx index ed4267adf..a6b93cebd 100644 --- a/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx +++ b/apps/frontend/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Kbd } from '@/components/ui/kbd'; import { SHORTCUTS } from '@/lib/keyboard-shortcuts'; @@ -15,6 +15,9 @@ export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcut Keyboard shortcuts + + List of available keyboard shortcuts and their key combinations. +
{groups.map((group) => ( diff --git a/apps/frontend/src/components/sidebar.tsx b/apps/frontend/src/components/sidebar.tsx index 4795e8ccf..544cb9fdb 100644 --- a/apps/frontend/src/components/sidebar.tsx +++ b/apps/frontend/src/components/sidebar.tsx @@ -32,6 +32,7 @@ import { useSidebarSectionOpen } from '@/hooks/use-sidebar-section-open'; import { useTimeAgo } from '@/hooks/use-time-ago'; import { getActiveProjectId, setActiveProjectId } from '@/lib/active-project'; import { getShortcutLabel } from '@/lib/keyboard-shortcuts'; +import { invalidateStoriesCaches } from '@/lib/stories-cache'; import { cn, hideIf } from '@/lib/utils'; import { trpc } from '@/main'; import { usePermissions } from '@/hooks/use-permissions'; @@ -76,14 +77,7 @@ export function Sidebar() { }, [navigate, isMobile, closeMobile]); const handleNavigateStories = useCallback(() => { - void queryClient.invalidateQueries({ queryKey: trpc.storyFolder.listTree.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.storyFolder.listItems.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.story.listAll.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.story.listArchived.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.story.listStandaloneArchived.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.storyShare.list.queryKey() }); - void queryClient.invalidateQueries({ queryKey: trpc.favorite.list.queryKey() }); + invalidateStoriesCaches(queryClient); navigate({ to: '/stories', search: { folderId: null } }); if (isMobile) { closeMobile(); diff --git a/apps/frontend/src/hooks/use-keyboard-shortcuts.ts b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts index 1a7920dba..6af5726db 100644 --- a/apps/frontend/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts @@ -20,7 +20,9 @@ export function useKeyboardShortcuts(handlers: ShortcutHandlers) { const handler = handlersRef.current[entry.id]; if (handler && matchesShortcut(event, entry.shortcut)) { event.preventDefault(); - handler(); + if (!event.repeat) { + handler(); + } return; } } diff --git a/apps/frontend/src/lib/platform.ts b/apps/frontend/src/lib/platform.ts index 10899eebd..288d39cc8 100644 --- a/apps/frontend/src/lib/platform.ts +++ b/apps/frontend/src/lib/platform.ts @@ -43,9 +43,10 @@ export function formatShortcutLabel(shortcut: Shortcut): string { } export function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean { - const modPressed = event.metaKey || event.ctrlKey; + const modPressed = isMac ? event.metaKey : event.ctrlKey; + const otherModPressed = isMac ? event.ctrlKey : event.metaKey; - if (modPressed !== Boolean(shortcut.mod)) { + if (modPressed !== Boolean(shortcut.mod) || otherModPressed) { return false; } if (event.shiftKey !== Boolean(shortcut.shift) || event.altKey !== Boolean(shortcut.alt)) { diff --git a/apps/frontend/src/lib/stories-cache.ts b/apps/frontend/src/lib/stories-cache.ts new file mode 100644 index 000000000..6cdc87ccd --- /dev/null +++ b/apps/frontend/src/lib/stories-cache.ts @@ -0,0 +1,14 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { trpc } from '@/main'; + +export function invalidateStoriesCaches(queryClient: QueryClient): void { + void queryClient.invalidateQueries({ queryKey: trpc.storyFolder.listTree.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.storyFolder.listItems.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listAll.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listArchived.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listStandaloneArchived.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.storyShare.list.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.favorite.list.queryKey() }); +} From 3e5a1033b5efe38b1df82483e0459422427077d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 08:48:56 +0000 Subject: [PATCH 4/5] Fixed Cubic complaints --- apps/frontend/src/lib/stories-cache.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/lib/stories-cache.ts b/apps/frontend/src/lib/stories-cache.ts index 6cdc87ccd..4606ba70c 100644 --- a/apps/frontend/src/lib/stories-cache.ts +++ b/apps/frontend/src/lib/stories-cache.ts @@ -9,6 +9,7 @@ export function invalidateStoriesCaches(queryClient: QueryClient): void { void queryClient.invalidateQueries({ queryKey: trpc.story.listStandalone.queryKey() }); void queryClient.invalidateQueries({ queryKey: trpc.story.listArchived.queryKey() }); void queryClient.invalidateQueries({ queryKey: trpc.story.listStandaloneArchived.queryKey() }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listSharedArchived.queryKey() }); void queryClient.invalidateQueries({ queryKey: trpc.storyShare.list.queryKey() }); void queryClient.invalidateQueries({ queryKey: trpc.favorite.list.queryKey() }); } From 1abd70600348c04972baf64aa08d504bb253251d Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 23 Jul 2026 11:57:19 +0200 Subject: [PATCH 5/5] New shortcut: Ctrl + C to cancel/edit prompt --- apps/backend/src/queries/chat.queries.ts | 115 ++++++++++++++++-- apps/backend/src/services/agent.ts | 26 +++- apps/backend/src/trpc/chat.routes.ts | 44 +++++++ apps/backend/src/utils/ai.ts | 10 ++ apps/frontend/src/components/chat-input.tsx | 41 ++++++- .../chat-messages/assistant-message.tsx | 6 + apps/frontend/src/contexts/agent.provider.tsx | 5 + apps/frontend/src/hooks/use-agent.ts | 59 +++++++++ .../src/hooks/use-keyboard-shortcuts.ts | 18 ++- apps/frontend/src/lib/keyboard-shortcuts.ts | 17 ++- apps/frontend/src/lib/platform.ts | 17 +++ 11 files changed, 333 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/queries/chat.queries.ts b/apps/backend/src/queries/chat.queries.ts index 7396b7e1f..aaf77d1da 100644 --- a/apps/backend/src/queries/chat.queries.ts +++ b/apps/backend/src/queries/chat.queries.ts @@ -5,7 +5,7 @@ import type { GroupedChatListResponse, LlmProvider, } from '@nao/shared/types'; -import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, like, ne, or, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, like, ne, or, sql } from 'drizzle-orm'; import s, { DBChat, @@ -262,17 +262,11 @@ const aggregateChatMessagParts = ( if (acc[row.chat_message.id]) { acc[row.chat_message.id].parts.push(uiPart); } else { - acc[row.chat_message.id] = { - id: row.chat_message.id, - role: row.chat_message.role, - parts: [uiPart], - feedback: row.message_feedback ?? undefined, - source: row.chat_message.source ?? undefined, - isForked: row.chat_message.isForked ?? undefined, - citation: row.chat_message.citation ?? undefined, - stopReason: row.chat_message.stopReason ?? undefined, - createdAt: row.chat_message.createdAt?.getTime(), - }; + acc[row.chat_message.id] = convertDBMessageToUIMessage( + row.chat_message, + [uiPart], + row.message_feedback, + ); } return acc; }, @@ -282,6 +276,22 @@ const aggregateChatMessagParts = ( return Object.values(messagesMap); }; +const convertDBMessageToUIMessage = ( + message: DBChatMessage, + parts: UIMessagePart[], + feedback?: MessageFeedback | null, +): UIMessage => ({ + id: message.id, + role: message.role, + parts, + feedback: feedback ?? undefined, + source: message.source ?? undefined, + isForked: message.isForked ?? undefined, + citation: message.citation ?? undefined, + stopReason: message.stopReason ?? undefined, + createdAt: message.createdAt?.getTime(), +}); + export const getChatMessages = async (chatId: string): Promise => { const result = await db .select() @@ -294,6 +304,87 @@ export const getChatMessages = async (chatId: string): Promise => { return aggregateChatMessagParts(result); }; +export const getLastTurn = async ( + chatId: string, +): Promise< + | { + userMessageId: string; + versionGroupId: string | null; + versionGroupSize: number; + assistantMessage?: UIMessage; + } + | undefined +> => { + const activeMessages = await db + .select() + .from(s.chatMessage) + .where(and(eq(s.chatMessage.chatId, chatId), isNull(s.chatMessage.supersededAt))) + .orderBy(desc(s.chatMessage.createdAt)) + .execute(); + const lastMessage = activeMessages.at(0); + if (!lastMessage || lastMessage.role === 'system') { + return undefined; + } + + const userMessage = + lastMessage.role === 'user' ? lastMessage : activeMessages.find((message) => message.role === 'user'); + if (!userMessage) { + return undefined; + } + + const versionGroupSize = userMessage.versionGroupId + ? await countMessagesInVersionGroup(chatId, userMessage.versionGroupId) + : 1; + const assistantMessage = + lastMessage.role === 'assistant' ? await getMessageWithParts(chatId, lastMessage) : undefined; + + return { + userMessageId: userMessage.id, + versionGroupId: userMessage.versionGroupId, + versionGroupSize, + assistantMessage, + }; +}; + +const countMessagesInVersionGroup = async (chatId: string, versionGroupId: string): Promise => { + const [result] = await db + .select({ value: count() }) + .from(s.chatMessage) + .where(and(eq(s.chatMessage.chatId, chatId), eq(s.chatMessage.versionGroupId, versionGroupId))) + .execute(); + return result?.value ?? 0; +}; + +const getMessageWithParts = async (chatId: string, message: DBChatMessage): Promise => { + const result = await db + .select() + .from(s.chatMessage) + .innerJoin(s.messagePart, eq(s.messagePart.messageId, s.chatMessage.id)) + .where(and(eq(s.chatMessage.chatId, chatId), eq(s.chatMessage.id, message.id))) + .orderBy(asc(s.messagePart.order)) + .execute(); + return aggregateChatMessagParts(result).at(0) ?? convertDBMessageToUIMessage(message, []); +}; + +export const deleteMessagesByIds = async (chatId: string, ids: string[]): Promise => { + if (ids.length === 0) { + return; + } + await db + .delete(s.chatMessage) + .where(and(eq(s.chatMessage.chatId, chatId), inArray(s.chatMessage.id, ids))) + .execute(); +}; + +export const countActiveMessages = async (chatId: string): Promise => { + const [result] = await db + .select({ value: count() }) + .from(s.chatMessage) + .where(and(eq(s.chatMessage.chatId, chatId), isNull(s.chatMessage.supersededAt))) + .execute(); + return result?.value ?? 0; +}; + export const getChatOwnerId = async (chatId: string): Promise => { const [result] = await db .select({ diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index 8532ad17d..39085d697 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -344,6 +344,8 @@ export const MAX_OUTPUT_TOKENS = 16_000; class AgentManager { private readonly _agent: ToolLoopAgent; + private readonly _finished: Promise; + private _resolveFinished: (() => void) | undefined; private _streamWriter?: UIMessageStreamWriter; constructor( @@ -357,6 +359,9 @@ class AgentManager { stopWhen: StopCondition[] = [hasToolCall('suggest_follow_ups'), hasToolCall('clarification')], private readonly _systemPromptOverride?: string, ) { + this._finished = new Promise((resolve) => { + this._resolveFinished = resolve; + }); const callSettings = this._modelConfig.callSettings ?? {}; const provider = this._modelSelection.provider; const providerOptions = fitThinkingBudget(this._modelConfig.providerOptions, this._maxOutputTokens); @@ -518,7 +523,7 @@ class AgentManager { llmModelId: this._modelSelection.modelId, }); } finally { - this._onDispose(); + this._finish(); } }, }); @@ -802,7 +807,7 @@ class AgentManager { responseParts: [], }; } finally { - this._onDispose(); + this._finish(); } } @@ -814,6 +819,23 @@ class AgentManager { this._abortController.abort(); } + waitUntilFinished(): Promise { + return this._finished; + } + + private _markFinished(): void { + this._resolveFinished?.(); + this._resolveFinished = undefined; + } + + private _finish(): void { + try { + this._onDispose(); + } finally { + this._markFinished(); + } + } + private _addCitationContext(messages: UIMessage[]): UIMessage[] { const [lastUserMessage] = findLastUserMessage(messages); if (!lastUserMessage?.citation) { diff --git a/apps/backend/src/trpc/chat.routes.ts b/apps/backend/src/trpc/chat.routes.ts index 9f8eef1bf..7b28b4eff 100644 --- a/apps/backend/src/trpc/chat.routes.ts +++ b/apps/backend/src/trpc/chat.routes.ts @@ -80,6 +80,45 @@ export const chatRoutes = { posthog.capture(ctx.user.id, PostHogEvent.AgentStopped, { project_id: projectId, chat_id: input.chatId }); }), + cancel: chatOwnerProcedure.input(z.object({ chatId: z.string(), hadContent: z.boolean() })).mutation( + async ({ + input, + ctx, + }): Promise<{ + outcome: 'deleted' | 'kept'; + chatDeleted: boolean; + }> => { + const agent = agentService.get(input.chatId); + if (agent) { + agent.stop(); + posthog.capture(ctx.user.id, PostHogEvent.AgentStopped, { + project_id: agent.chat.projectId, + chat_id: input.chatId, + }); + await Promise.race([agent.waitUntilFinished(), delay(10_000)]); + } + + const turn = await chatQueries.getLastTurn(input.chatId); + if (!turn) { + return { outcome: 'kept', chatDeleted: false }; + } + + if (input.hadContent || turn.versionGroupSize > 1) { + return { outcome: 'kept', chatDeleted: false }; + } + + const idsToDelete = [turn.userMessageId, ...(turn.assistantMessage ? [turn.assistantMessage.id] : [])]; + await chatQueries.deleteMessagesByIds(input.chatId, idsToDelete); + + const remaining = await chatQueries.countActiveMessages(input.chatId); + if (remaining === 0) { + await chatQueries.deleteChat(input.chatId); + return { outcome: 'deleted', chatDeleted: true }; + } + return { outcome: 'deleted', chatDeleted: false }; + }, + ), + rename: chatOwnerProcedure .input(z.object({ chatId: z.string(), title: z.string().min(1).max(255) })) .mutation(async ({ input, ctx }): Promise => { @@ -135,3 +174,8 @@ export const chatRoutes = { return usage; }), }; + +const delay = (milliseconds: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); diff --git a/apps/backend/src/utils/ai.ts b/apps/backend/src/utils/ai.ts index b665c3825..5aa265c91 100644 --- a/apps/backend/src/utils/ai.ts +++ b/apps/backend/src/utils/ai.ts @@ -97,6 +97,16 @@ export const createChatTitle = ({ text }: { text: string }) => { return text.slice(0, 64); }; +export const checkAssistantMessageHasContent = (message: UIMessage): boolean => + message.parts.some( + (part) => + part.type !== 'step-start' && + part.type !== 'tool-suggest_follow_ups' && + part.type !== 'reasoning' && + part.type !== 'data-newChat' && + part.type !== 'data-newUserMessage', + ); + export const joinAllTextParts = (message: UIMessage, separator: string = '\n'): string => { return message.parts .filter((part) => part.type === 'text') diff --git a/apps/frontend/src/components/chat-input.tsx b/apps/frontend/src/components/chat-input.tsx index d830f12fd..593dab639 100644 --- a/apps/frontend/src/components/chat-input.tsx +++ b/apps/frontend/src/components/chat-input.tsx @@ -33,6 +33,7 @@ import { cn } from '@/lib/utils'; import { useChatId } from '@/hooks/use-chat-id'; import { usePermissions } from '@/hooks/use-permissions'; import { messageQueueStore } from '@/stores/chat-message-queue'; +import { chatInputRestoreStore, useChatInputRestore } from '@/stores/chat-input-restore'; import { chatPendingCitationStore } from '@/stores/chat-pending-citation'; import { useChatPendingCitation } from '@/hooks/use-chat-pending-citation'; import { SelectionCitationBanner } from '@/components/selection-citation-banner'; @@ -96,7 +97,7 @@ function ChatInputBase({ const [inputText, setInputText] = useState(''); const { isRunning, - stopAgent, + cancelAgent, isLoadingMessages, adminMode, setAdminMode, @@ -110,6 +111,7 @@ function ChatInputBase({ const isAdminMode = isAdmin && adminMode; const imageUpload = useImageUpload(); + const chatInputRestore = useChatInputRestore(!!allowQueueing); const effectivePlaceholder = isRunning && allowQueueing ? 'Add a follow-up...' : placeholder; const agentSettings = useQuery(trpc.project.getAgentSettings.queryOptions()); @@ -132,6 +134,33 @@ function ChatInputBase({ useEffect(() => promptRef.current?.focus(), [chatId, promptRef]); + useEffect(() => { + if (!allowQueueing || !chatInputRestore) { + return; + } + + chatInputRestoreStore.clear(); + promptRef.current?.clear(); + promptRef.current?.insertText(chatInputRestore.text); + setInputText(chatInputRestore.text); + imageUpload.clearImages(); + + if (chatInputRestore.citation && chatId) { + chatPendingCitationStore.set({ ...chatInputRestore.citation, chatId }); + } + + const restoreImages = async () => { + const files = await Promise.all( + chatInputRestore.images.map(({ url, mediaType }, index) => + dataUrlToFile(url, mediaType, `image-${index + 1}`), + ), + ); + await imageUpload.addFiles(files); + requestAnimationFrame(() => promptRef.current?.focus()); + }; + restoreImages(); + }, [allowQueueing, chatInputRestore]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { const el = dropZoneRef.current; if (!el) { @@ -382,14 +411,14 @@ function ChatInputBase({ ) : ( )} @@ -401,6 +430,12 @@ function ChatInputBase({ ); } +async function dataUrlToFile(url: string, mediaType: string, name: string): Promise { + const response = await fetch(url); + const blob = await response.blob(); + return new File([blob], name, { type: mediaType }); +} + const CHAT_INPUT_BORDER_RADIUS = 18; const CHAT_INPUT_BORDER_STROKE = 1; diff --git a/apps/frontend/src/components/chat-messages/assistant-message.tsx b/apps/frontend/src/components/chat-messages/assistant-message.tsx index a5e559e85..fc7c772e0 100644 --- a/apps/frontend/src/components/chat-messages/assistant-message.tsx +++ b/apps/frontend/src/components/chat-messages/assistant-message.tsx @@ -11,6 +11,7 @@ import { TextShimmer } from '@/components/ui/text-shimmer'; import { AssistantMessageActions } from '@/components/chat-messages/assistant-message-actions'; import { cn, isLast } from '@/lib/utils'; import { useChatId } from '@/hooks/use-chat-id'; +import { useIsCancellingMessage } from '@/hooks/use-is-cancelling-message-store'; import { useToolCallDensity } from '@/hooks/use-tool-call-density'; import { AssistantMessageProvider, useAssistantMessage } from '@/contexts/assistant-message'; @@ -37,6 +38,7 @@ export const AssistantMessage = memo( [message.parts, toolCallDensity], ); const hasContent = useMemo(() => checkAssistantMessageHasContent(message), [message]); + const isCancelling = useIsCancellingMessage(message.id); const isCompacting = message.parts.at(-1)?.type === 'data-compactionSummaryStarted'; const showActions = message.id !== storyIntroMessageId; const hasFeedback = message.feedback != null; @@ -45,6 +47,10 @@ export const AssistantMessage = memo( return null; } + if (isCancelling && isSettled && !hasContent) { + return null; + } + return (
diff --git a/apps/frontend/src/contexts/agent.provider.tsx b/apps/frontend/src/contexts/agent.provider.tsx index bb0a2f7d3..ec9bf1785 100644 --- a/apps/frontend/src/contexts/agent.provider.tsx +++ b/apps/frontend/src/contexts/agent.provider.tsx @@ -3,6 +3,7 @@ import type { UIMessage } from '@nao/backend/chat'; import type { AgentHelpers } from '@/hooks/use-agent'; import { useAgent, useSyncMessages } from '@/hooks/use-agent'; +import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useStreamEndSound } from '@/hooks/use-stream-end-sound'; export const AgentContext = createContext(null); @@ -25,6 +26,9 @@ export interface Props { export const AgentProvider = ({ children, disableNavigation }: Props) => { const agent = useAgent({ disableNavigation }); + useKeyboardShortcuts({ + 'stop-generation': agent.isRunning ? agent.cancelAgent : undefined, + }); useSyncMessages({ agent }); useStreamEndSound(agent.isRunning); @@ -54,6 +58,7 @@ export const ReadonlyAgentMessagesProvider = ({ isRunning: false, isLoadingMessages: false, stopAgent: noopPromise, + cancelAgent: noopPromise, error: undefined, clearError: noop, selectedModel: null, diff --git a/apps/frontend/src/hooks/use-agent.ts b/apps/frontend/src/hooks/use-agent.ts index e58d1f116..821aa3531 100644 --- a/apps/frontend/src/hooks/use-agent.ts +++ b/apps/frontend/src/hooks/use-agent.ts @@ -15,9 +15,11 @@ import type { MentionOption } from 'prompt-mentions'; import { getActiveProjectId } from '@/lib/active-project'; import { + checkAssistantMessageHasContent, checkIsAgentRunning, extractImagesFromMessage, getLastUserMessageIdx, + getMessageImages, getMessageText, getTextFromUserMessageOrThrow, NEW_CHAT_ID, @@ -28,6 +30,9 @@ import { trpc } from '@/main'; import { useChatQuery, useSetChat } from '@/queries/use-chat-query'; import { agentService } from '@/services/agents'; import { chatActivityStore } from '@/stores/chat-activity'; +import { cancellingMessageIdStore } from '@/stores/chat-cancelling-message'; +import { editedMessageIdStore } from '@/stores/chat-edited-message'; +import { chatInputRestoreStore } from '@/stores/chat-input-restore'; import { messageQueueStore } from '@/stores/chat-message-queue'; export interface AgentHelpers { @@ -43,6 +48,7 @@ export interface AgentHelpers { isRunning: boolean; isLoadingMessages: boolean; stopAgent: () => Promise; + cancelAgent: () => Promise; error: Error | undefined; clearError: UseChatHelpers['clearError']; selectedModel: LlmSelectedModel | null; @@ -228,6 +234,7 @@ export const useAgent = ({ disableNavigation = false }: { disableNavigation?: bo const { status, error, clearError, sendMessage, setMessages, messages } = useChat({ chat: agentInstance }); const stopAgentMutation = useMutation(trpc.chat.stop.mutationOptions()); + const cancelAgentMutation = useMutation(trpc.chat.cancel.mutationOptions()); const switchMessageVersionMutation = useMutation(trpc.chat.switchMessageVersion.mutationOptions()); const isRunning = checkIsAgentRunning({ status }); @@ -289,6 +296,57 @@ export const useAgent = ({ disableNavigation = false }: { disableNavigation?: bo await stopAgentMutation.mutateAsync({ chatId }); }, [chatId, agentInstance, stopAgentMutation.mutateAsync]); // eslint-disable-line + const cancelAgent = useCallback(async () => { + if (!chatId) { + return; + } + + const lastUserMessageIndex = getLastUserMessageIdx(messages); + const lastUserMessage = lastUserMessageIndex !== undefined ? messages[lastUserMessageIndex] : undefined; + const lastMessage = messages.at(-1); + const lastAssistantMessage = lastMessage?.role === 'assistant' ? lastMessage : undefined; + const hadContent = lastAssistantMessage ? checkAssistantMessageHasContent(lastAssistantMessage) : false; + + if (lastAssistantMessage) { + cancellingMessageIdStore.setCancelling(lastAssistantMessage.id); + } + agentInstance.stop(); + + try { + const result = await cancelAgentMutation.mutateAsync({ chatId, hadContent }); + + if (result.outcome === 'kept') { + if (lastUserMessage) { + editedMessageIdStore.setEditingId(lastUserMessage.id); + } + return; + } + + if (lastUserMessageIndex !== undefined) { + setMessages(messages.slice(0, lastUserMessageIndex)); + } + const restorePayload = lastUserMessage + ? { + text: getMessageText(lastUserMessage), + images: getMessageImages(lastUserMessage), + citation: lastUserMessage.citation, + } + : undefined; + + if (result.chatDeleted) { + queryClient.invalidateQueries({ queryKey: [['chat', 'listGrouped']] }); + await navigate({ to: '/' }); + if (restorePayload) { + chatInputRestoreStore.set(restorePayload); + } + } else if (restorePayload) { + chatInputRestoreStore.set(restorePayload); + } + } finally { + cancellingMessageIdStore.setCancelling(undefined); + } + }, [chatId, messages, agentInstance, cancelAgentMutation, setMessages, navigate, queryClient]); + const handleSendMessage = useCallback['sendMessage']>( async (...args) => { clearError(); @@ -405,6 +463,7 @@ export const useAgent = ({ disableNavigation = false }: { disableNavigation?: bo isRunning, isLoadingMessages: chat.isLoading, stopAgent, + cancelAgent, error, clearError, selectedModel, diff --git a/apps/frontend/src/hooks/use-keyboard-shortcuts.ts b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts index 1a7920dba..0ae926a08 100644 --- a/apps/frontend/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/frontend/src/hooks/use-keyboard-shortcuts.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; -import type { ShortcutId } from '@/lib/keyboard-shortcuts'; +import type { ShortcutDefinition, ShortcutId } from '@/lib/keyboard-shortcuts'; import { isTypingTarget, SHORTCUTS } from '@/lib/keyboard-shortcuts'; import { matchesShortcut } from '@/lib/platform'; @@ -12,13 +12,15 @@ export function useKeyboardShortcuts(handlers: ShortcutHandlers) { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (isTypingTarget(event)) { - return; - } - for (const entry of SHORTCUTS) { const handler = handlersRef.current[entry.id]; - if (handler && matchesShortcut(event, entry.shortcut)) { + if (!handler) { + continue; + } + if (isTypingTarget(event) && !isAllowedWhileTyping(entry)) { + continue; + } + if (matchesShortcut(event, entry.shortcut)) { event.preventDefault(); handler(); return; @@ -30,3 +32,7 @@ export function useKeyboardShortcuts(handlers: ShortcutHandlers) { return () => document.removeEventListener('keydown', handleKeyDown); }, []); } + +function isAllowedWhileTyping(entry: ShortcutDefinition): boolean { + return Boolean(entry.allowInInput || entry.shortcut.mod || entry.shortcut.ctrl || entry.shortcut.alt); +} diff --git a/apps/frontend/src/lib/keyboard-shortcuts.ts b/apps/frontend/src/lib/keyboard-shortcuts.ts index 6bbc52038..ebab52a2f 100644 --- a/apps/frontend/src/lib/keyboard-shortcuts.ts +++ b/apps/frontend/src/lib/keyboard-shortcuts.ts @@ -1,14 +1,21 @@ import type { Shortcut } from '@/lib/platform'; import { formatShortcut, formatShortcutLabel } from '@/lib/platform'; -export type ShortcutId = 'toggle-sidebar' | 'command-menu' | 'new-chat' | 'go-to-stories' | 'keyboard-help'; -export type ShortcutGroup = 'General' | 'Navigation'; +export type ShortcutId = + | 'toggle-sidebar' + | 'command-menu' + | 'new-chat' + | 'go-to-stories' + | 'keyboard-help' + | 'stop-generation'; +export type ShortcutGroup = 'General' | 'Navigation' | 'Chat'; export type ShortcutDefinition = { id: ShortcutId; label: string; group: ShortcutGroup; shortcut: Shortcut; + allowInInput?: boolean; }; export const SHORTCUTS: readonly ShortcutDefinition[] = [ @@ -42,6 +49,12 @@ export const SHORTCUTS: readonly ShortcutDefinition[] = [ group: 'Navigation', shortcut: { mod: true, shift: true, key: 's' }, }, + { + id: 'stop-generation', + label: 'Cancel prompt', + group: 'Chat', + shortcut: { ctrl: true, key: 'c' }, + }, ]; export function getShortcut(id: ShortcutId): ShortcutDefinition { diff --git a/apps/frontend/src/lib/platform.ts b/apps/frontend/src/lib/platform.ts index 10899eebd..86e319c7a 100644 --- a/apps/frontend/src/lib/platform.ts +++ b/apps/frontend/src/lib/platform.ts @@ -1,5 +1,6 @@ export type Shortcut = { mod?: boolean; + ctrl?: boolean; shift?: boolean; alt?: boolean; key: string; @@ -13,6 +14,9 @@ export function formatShortcut(shortcut: Shortcut): string[] { const tokens: string[] = []; if (isMac) { + if (shortcut.ctrl) { + tokens.push('⌃'); + } if (shortcut.alt) { tokens.push('⌥'); } @@ -23,6 +27,9 @@ export function formatShortcut(shortcut: Shortcut): string[] { tokens.push('⌘'); } } else { + if (shortcut.ctrl) { + tokens.push('Ctrl'); + } if (shortcut.mod) { tokens.push('Ctrl'); } @@ -43,6 +50,16 @@ export function formatShortcutLabel(shortcut: Shortcut): string { } export function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean { + if (shortcut.ctrl) { + return ( + event.ctrlKey && + !event.metaKey && + event.shiftKey === Boolean(shortcut.shift) && + event.altKey === Boolean(shortcut.alt) && + event.key.toLowerCase() === shortcut.key.toLowerCase() + ); + } + const modPressed = event.metaKey || event.ctrlKey; if (modPressed !== Boolean(shortcut.mod)) {