diff --git a/src/interface/web/app/chat/chat.module.css b/src/interface/web/app/chat/chat.module.css index 2218bd640..7f20e904f 100644 --- a/src/interface/web/app/chat/chat.module.css +++ b/src/interface/web/app/chat/chat.module.css @@ -1,5 +1,6 @@ div.main { height: 100%; + overflow: hidden; color: hsla(var(--foreground)); margin-left: auto; margin-right: auto; @@ -127,6 +128,7 @@ div.chatTitleWrapper { /* Print-specific styles for chat layout */ @media print { + /* Chat container adjustments */ div.main { height: auto !important; diff --git a/src/interface/web/app/chat/page.tsx b/src/interface/web/app/chat/page.tsx index e273455e4..d0b3fc0be 100644 --- a/src/interface/web/app/chat/page.tsx +++ b/src/interface/web/app/chat/page.tsx @@ -164,7 +164,7 @@ function ChatBodyData(props: ChatBodyDataProps) { />
- +
@@ -616,7 +616,7 @@ export default function Chat() {
-
+
{`${defaultTitle}${!!title && title !== defaultTitle ? `: ${title}` : ""}`} diff --git a/src/interface/web/app/components/appSidebar/appSidebar.tsx b/src/interface/web/app/components/appSidebar/appSidebar.tsx index 94b3b3ea5..9291ee49e 100644 --- a/src/interface/web/app/components/appSidebar/appSidebar.tsx +++ b/src/interface/web/app/components/appSidebar/appSidebar.tsx @@ -159,19 +159,36 @@ export function AppSidebar(props: AppSidebarProps) { } - {items.map((item) => ( - - - - - {item.title} - - - - ))} + {items.map((item) => { + // Map title to shortcut label + const labelMap: Record = { + Home: "Alt+H", + Agents: "Alt+A", + Automations: "Alt+U", + Search: "Alt+K", + Settings: "Alt+,", + }; + const shortcut = labelMap[item.title]; + + return ( + + + + + + {item.title} + + {shortcut && ( + {shortcut} + )} + + + + ); + })}
-
+
{fetchingData && }
@@ -601,12 +601,12 @@ export default function ChatHistory(props: ChatHistoryProps) { index === data.chat.length - 2 ? latestUserMessageRef : // attach ref to the newest fetched message to handle scroll on fetch - // note: stabilize index selection against last page having less messages than fetchMessageCount - index === + // note: stabilize index selection against last page having less messages than fetchMessageCount + index === data.chat.length - - (currentPage - 1) * fetchMessageCount - ? latestFetchedMessageRef - : null + (currentPage - 1) * fetchMessageCount + ? latestFetchedMessageRef + : null } isMobileWidth={isMobileWidth} chatMessage={chatMessage} diff --git a/src/interface/web/app/components/chatInputArea/chatInputArea.module.css b/src/interface/web/app/components/chatInputArea/chatInputArea.module.css index cfee75f16..9b1c1c28e 100644 --- a/src/interface/web/app/components/chatInputArea/chatInputArea.module.css +++ b/src/interface/web/app/components/chatInputArea/chatInputArea.module.css @@ -2,3 +2,10 @@ div.actualInputArea { display: grid; grid-template-columns: auto 1fr auto auto; } + +/* Highlight when dragging files over the input area */ +.dragOver { + outline: 2px dashed rgba(59, 130, 246, 0.6); + /* blue-500 with transparency */ + border-radius: 8px; +} diff --git a/src/interface/web/app/components/chatInputArea/chatInputArea.tsx b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx index 408eea1a4..9dc161e45 100644 --- a/src/interface/web/app/components/chatInputArea/chatInputArea.tsx +++ b/src/interface/web/app/components/chatInputArea/chatInputArea.tsx @@ -111,6 +111,7 @@ export const ChatInputArea = forwardRef((pr const [progressValue, setProgressValue] = useState(0); const [isDragAndDropping, setIsDragAndDropping] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); const [showCommandList, setShowCommandList] = useState(false); const [useResearchMode, setUseResearchMode] = useState( @@ -118,6 +119,7 @@ export const ChatInputArea = forwardRef((pr ); const chatInputRef = ref as React.MutableRefObject; + const commandRef = useRef(null); useEffect(() => { if (!uploading) { setProgressValue(0); @@ -236,12 +238,23 @@ export const ChatInputArea = forwardRef((pr function handleDragAndDropFiles(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(false); + setIsDraggingOver(false); if (!event.dataTransfer.files) return; uploadFiles(event.dataTransfer.files); } + function handlePaste(event: React.ClipboardEvent) { + if (!event.clipboardData) return; + const files = event.clipboardData.files; + if (files && files.length > 0) { + // Prevent default only when handling files so simple text paste remains intact + event.preventDefault(); + uploadFiles(files); + } + } + function uploadFiles(files: FileList) { if (!props.isLoggedIn) { setLoginRedirectMessage("Please login to chat with your files"); @@ -321,8 +334,8 @@ export const ChatInputArea = forwardRef((pr } catch (error) { setError( "Error converting files. " + - error + - ". Please try again, or contact team@khoj.dev if the issue persists.", + error + + ". Please try again, or contact team@khoj.dev if the issue persists.", ); console.error("Error converting files:", error); return []; @@ -421,11 +434,18 @@ export const ChatInputArea = forwardRef((pr function handleDragOver(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(true); + setIsDraggingOver(true); + } + + function handleDragEnter(event: React.DragEvent) { + event.preventDefault(); + setIsDraggingOver(true); } function handleDragLeave(event: React.DragEvent) { event.preventDefault(); setIsDragAndDropping(false); + setIsDraggingOver(false); } function removeImageUpload(index: number) { @@ -514,7 +534,7 @@ export const ChatInputArea = forwardRef((pr sideOffset={props.conversationId ? 0 : 80} alignOffset={0} > - + ((pr ))}
((pr autoFocus={true} value={message} onKeyDown={(e) => { + // When the slash command menu is visible, forward ArrowUp/ArrowDown + // key events to the Command component so it can handle selection. + // We dispatch a cloned KeyboardEvent on the Command DOM node and + // then prevent the textarea from moving the caret. + if (showCommandList && (e.key === "ArrowUp" || e.key === "ArrowDown")) { + if (commandRef.current) { + const forwardedEvent = new window.KeyboardEvent(e.type, { + key: e.key, + code: (e as any).code, + bubbles: true, + cancelable: true, + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + metaKey: e.metaKey, + }); + // Dispatch the cloned event on the Command element so the + // Command component's internal handler receives it. + commandRef.current.dispatchEvent(forwardedEvent); + } + // Prevent the textarea caret from moving after dispatching. + e.preventDefault(); + } + + // If the slash command menu is open, Enter should select the highlighted command + if (showCommandList && e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const selectedEl = commandRef.current?.querySelector( + '[data-selected="true"]', + ) as HTMLElement | null; + if (selectedEl) { + selectedEl.click(); + } + setShowCommandList(false); + return; + } + if ( e.key === "Enter" && !e.shiftKey && diff --git a/src/interface/web/app/components/chatMessage/chatMessage.module.css b/src/interface/web/app/components/chatMessage/chatMessage.module.css index 0f6483624..943f0c1a4 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.module.css +++ b/src/interface/web/app/components/chatMessage/chatMessage.module.css @@ -141,7 +141,7 @@ div.khoj div.imagesContainer { overflow-x: hidden; } -div.chatMessageContainer > img { +div.chatMessageContainer>img { width: auto; height: auto; max-width: 100%; @@ -233,6 +233,45 @@ div.trainOfThoughtElement { align-items: start; } +/* Ensure selection highlight is visible inside chat messages */ +.chatMessage *::selection, +.chatMessage ::selection, +.chatMessage *::-moz-selection, +.chatMessage ::-moz-selection { + background: rgba(59, 130, 246, 0.35) !important; + color: inherit !important; + -webkit-text-fill-color: unset !important; +} + +.dark .chatMessage *::selection, +.dark .chatMessage ::selection, +.dark .chatMessage *::-moz-selection, +.dark .chatMessage ::-moz-selection { + background: rgba(99, 102, 241, 0.45) !important; + color: inherit !important; + -webkit-text-fill-color: unset !important; +} + +/* Footer controls visibility toggle to avoid DOM mount/unmount while preserving selection */ +.chatFooterInner { + display: flex; + align-items: center; + justify-content: space-between; + transition: opacity 120ms ease, transform 120ms ease; +} + +.hiddenControls { + opacity: 0; + pointer-events: none; + transform: translateY(4px); +} + +.visibleControls { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + div.trainOfThoughtElement ol, div.trainOfThoughtElement ul { margin: auto; diff --git a/src/interface/web/app/components/chatMessage/chatMessage.tsx b/src/interface/web/app/components/chatMessage/chatMessage.tsx index ec8a90cc5..97a445928 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.tsx +++ b/src/interface/web/app/components/chatMessage/chatMessage.tsx @@ -422,6 +422,7 @@ const ChatMessage = forwardRef((props, ref) => const interruptedRef = useRef(false); const messageRef = useRef(null); + const selectionActiveRef = useRef(false); useEffect(() => { interruptedRef.current = interrupted; @@ -687,6 +688,42 @@ const ChatMessage = forwardRef((props, ref) => } }, [markdownRendered, messageRef]); + // Track if there is an active text selection inside this message (no React re-render) + useEffect(() => { + const onSelectionChange = () => { + try { + const sel = document.getSelection(); + selectionActiveRef.current = !!( + sel && sel.rangeCount > 0 && sel.toString() && + messageRef.current && + (messageRef.current.contains(sel.anchorNode) || messageRef.current.contains(sel.focusNode)) + ); + } catch { + selectionActiveRef.current = false; + } + }; + + // Attach scroll handler to the chat viewport only, not window + const scrollAreaViewport = document.querySelector("[data-radix-scroll-area-viewport]"); + const onScroll = () => { + // De-bounce lightly to read selection after scroll applies + setTimeout(() => { + const sel = document.getSelection(); + if (!sel || !sel.toString() || !messageRef.current) { + selectionActiveRef.current = false; + } + }, 30); + }; + + document.addEventListener("selectionchange", onSelectionChange); + if (scrollAreaViewport) scrollAreaViewport.addEventListener("scroll", onScroll, { passive: true }); + + return () => { + document.removeEventListener("selectionchange", onSelectionChange); + if (scrollAreaViewport) scrollAreaViewport.removeEventListener("scroll", onScroll); + }; + }, []); + // Fetch file content for dialog and hover using shared hook const { content: previewContentHook, @@ -874,12 +911,21 @@ const ChatMessage = forwardRef((props, ref) => props.chatMessage.codeContext, ); + function handleMouseEnter(event: React.MouseEvent) { + setIsHovering(true); + } + function handleMouseLeave(event: React.MouseEvent) { + // Prevent hover collapse while a selection inside this message is active + if (selectionActiveRef.current) return; + setIsHovering(false); + } + return (
setIsHovering(false)} - onMouseEnter={(event) => setIsHovering(true)} + onMouseLeave={handleMouseLeave} + onMouseEnter={handleMouseEnter} data-created={formatDate(props.chatMessage.created)} >
@@ -1054,126 +1100,119 @@ const ChatMessage = forwardRef((props, ref) => />
- {(isHovering || props.isMobileWidth || props.isLastMessage || isPlaying) && ( - <> -
- {renderTimeStamp(props.chatMessage.created)} -
-
- {props.chatMessage.by === "khoj" && - (isPlaying ? ( - interrupted ? ( - - ) : ( - - ) +
+
+ {renderTimeStamp(props.chatMessage.created)} +
+
+ {props.chatMessage.by === "khoj" && + (isPlaying ? ( + interrupted ? ( + ) : ( - - ))} - {props.chatMessage.turnId && ( - - )} - {props.chatMessage.by === "khoj" && - props.onRetryMessage && - props.isLastMessage && ( - )} - + ))} + {props.chatMessage.turnId && ( + - {props.chatMessage.by === "khoj" && - (props.chatMessage.intent ? ( - - ) : ( - { + const turnId = props.chatMessage.turnId || props.turnId; + const query = + props.chatMessage.rawQuery || + props.chatMessage.intent?.query; + if (query) { + props.onRetryMessage?.(query, turnId); + } else { + console.error("No original query found for retry"); + const fallbackQuery = prompt( + "Enter the original query to retry:", + ); + if (fallbackQuery) { + props.onRetryMessage?.(fallbackQuery, turnId); + } } - kquery={props.chatMessage.message} + }} + > + - ))} -
- - )} + + )} + + {props.chatMessage.by === "khoj" && + (props.chatMessage.intent ? ( + + ) : ( + + ))} +
+
); diff --git a/src/interface/web/app/components/navMenu/navMenu.module.css b/src/interface/web/app/components/navMenu/navMenu.module.css index aab37b5bb..2402a75d2 100644 --- a/src/interface/web/app/components/navMenu/navMenu.module.css +++ b/src/interface/web/app/components/navMenu/navMenu.module.css @@ -59,6 +59,17 @@ div.settingsMenuOptions { border-radius: 8px; } +/* Keyboard key styling */ +kbd { + background: #f3f4f6; + border: 1px solid #e5e7eb; + padding: 2px 6px; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Segoe UI Mono", "Noto Mono", monospace; + font-size: 0.75rem; + line-height: 1; +} + @media screen and (max-width: 600px) { menu.menu span { display: none; diff --git a/src/interface/web/app/components/providers/themeProvider.tsx b/src/interface/web/app/components/providers/themeProvider.tsx index a5316d067..d7db35fe5 100644 --- a/src/interface/web/app/components/providers/themeProvider.tsx +++ b/src/interface/web/app/components/providers/themeProvider.tsx @@ -1,8 +1,27 @@ "use client"; import { useIsDarkMode } from "@/app/common/utils"; +import useKeyboardShortcuts from "@/hooks/useKeyboardShortcuts"; +import { useRouter } from "next/navigation"; export function ThemeProvider({ children }: { children: React.ReactNode }) { const [darkMode, setDarkMode] = useIsDarkMode(); + const router = useRouter(); + + useKeyboardShortcuts({ + "Alt+N": () => { + router.push("/chat"); + }, + "Alt+H": () => router.push("/"), + "Alt+A": () => router.push("/agents"), + "Alt+U": () => router.push("/automations"), + "Alt+K": () => router.push("/search"), + "Alt+,": () => router.push("/settings"), + "Alt+F": () => { + const el = document.querySelector('input[placeholder="Find file"], input[placeholder="Search conversations"], input[placeholder="Search conversations"]'); + if (el && el instanceof HTMLElement) el.focus(); + }, + }); + return <>{children}; } diff --git a/src/interface/web/app/components/suggestions/suggestions.module.css b/src/interface/web/app/components/suggestions/suggestions.module.css index e0835610a..88dfeed68 100644 --- a/src/interface/web/app/components/suggestions/suggestions.module.css +++ b/src/interface/web/app/components/suggestions/suggestions.module.css @@ -1,5 +1,26 @@ .card { border-radius: 0.5rem; + transition: transform 180ms cubic-bezier(.2, .8, .2, 1), box-shadow 180ms cubic-bezier(.2, .8, .2, 1); + will-change: transform, box-shadow; + transform: translateY(0); + box-shadow: 0 1px 6px rgba(16, 24, 40, 0.06); +} + +.card:hover, +.card:focus-within, +.card:focus { + transform: translateY(-6px); + box-shadow: 0 10px 30px rgba(16, 24, 40, 0.12); + outline: none; +} + +.card:active { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(16, 24, 40, 0.10); +} + +.card:focus-visible { + box-shadow: 0 10px 30px rgba(16, 24, 40, 0.12), 0 0 0 3px rgba(59, 130, 246, 0.12); } .title { diff --git a/src/interface/web/app/globals.css b/src/interface/web/app/globals.css index 8609095f6..bed3f3281 100644 --- a/src/interface/web/app/globals.css +++ b/src/interface/web/app/globals.css @@ -375,6 +375,7 @@ body { @apply bg-background text-foreground; + /* Prevent page-level scrolling - moved to chat-specific styles to avoid hiding scrollbars on other pages */ } h1 { diff --git a/src/interface/web/bun.lock b/src/interface/web/bun.lock index cd380f935..254617eb2 100644 --- a/src/interface/web/bun.lock +++ b/src/interface/web/bun.lock @@ -40,7 +40,7 @@ "input-otp": "^1.2.4", "intl-tel-input": "^23.8.1", "jszip": "^3.10.1", - "katex": "^0.16.21", + "katex": "^0.16.22", "libphonenumber-js": "^1.11.4", "lucide-react": "^0.468.0", "markdown-it": "^14.1.0", diff --git a/src/interface/web/components/ui/dropdown-menu.tsx b/src/interface/web/components/ui/dropdown-menu.tsx index e7a7c4779..5dc35adbf 100644 --- a/src/interface/web/components/ui/dropdown-menu.tsx +++ b/src/interface/web/components/ui/dropdown-menu.tsx @@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef< span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "cursor-pointer peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -595,7 +595,7 @@ const SidebarMenuAction = React.forwardRef< "peer-data-[size=lg]/menu-button:top-2.5", "group-data-[collapsible=icon]:hidden", showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", className, )} {...props} diff --git a/src/interface/web/hooks/useKeyboardShortcuts.ts b/src/interface/web/hooks/useKeyboardShortcuts.ts new file mode 100644 index 000000000..08c416f53 --- /dev/null +++ b/src/interface/web/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect } from "react"; + +type ShortcutMap = Record void>; + +function normalizeKeyCombo(combo: string) { + // Normalize combos like "Alt+N" -> "alt+n" + return combo + .split("+") + .map((p) => p.trim().toLowerCase()) + .join("+"); +} + +export default function useKeyboardShortcuts(shortcuts: ShortcutMap | null) { + useEffect(() => { + if (!shortcuts) return; + + const normalizedMap: Record void> = {}; + for (const key in shortcuts) { + normalizedMap[normalizeKeyCombo(key)] = shortcuts[key]; + } + + const handler = (event: KeyboardEvent) => { + + const parts: string[] = []; + if (event.altKey) parts.push("alt"); + if (event.ctrlKey) parts.push("ctrl"); + if (event.metaKey) parts.push("meta"); + if (event.shiftKey) parts.push("shift"); + + // Normalize the key (comma stays comma) + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key.toLowerCase(); + parts.push(key); + + const combo = parts.join("+"); + + const cb = normalizedMap[combo]; + if (cb) { + event.preventDefault(); + cb(event); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [shortcuts]); +}