From 0a768992bc465a6f440a9833602cb07f170615a6 Mon Sep 17 00:00:00 2001 From: Richard Emijere <67247138+The-CodeINN@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:58:45 +0000 Subject: [PATCH 1/4] feat: Implement keyboard shortcuts management hook and navigation shortcuts for Agent Inbox --- src/hooks/use-keyboard-shortcuts.tsx | 222 +++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/hooks/use-keyboard-shortcuts.tsx diff --git a/src/hooks/use-keyboard-shortcuts.tsx b/src/hooks/use-keyboard-shortcuts.tsx new file mode 100644 index 0000000..a9afd86 --- /dev/null +++ b/src/hooks/use-keyboard-shortcuts.tsx @@ -0,0 +1,222 @@ +import React from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useQueryParams } from "@/components/agent-inbox/hooks/use-query-params"; +import { VIEW_STATE_THREAD_QUERY_PARAM } from "@/components/agent-inbox/constants"; + +export type KeyboardShortcut = { + key: string; + description: string; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; + handler: (e: KeyboardEvent) => void; +}; + +type KeyboardShortcutsOptions = { + shortcuts: KeyboardShortcut[]; + isEnabled?: boolean; +}; + +/** + * A hook for managing keyboard shortcuts in the application + * + * @param options - Configuration options for keyboard shortcuts + * @returns - Object containing active state and keyboard shortcuts list + */ +export function useKeyboardShortcuts({ + shortcuts, + isEnabled = true, +}: KeyboardShortcutsOptions) { + // Track active state to be able to enable/disable shortcuts + const [active, setActive] = React.useState(isEnabled); + + // Track whether text input or textarea is focused + const isInputActive = useRef(false); + + // Handler for keydown events + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // Skip if shortcuts are disabled or if we're typing in an input field + if (!active || isInputActive.current) return; + + // Skip if the event comes from an input field + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && + (event.target.isContentEditable || + event.target.tagName === "INPUT" || + event.target.tagName === "TEXTAREA")) + ) { + isInputActive.current = true; + return; + } + + // Find the matching shortcut + const shortcut = shortcuts.find( + (s) => + s.key.toLowerCase() === event.key.toLowerCase() && + !!s.ctrlKey === event.ctrlKey && + !!s.altKey === event.altKey && + !!s.shiftKey === event.shiftKey && + !!s.metaKey === event.metaKey + ); + + if (shortcut) { + event.preventDefault(); + shortcut.handler(event); + } + }, + [active, shortcuts] + ); + + // Handle focus/blur events to track when inputs are active + const handleFocus = useCallback((event: FocusEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && + (event.target.isContentEditable || + event.target.tagName === "INPUT" || + event.target.tagName === "TEXTAREA")) + ) { + isInputActive.current = true; + } + }, []); + + const handleBlur = useCallback(() => { + isInputActive.current = false; + }, []); + + useEffect(() => { + if (!isEnabled) { + setActive(false); + return; + } + + window.addEventListener("keydown", handleKeyDown); + document.addEventListener("focusin", handleFocus); + document.addEventListener("focusout", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("focusin", handleFocus); + document.removeEventListener("focusout", handleBlur); + }; + }, [handleKeyDown, handleFocus, handleBlur, isEnabled]); + + return { + active, + setActive, + shortcuts, + }; +} + +/** + * Hook specifically for navigation shortcuts in the Agent Inbox + * + * @returns Configured keyboard shortcuts for Agent Inbox navigation + */ +export function useAgentInboxShortcuts() { + const { updateQueryParams } = useQueryParams(); + + // Create a reference for any element that should be focused + const responseInputRef = useRef(null); + + // Create a reference for text inputs to be able to undo changes + const textInputValuesRef = useRef>(new Map()); + + // Method to close the current thread and return to inbox + const closeThread = useCallback(() => { + updateQueryParams(VIEW_STATE_THREAD_QUERY_PARAM); + }, [updateQueryParams]); + + // Method to focus on the response input field + const focusResponseInput = useCallback(() => { + if (responseInputRef.current) { + responseInputRef.current.focus(); + } else { + // If the ref isn't available, find the first textarea in the inbox item input + const responseTextarea = document.querySelector( + ".InboxItemInput textarea" + ); + if (responseTextarea instanceof HTMLTextAreaElement) { + responseTextarea.focus(); + } + } + }, []); + + // Method to navigate between threads using arrow keys + const navigateThreads = useCallback((direction: "up" | "down") => { + const threadItems = Array.from( + document.querySelectorAll( + '[class*="InterruptedInboxItem"], [class*="GenericInboxItem"]' + ) + ); + + if (!threadItems.length) return; + + // Find currently selected thread or get the first one + const currentThreadId = new URLSearchParams(window.location.search).get( + VIEW_STATE_THREAD_QUERY_PARAM + ); + let currentIndex = -1; + + if (currentThreadId) { + // If we're in thread view, we'll navigate from the inbox when returning + return; + } + + // Find the thread that has a visual indicator of being selected + threadItems.forEach((item, index) => { + if ( + item.classList.contains("selected") || + item.classList.contains("active") + ) { + currentIndex = index; + } + }); + + // Calculate new index with wrap-around + let newIndex; + if (direction === "up") { + newIndex = currentIndex <= 0 ? threadItems.length - 1 : currentIndex - 1; + } else { + newIndex = currentIndex >= threadItems.length - 1 ? 0 : currentIndex + 1; + } + + // Simulate a click on the thread to navigate to it + (threadItems[newIndex] as HTMLElement).click(); + }, []); + + // Method to undo changes in all inputs + const undoChanges = useCallback(() => { + // Find all textareas and reset them to their original value + const textareas = document.querySelectorAll("textarea"); + textareas.forEach((textarea) => { + const originalValue = textInputValuesRef.current.get(textarea); + if (originalValue !== undefined) { + textarea.value = originalValue; + // Dispatch input event to trigger React's onChange handlers + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } + }); + + // Find all reset buttons and click them + const resetButtons = document.querySelectorAll('button[class*="reset"]'); + resetButtons.forEach((button) => { + (button as HTMLButtonElement).click(); + }); + }, []); + + // Return the keyboard shortcut handlers and refs without creating unused variables + return { + responseInputRef, + textInputValuesRef, + closeThread, + focusResponseInput, + navigateThreads, + undoChanges, + }; +} From 6333a9602ffc5dd393795f5cc314c349a5e5aba4 Mon Sep 17 00:00:00 2001 From: Richard Emijere <67247138+The-CodeINN@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:59:00 +0000 Subject: [PATCH 2/4] feat: Add keyboard shortcuts for thread navigation in Agent Inbox --- src/components/agent-inbox/inbox-view.tsx | 104 ++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/components/agent-inbox/inbox-view.tsx b/src/components/agent-inbox/inbox-view.tsx index 454dc53..971f5a2 100644 --- a/src/components/agent-inbox/inbox-view.tsx +++ b/src/components/agent-inbox/inbox-view.tsx @@ -7,6 +7,7 @@ import { ThreadStatusWithAll } from "./types"; import { Pagination } from "./components/pagination"; import { Inbox as InboxIcon, LoaderCircle } from "lucide-react"; import { InboxButtons } from "./components/inbox-buttons"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; interface AgentInboxViewProps< _ThreadValues extends Record = Record, @@ -23,6 +24,109 @@ export function AgentInboxView< const selectedInbox = (getSearchParam(INBOX_PARAM) || "interrupted") as ThreadStatusWithAll; const scrollableContentRef = React.useRef(null); + const [selectedThreadIndex, setSelectedThreadIndex] = + React.useState(-1); + + // Setup keyboard shortcuts for thread navigation with arrow keys + const navigateThreadsUp = React.useCallback(() => { + const threadItems = document.querySelectorAll( + '[class*="InterruptedInboxItem"], [class*="GenericInboxItem"]' + ); + if (!threadItems.length) return; + + // Navigate to previous thread with wrap-around + const newIndex = + selectedThreadIndex <= 0 + ? threadItems.length - 1 + : selectedThreadIndex - 1; + setSelectedThreadIndex(newIndex); + + // Scroll the selected thread into view + threadItems[newIndex].scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + // Add visual indicator (could be done with a class or style) + threadItems.forEach((item, idx) => { + if (idx === newIndex) { + item.classList.add("thread-selected"); + } else { + item.classList.remove("thread-selected"); + } + }); + }, [selectedThreadIndex]); + + const navigateThreadsDown = React.useCallback(() => { + const threadItems = document.querySelectorAll( + '[class*="InterruptedInboxItem"], [class*="GenericInboxItem"]' + ); + if (!threadItems.length) return; + + // Navigate to next thread with wrap-around + const newIndex = + selectedThreadIndex >= threadItems.length - 1 + ? 0 + : selectedThreadIndex + 1; + setSelectedThreadIndex(newIndex); + + // Scroll the selected thread into view + threadItems[newIndex].scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + // Add visual indicator + threadItems.forEach((item, idx) => { + if (idx === newIndex) { + item.classList.add("thread-selected"); + } else { + item.classList.remove("thread-selected"); + } + }); + }, [selectedThreadIndex]); + + const openSelectedThread = React.useCallback(() => { + const threadItems = document.querySelectorAll( + '[class*="InterruptedInboxItem"], [class*="GenericInboxItem"]' + ); + if ( + !threadItems.length || + selectedThreadIndex < 0 || + selectedThreadIndex >= threadItems.length + ) + return; + + // First save scroll position + handleThreadClick(); + + // Then simulate click on the selected thread + (threadItems[selectedThreadIndex] as HTMLElement).click(); + }, [selectedThreadIndex]); + + // Configure keyboard shortcuts + useKeyboardShortcuts({ + shortcuts: React.useMemo( + () => [ + { + key: "ArrowUp", + description: "Navigate to previous thread", + handler: navigateThreadsUp, + }, + { + key: "ArrowDown", + description: "Navigate to next thread", + handler: navigateThreadsDown, + }, + { + key: "Enter", + description: "Open selected thread", + handler: openSelectedThread, + }, + ], + [navigateThreadsUp, navigateThreadsDown, openSelectedThread] + ), + }); // Register scroll event listener to automatically save scroll position whenever user scrolls React.useEffect(() => { From 8ae0d616401bdcb0b5910a878011583cf8e7ff59 Mon Sep 17 00:00:00 2001 From: Richard Emijere <67247138+The-CodeINN@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:59:09 +0000 Subject: [PATCH 3/4] feat: Add keyboard shortcuts for closing thread and returning to inbox in ThreadView --- src/components/agent-inbox/thread-view.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/components/agent-inbox/thread-view.tsx b/src/components/agent-inbox/thread-view.tsx index 1f9243f..becf861 100644 --- a/src/components/agent-inbox/thread-view.tsx +++ b/src/components/agent-inbox/thread-view.tsx @@ -6,6 +6,10 @@ import React from "react"; import { cn } from "@/lib/utils"; import { useQueryParams } from "./hooks/use-query-params"; import { VIEW_STATE_THREAD_QUERY_PARAM } from "./constants"; +import { + useKeyboardShortcuts, + useAgentInboxShortcuts, +} from "@/hooks/use-keyboard-shortcuts"; export function ThreadView< ThreadValues extends Record = Record, @@ -18,6 +22,23 @@ export function ThreadView< const [showState, setShowState] = React.useState(false); const showSidePanel = showDescription || showState; + // Use our custom hook that provides keyboard shortcut handlers + const { closeThread } = useAgentInboxShortcuts(); + + // Setup keyboard shortcuts for navigating back to inbox + const shortcuts = React.useMemo( + () => [ + { + key: "e", + description: "Close thread and return to inbox", + handler: closeThread, + }, + ], + [closeThread] + ); + + useKeyboardShortcuts({ shortcuts }); + // Scroll to top when thread view is mounted React.useEffect(() => { if (typeof window !== "undefined") { From 11b682ecd4bb95f1904114adf2e11030dfe87cc7 Mon Sep 17 00:00:00 2001 From: Richard Emijere <67247138+The-CodeINN@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:59:18 +0000 Subject: [PATCH 4/4] feat: Integrate keyboard shortcuts and refactor response handling in InboxItemInput --- .../components/inbox-item-input.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/agent-inbox/components/inbox-item-input.tsx b/src/components/agent-inbox/components/inbox-item-input.tsx index e3f736b..804dae3 100644 --- a/src/components/agent-inbox/components/inbox-item-input.tsx +++ b/src/components/agent-inbox/components/inbox-item-input.tsx @@ -6,13 +6,17 @@ import { SubmitType, } from "../types"; import { Textarea } from "@/components/ui/textarea"; -import React from "react"; +import React, { useRef } from "react"; import { haveArgsChanged, prettifyText } from "../utils"; import { MarkdownText } from "@/components/ui/markdown-text"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { CircleX, LoaderCircle, Undo2 } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; +import { useAgentInboxShortcuts } from "@/hooks/use-keyboard-shortcuts"; + +// Assigning a CSS class for easy identification from keyboard shortcuts +const INBOX_ITEM_INPUT_CLASS = "InboxItemInput"; function ResetButton({ handleReset }: { handleReset: () => void }) { return ( @@ -89,6 +93,7 @@ function ResponseComponent({ interruptValue, onResponseChange, handleSubmit, + responseInputRef, }: { humanResponse: HumanResponseWithEdits[]; streaming: boolean; @@ -98,6 +103,7 @@ function ResponseComponent({ handleSubmit: ( e: React.MouseEvent | React.KeyboardEvent ) => Promise; + responseInputRef?: React.RefObject; }) { const res = humanResponse.find((r) => r.type === "response"); if (!res || typeof res.args !== "string") { @@ -131,6 +137,7 @@ function ResponseComponent({

Response