Skip to content

Commit f3fec38

Browse files
feat: replace floating chat with persistent panel (#4283)
* refactor: replace floating chat with persistent panel Replace ChatView with PersistentChatPanel that renders chat in both floating and panel modes. Add portal-like rendering system using container refs and position tracking. Remove auto-closer logic from floating button component and simplify its render conditions. * refactor: remove React portal from floating component Remove createPortal usage in floating component to render directly in component tree instead of document body. This simplifies the component structure and eliminates the need for portal-based rendering while maintaining the same visual positioning through CSS positioning. * feat: Adjust submit button padding for better alignment Replace symmetric px-2.5 padding with asymmetric pl-2.5 pr-1.5 padding on the chat input submit button to improve visual balance and alignment with adjacent UI elements.
1 parent a9691d3 commit f3fec38

File tree

5 files changed

+182
-26
lines changed

5 files changed

+182
-26
lines changed
Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { useCallback } from "react";
22
import { useShell } from "~/contexts/shell";
3-
import { useAutoCloser } from "~/shared/hooks/useAutoCloser";
43

5-
import { InteractiveContainer } from "./interactive";
64
import { ChatTrigger } from "./trigger";
7-
import { ChatView } from "./view";
85

96
export function ChatFloatingButton({
107
isCaretNearBottom = false,
@@ -16,28 +13,19 @@ export function ChatFloatingButton({
1613
const { chat } = useShell();
1714
const isOpen = chat.mode === "FloatingOpen";
1815

19-
useAutoCloser(() => chat.sendEvent({ type: "CLOSE" }), {
20-
esc: isOpen,
21-
outside: false,
22-
});
23-
2416
const handleClickTrigger = useCallback(async () => {
2517
chat.sendEvent({ type: "OPEN" });
2618
}, [chat]);
2719

28-
if (!isOpen) {
29-
return (
30-
<ChatTrigger
31-
onClick={handleClickTrigger}
32-
isCaretNearBottom={isCaretNearBottom}
33-
showTimeline={showTimeline}
34-
/>
35-
);
20+
if (isOpen) {
21+
return null;
3622
}
3723

3824
return (
39-
<InteractiveContainer width={400} height={window.innerHeight * 0.7}>
40-
<ChatView />
41-
</InteractiveContainer>
25+
<ChatTrigger
26+
onClick={handleClickTrigger}
27+
isCaretNearBottom={isCaretNearBottom}
28+
showTimeline={showTimeline}
29+
/>
4230
);
4331
}

apps/desktop/src/chat/components/input/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function ChatMessageInput({
8888
onClick={handleSubmit}
8989
disabled={disabled}
9090
className={cn([
91-
"inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg text-xs font-medium transition-all duration-100",
91+
"inline-flex items-center gap-1.5 h-7 pl-2.5 pr-1.5 rounded-lg text-xs font-medium transition-all duration-100",
9292
"border",
9393
disabled
9494
? "text-neutral-300 border-neutral-200 cursor-default"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Resizable } from "re-resizable";
2+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
3+
import { useHotkeys } from "react-hotkeys-hook";
4+
import { useShell } from "~/contexts/shell";
5+
6+
import { cn } from "@hypr/utils";
7+
8+
import { ChatView } from "./view";
9+
10+
export function PersistentChatPanel({
11+
panelContainerRef,
12+
}: {
13+
panelContainerRef: React.RefObject<HTMLDivElement | null>;
14+
}) {
15+
const { chat } = useShell();
16+
const mode = chat.mode;
17+
const isFloating = mode === "FloatingOpen";
18+
const isPanel = mode === "RightPanelOpen";
19+
const isVisible = isFloating || isPanel;
20+
21+
const [hasBeenOpened, setHasBeenOpened] = useState(false);
22+
const [floatingSize, setFloatingSize] = useState({
23+
width: 400,
24+
height: window.innerHeight * 0.7,
25+
});
26+
const [isResizing, setIsResizing] = useState(false);
27+
const [panelRect, setPanelRect] = useState<DOMRect | null>(null);
28+
const observerRef = useRef<ResizeObserver | null>(null);
29+
30+
useEffect(() => {
31+
if (isVisible && !hasBeenOpened) {
32+
setHasBeenOpened(true);
33+
}
34+
}, [isVisible, hasBeenOpened]);
35+
36+
useHotkeys(
37+
"esc",
38+
() => chat.sendEvent({ type: "CLOSE" }),
39+
{
40+
enabled: isFloating,
41+
preventDefault: true,
42+
enableOnFormTags: true,
43+
enableOnContentEditable: true,
44+
},
45+
[chat, isFloating],
46+
);
47+
48+
useLayoutEffect(() => {
49+
if (!isPanel || !panelContainerRef.current) {
50+
setPanelRect(null);
51+
return;
52+
}
53+
setPanelRect(panelContainerRef.current.getBoundingClientRect());
54+
}, [isPanel, panelContainerRef]);
55+
56+
useEffect(() => {
57+
if (!isPanel || !panelContainerRef.current) {
58+
if (observerRef.current) {
59+
observerRef.current.disconnect();
60+
observerRef.current = null;
61+
}
62+
return;
63+
}
64+
65+
const el = panelContainerRef.current;
66+
const updateRect = () => {
67+
setPanelRect(el.getBoundingClientRect());
68+
};
69+
70+
observerRef.current = new ResizeObserver(updateRect);
71+
observerRef.current.observe(el);
72+
window.addEventListener("resize", updateRect);
73+
74+
return () => {
75+
observerRef.current?.disconnect();
76+
observerRef.current = null;
77+
window.removeEventListener("resize", updateRect);
78+
};
79+
}, [isPanel, panelContainerRef]);
80+
81+
if (!hasBeenOpened) {
82+
return null;
83+
}
84+
85+
const panelStyle: React.CSSProperties | undefined =
86+
isPanel && panelRect
87+
? {
88+
top: panelRect.top,
89+
left: panelRect.left,
90+
width: panelRect.width,
91+
height: panelRect.height,
92+
}
93+
: undefined;
94+
95+
return (
96+
<div
97+
className={cn([
98+
"fixed z-[100]",
99+
!isVisible && "!hidden",
100+
isPanel && "pointer-events-none",
101+
])}
102+
style={
103+
isFloating
104+
? { right: 16, bottom: 16 }
105+
: (panelStyle ?? { display: "none" })
106+
}
107+
>
108+
<Resizable
109+
size={isFloating ? floatingSize : { width: "100%", height: "100%" }}
110+
onResizeStart={isFloating ? () => setIsResizing(true) : undefined}
111+
onResizeStop={
112+
isFloating
113+
? (_, __, ___, d) => {
114+
setFloatingSize((prev) => ({
115+
width: prev.width + d.width,
116+
height: prev.height + d.height,
117+
}));
118+
setIsResizing(false);
119+
}
120+
: undefined
121+
}
122+
enable={
123+
isFloating
124+
? {
125+
top: true,
126+
right: false,
127+
bottom: false,
128+
left: true,
129+
topRight: false,
130+
bottomRight: false,
131+
bottomLeft: false,
132+
topLeft: true,
133+
}
134+
: false
135+
}
136+
minWidth={isFloating ? 400 : undefined}
137+
minHeight={isFloating ? 400 : undefined}
138+
bounds={isFloating ? "window" : undefined}
139+
className={cn([
140+
"flex flex-col pointer-events-auto",
141+
isFloating && [
142+
"bg-white rounded-b-2xl rounded-t-xl shadow-2xl",
143+
"border border-neutral-200",
144+
!isResizing && "transition-all duration-200",
145+
],
146+
isPanel && "h-full w-full",
147+
])}
148+
handleStyles={
149+
isFloating
150+
? {
151+
top: { height: "4px", top: 0 },
152+
left: { width: "4px", left: 0 },
153+
topLeft: {
154+
width: "12px",
155+
height: "12px",
156+
top: 0,
157+
left: 0,
158+
},
159+
}
160+
: undefined
161+
}
162+
>
163+
<ChatView />
164+
</Resizable>
165+
</div>
166+
);
167+
}

apps/desktop/src/routes/app/main/_layout.index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createFileRoute } from "@tanstack/react-router";
22
import { useEffect, useRef } from "react";
33
import type { ComponentRef } from "react";
4-
import { ChatView } from "~/chat/components/view";
4+
import { PersistentChatPanel } from "~/chat/components/persistent-chat";
55
import { useShell } from "~/contexts/shell";
66
import { useSearch } from "~/search/contexts/ui";
77
import { Body } from "~/shared/main";
@@ -29,6 +29,7 @@ function Component() {
2929
const previousModeRef = useRef(chat.mode);
3030
const previousQueryRef = useRef(query);
3131
const bodyPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
32+
const chatPanelContainerRef = useRef<HTMLDivElement>(null);
3233

3334
const isChatOpen = chat.mode === "RightPanelOpen";
3435

@@ -93,11 +94,13 @@ function Component() {
9394
className="pl-1"
9495
style={{ minWidth: CHAT_MIN_WIDTH_PX }}
9596
>
96-
<ChatView />
97+
<div ref={chatPanelContainerRef} className="h-full" />
9798
</ResizablePanel>
9899
</>
99100
)}
100101
</ResizablePanelGroup>
102+
103+
<PersistentChatPanel panelContainerRef={chatPanelContainerRef} />
101104
</div>
102105
);
103106
}

apps/desktop/src/session/components/floating/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { type ReactNode, useEffect, useRef, useState } from "react";
2-
import { createPortal } from "react-dom";
32
import { useShell } from "~/contexts/shell";
43
import { useCaretPosition } from "~/session/components/caret-position-context";
54
import {
@@ -106,7 +105,7 @@ function FloatingButtonContainer({ children }: { children: ReactNode }) {
106105
const rightOffset = chatPanelWidth / 2;
107106
const totalOffset = leftOffset - rightOffset;
108107

109-
return createPortal(
108+
return (
110109
<div
111110
ref={containerRef}
112111
style={{ left: `calc(50% + ${totalOffset}px)` }}
@@ -117,7 +116,6 @@ function FloatingButtonContainer({ children }: { children: ReactNode }) {
117116
])}
118117
>
119118
{children}
120-
</div>,
121-
document.body,
119+
</div>
122120
);
123121
}

0 commit comments

Comments
 (0)