diff --git a/apps/desktop2/src/components/chat/body.tsx b/apps/desktop2/src/components/chat/body.tsx index 1271ca61b2..9a34a9171b 100644 --- a/apps/desktop2/src/components/chat/body.tsx +++ b/apps/desktop2/src/components/chat/body.tsx @@ -1,10 +1,14 @@ import type { UIMessage } from "ai"; import { MessageCircle } from "lucide-react"; import { useEffect, useRef } from "react"; -import { Streamdown } from "streamdown"; + +import { cn } from "@hypr/ui/lib/utils"; +import { useShell } from "../../contexts/shell"; +import { ChatBodyMessage } from "./message"; export function ChatBody({ messages }: { messages: UIMessage[] }) { - const scrollRef = useRef(null); + const scrollRef = useRef(null); + const { chat } = useShell(); useEffect(() => { if (scrollRef.current) { @@ -12,58 +16,33 @@ export function ChatBody({ messages }: { messages: UIMessage[] }) { } }, [messages]); - if (messages.length === 0) { - return ; - } - return ( -
-
- {messages.map((message) => )} -
+
+ {messages.length === 0 ? : }
); } function ChatBodyEmpty() { return ( -
-
- -

Ask the AI assistant about anything.

-

It can also do few cool stuff for you.

-
+
+ +

Ask the AI assistant about anything.

+

It can also do few cool stuff for you.

); } -function ChatBodyMessage({ message }: { message: UIMessage }) { - const isUser = message.role === "user"; - - const content = message.parts - .filter((p) => p.type === "text") - .map((p) => (p.type === "text" ? p.text : "")) - .join(""); - +function ChatBodyNonEmpty({ messages }: { messages: UIMessage[] }) { return ( -
-
- -
+
+ {messages.map((message) => )}
); } - -function Markdown({ content }: { content: string }) { - return ( - - {content} - - ); -} diff --git a/apps/desktop2/src/components/chat/header.tsx b/apps/desktop2/src/components/chat/header.tsx index 8917c69fb7..83e145d531 100644 --- a/apps/desktop2/src/components/chat/header.tsx +++ b/apps/desktop2/src/components/chat/header.tsx @@ -1,9 +1,10 @@ -import clsx from "clsx"; import { formatDistanceToNow } from "date-fns"; -import { ChevronDown, MessageCircle, Plus, X } from "lucide-react"; +import { ChevronDown, MessageCircle, PanelRightIcon, PictureInPicture2Icon, Plus, X } from "lucide-react"; import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@hypr/ui/components/ui/dropdown-menu"; +import { cn } from "@hypr/ui/lib/utils"; +import { useShell } from "../../contexts/shell"; import * as persisted from "../../store/tinybase/persisted"; export function ChatHeader({ @@ -17,16 +18,33 @@ export function ChatHeader({ onSelectChat: (chatGroupId: string) => void; handleClose: () => void; }) { - return ( -
- + const { chat } = useShell(); -
+ return ( +
+
+ } onClick={onNewChat} title="New chat" /> +
+ +
+ + : } + onClick={() => chat.sendEvent({ type: "SHIFT" })} + title="Toggle" + /> } onClick={handleClose} @@ -49,7 +67,7 @@ function ChatActionButton({ return (
); } @@ -38,7 +39,7 @@ export function Body() { function Header({ tabs }: { tabs: Tab[] }) { const { persistedStore, internalStore } = useRouteContext({ from: "__root__" }); - const { isExpanded, setIsExpanded } = useLeftSidebar(); + const { leftsidebar } = useShell(); const { select, close, reorder, openNew } = useTabs(); const handleNewNote = useCallback(() => { @@ -59,22 +60,22 @@ function Header({ tabs }: { tabs: Tab[] }) { className={cn([ "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", "w-full overflow-x-auto h-8", - !isExpanded && "pl-[72px]", + !leftsidebar.expanded && "pl-[72px]", ])} >
- {!isExpanded && ( + {!leftsidebar.expanded && (
setIsExpanded(true)} + onClick={() => leftsidebar.setExpanded(true)} />
)} +
diff --git a/apps/desktop2/src/contexts/shell/chat.ts b/apps/desktop2/src/contexts/shell/chat.ts new file mode 100644 index 0000000000..4a0ebb448b --- /dev/null +++ b/apps/desktop2/src/contexts/shell/chat.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { createActor, fromTransition } from "xstate"; + +export type ChatMode = "RightPanelOpen" | "FloatingClosed" | "FloatingOpen"; +export type ChatEvent = { type: "OPEN" } | { type: "CLOSE" } | { type: "SHIFT" } | { type: "TOGGLE" }; + +const chatModeLogic = fromTransition( + (state: ChatMode, event: ChatEvent): ChatMode => { + switch (state) { + case "RightPanelOpen": + if (event.type === "CLOSE" || event.type === "TOGGLE") { + return "FloatingClosed"; + } + if (event.type === "SHIFT") { + return "FloatingOpen"; + } + return state; + case "FloatingClosed": + if (event.type === "OPEN" || event.type === "TOGGLE") { + return "FloatingOpen"; + } + return state; + case "FloatingOpen": + if (event.type === "CLOSE" || event.type === "TOGGLE") { + return "FloatingClosed"; + } + if (event.type === "SHIFT") { + return "RightPanelOpen"; + } + return state; + default: + return state; + } + }, + "FloatingClosed" as ChatMode, +); + +export function useChatMode() { + const [mode, setMode] = useState("FloatingClosed"); + const [groupId, setGroupId] = useState(undefined); + + const actorRef = useMemo(() => createActor(chatModeLogic), []); + + useEffect(() => { + actorRef.subscribe((snapshot) => setMode(snapshot.context)); + actorRef.start(); + }, [actorRef]); + + const sendEvent = useCallback( + (event: ChatEvent) => actorRef.send(event), + [actorRef], + ); + + useHotkeys("mod+j", () => sendEvent({ type: "TOGGLE" })); + + return { + mode, + sendEvent, + groupId, + setGroupId, + }; +} diff --git a/apps/desktop2/src/contexts/shell/index.tsx b/apps/desktop2/src/contexts/shell/index.tsx new file mode 100644 index 0000000000..beb6d6b60c --- /dev/null +++ b/apps/desktop2/src/contexts/shell/index.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext } from "react"; + +import { useChatMode } from "./chat"; +import { useLeftSidebar } from "./leftsidebar"; + +interface ShellContextType { + chat: ReturnType; + leftsidebar: ReturnType; +} + +const ShellContext = createContext(null); + +export function ShellProvider({ children }: { children: React.ReactNode }) { + const chat = useChatMode(); + const leftsidebar = useLeftSidebar(); + + return ( + + {children} + + ); +} + +export function useShell() { + const context = useContext(ShellContext); + if (!context) { + throw new Error("'useShell' must be used within 'ShellProvider'"); + } + return context; +} diff --git a/apps/desktop2/src/contexts/shell/leftsidebar.ts b/apps/desktop2/src/contexts/shell/leftsidebar.ts new file mode 100644 index 0000000000..678b07e1f4 --- /dev/null +++ b/apps/desktop2/src/contexts/shell/leftsidebar.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +export function useLeftSidebar() { + const [expanded, setExpanded] = useState(true); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => !prev); + }, []); + + useHotkeys( + "mod+l", + (event) => { + const target = event.target as HTMLElement; + const isInput = target.tagName === "INPUT" + || target.tagName === "TEXTAREA" + || target.tagName === "SELECT"; + const isContentEditable = target.isContentEditable; + + if (isInput || isContentEditable) { + return; + } + + event.preventDefault(); + toggleExpanded(); + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + }, + ); + + return { + expanded, + setExpanded, + toggleExpanded, + }; +} diff --git a/apps/desktop2/src/routes/app/main/_layout.index.tsx b/apps/desktop2/src/routes/app/main/_layout.index.tsx index 066e4ad0d0..b4df15ac24 100644 --- a/apps/desktop2/src/routes/app/main/_layout.index.tsx +++ b/apps/desktop2/src/routes/app/main/_layout.index.tsx @@ -1,20 +1,40 @@ +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@hypr/ui/components/ui/resizable"; import { createFileRoute } from "@tanstack/react-router"; -import { useLeftSidebar } from "@hypr/utils/contexts"; +import { ChatView } from "../../../components/chat/view"; import { Body } from "../../../components/main/body"; import { LeftSidebar } from "../../../components/main/sidebar"; +import { useShell } from "../../../contexts/shell"; export const Route = createFileRoute("/app/main/_layout/")({ component: Component, }); function Component() { - const { isExpanded: isLeftPanelExpanded } = useLeftSidebar(); + const { leftsidebar, chat } = useShell(); + + const isChatOpen = chat.mode === "RightPanelOpen"; return (
- {isLeftPanelExpanded && } - + {leftsidebar.expanded && } + + + + + {isChatOpen && ( + <> + + + + + + )} +
); } diff --git a/apps/desktop2/src/routes/app/main/_layout.tsx b/apps/desktop2/src/routes/app/main/_layout.tsx index b48bc75be7..5f23ad6c67 100644 --- a/apps/desktop2/src/routes/app/main/_layout.tsx +++ b/apps/desktop2/src/routes/app/main/_layout.tsx @@ -1,8 +1,8 @@ import { createFileRoute, Outlet, useRouteContext } from "@tanstack/react-router"; import { useEffect } from "react"; -import { LeftSidebarProvider, RightPanelProvider } from "@hypr/utils/contexts"; import { SearchProvider } from "../../../contexts/search"; +import { ShellProvider } from "../../../contexts/shell"; import { useTabs } from "../../../store/zustand/tabs"; import { id } from "../../../utils"; @@ -12,14 +12,12 @@ export const Route = createFileRoute("/app/main/_layout")({ function Component() { return ( - - - - - - - - + + + + + + ); }