Skip to content

Commit 2b34f9a

Browse files
feat(ui): adjust chat trigger and note input positioning
1 parent e02291f commit 2b34f9a

File tree

7 files changed

+199
-24
lines changed

7 files changed

+199
-24
lines changed

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { InteractiveContainer } from "./interactive";
66
import { ChatTrigger } from "./trigger";
77
import { ChatView } from "./view";
88

9-
export function ChatFloatingButton() {
9+
export function ChatFloatingButton({
10+
isCaretNearBottom = false,
11+
}: {
12+
isCaretNearBottom?: boolean;
13+
}) {
1014
const { chat } = useShell();
1115
const isOpen = chat.mode === "FloatingOpen";
1216

@@ -20,7 +24,12 @@ export function ChatFloatingButton() {
2024
}, [chat]);
2125

2226
if (!isOpen) {
23-
return <ChatTrigger onClick={handleClickTrigger} />;
27+
return (
28+
<ChatTrigger
29+
onClick={handleClickTrigger}
30+
isCaretNearBottom={isCaretNearBottom}
31+
/>
32+
);
2433
}
2534

2635
return (

apps/desktop/src/components/chat/trigger.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@ import { createPortal } from "react-dom";
22

33
import { cn } from "@hypr/utils";
44

5-
export function ChatTrigger({ onClick }: { onClick: () => void }) {
5+
export function ChatTrigger({
6+
onClick,
7+
isCaretNearBottom = false,
8+
}: {
9+
onClick: () => void;
10+
isCaretNearBottom?: boolean;
11+
}) {
612
return createPortal(
713
<button
814
onClick={onClick}
915
className={cn([
10-
"fixed bottom-4 right-4 z-[100]",
16+
"fixed right-4 z-[100]",
1117
"w-14 h-14 rounded-full",
1218
"bg-white shadow-lg hover:shadow-xl",
1319
"border border-neutral-200",
1420
"flex items-center justify-center",
15-
"transition-all duration-200",
21+
"transition-all duration-200 ease-out",
1622
"hover:scale-105",
23+
isCaretNearBottom ? "bottom-0 translate-y-[85%]" : "bottom-4",
1724
])}
1825
>
1926
<img

apps/desktop/src/components/main/body/index.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { TabContentHuman, TabItemHuman } from "./humans";
4343
import { TabContentPrompt, TabItemPrompt } from "./prompts";
4444
import { Search } from "./search";
4545
import { TabContentNote, TabItemNote } from "./sessions";
46+
import { useCaretPosition } from "./sessions/caret-position-context";
4647
import { TabContentSettings, TabItemSettings } from "./settings";
4748
import { TabContentTemplate, TabItemTemplate } from "./templates";
4849
import { Update } from "./update";
@@ -496,7 +497,11 @@ function ContentWrapper({ tab }: { tab: Tab }) {
496497
return null;
497498
}
498499

499-
function TabChatButton() {
500+
function TabChatButton({
501+
isCaretNearBottom = false,
502+
}: {
503+
isCaretNearBottom?: boolean;
504+
}) {
500505
const { chat } = useShell();
501506
const currentTab = useTabs((state) => state.currentTab);
502507

@@ -512,7 +517,7 @@ function TabChatButton() {
512517
return null;
513518
}
514519

515-
return <ChatFloatingButton />;
520+
return <ChatFloatingButton isCaretNearBottom={isCaretNearBottom} />;
516521
}
517522

518523
export function StandardTabWrapper({
@@ -529,13 +534,20 @@ export function StandardTabWrapper({
529534
<div className="flex flex-col rounded-xl border border-neutral-200 flex-1 overflow-hidden relative">
530535
{children}
531536
{floatingButton}
532-
<TabChatButton />
537+
<StandardTabChatButton />
533538
</div>
534539
{afterBorder}
535540
</div>
536541
);
537542
}
538543

544+
function StandardTabChatButton() {
545+
const caretPosition = useCaretPosition();
546+
const isCaretNearBottom = caretPosition?.isCaretNearBottom ?? false;
547+
548+
return <TabChatButton isCaretNearBottom={isCaretNearBottom} />;
549+
}
550+
539551
function useHasSpaceForSearch() {
540552
const ref = useRef<HTMLDivElement>(null);
541553
const { width = 0 } = useResizeObserver({
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
createContext,
3+
type ReactNode,
4+
useCallback,
5+
useContext,
6+
useState,
7+
} from "react";
8+
9+
interface CaretPositionContextValue {
10+
isCaretNearBottom: boolean;
11+
setCaretNearBottom: (value: boolean) => void;
12+
}
13+
14+
const CaretPositionContext = createContext<CaretPositionContextValue | null>(
15+
null,
16+
);
17+
18+
export function CaretPositionProvider({ children }: { children: ReactNode }) {
19+
const [isCaretNearBottom, setIsCaretNearBottom] = useState(false);
20+
21+
const setCaretNearBottom = useCallback((value: boolean) => {
22+
setIsCaretNearBottom(value);
23+
}, []);
24+
25+
return (
26+
<CaretPositionContext.Provider
27+
value={{ isCaretNearBottom, setCaretNearBottom }}
28+
>
29+
{children}
30+
</CaretPositionContext.Provider>
31+
);
32+
}
33+
34+
export function useCaretPosition() {
35+
return useContext(CaretPositionContext);
36+
}

apps/desktop/src/components/main/body/sessions/floating/index.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import { type ReactNode } from "react";
1+
import { type ReactNode, useEffect, useState } from "react";
2+
import { createPortal } from "react-dom";
23

4+
import { cn } from "@hypr/utils";
5+
6+
import { useShell } from "../../../../../contexts/shell";
37
import type { Tab } from "../../../../../store/zustand/tabs/schema";
8+
import { useCaretPosition } from "../caret-position-context";
49
import { useCurrentNoteTab, useHasTranscript } from "../shared";
510
import { ListenButton } from "./listen";
611

12+
const SIDEBAR_WIDTH = 280;
13+
const LAYOUT_PADDING = 4;
14+
715
export function FloatingActionButton({
816
tab,
917
}: {
@@ -24,9 +32,59 @@ export function FloatingActionButton({
2432
}
2533

2634
function FloatingButtonContainer({ children }: { children: ReactNode }) {
27-
return (
28-
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3">
35+
const caretPosition = useCaretPosition();
36+
const { leftsidebar, chat } = useShell();
37+
const isCaretNearBottom = caretPosition?.isCaretNearBottom ?? false;
38+
const [chatPanelWidth, setChatPanelWidth] = useState(0);
39+
40+
const isChatPanelOpen = chat.mode === "RightPanelOpen";
41+
42+
useEffect(() => {
43+
if (!isChatPanelOpen) {
44+
setChatPanelWidth(0);
45+
return;
46+
}
47+
48+
const updateChatWidth = () => {
49+
const chatPanel = document.querySelector("[data-panel-id]");
50+
if (chatPanel) {
51+
const panels = document.querySelectorAll("[data-panel-id]");
52+
const lastPanel = panels[panels.length - 1];
53+
if (lastPanel) {
54+
setChatPanelWidth(lastPanel.getBoundingClientRect().width);
55+
}
56+
}
57+
};
58+
59+
updateChatWidth();
60+
window.addEventListener("resize", updateChatWidth);
61+
62+
const observer = new MutationObserver(updateChatWidth);
63+
observer.observe(document.body, { subtree: true, attributes: true });
64+
65+
return () => {
66+
window.removeEventListener("resize", updateChatWidth);
67+
observer.disconnect();
68+
};
69+
}, [isChatPanelOpen]);
70+
71+
const leftOffset = leftsidebar.expanded
72+
? (SIDEBAR_WIDTH + LAYOUT_PADDING) / 2
73+
: 0;
74+
const rightOffset = chatPanelWidth / 2;
75+
const totalOffset = leftOffset - rightOffset;
76+
77+
return createPortal(
78+
<div
79+
style={{ left: `calc(50% + ${totalOffset}px)` }}
80+
className={cn([
81+
"fixed -translate-x-1/2 z-[100] flex items-center gap-3",
82+
"transition-all duration-200 ease-out",
83+
isCaretNearBottom ? "bottom-0 translate-y-[85%]" : "bottom-4",
84+
])}
85+
>
2986
{children}
30-
</div>
87+
</div>,
88+
document.body,
3189
);
3290
}

apps/desktop/src/components/main/body/sessions/index.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as main from "../../../../store/tinybase/main";
1010
import { rowIdfromTab, type Tab } from "../../../../store/zustand/tabs";
1111
import { StandardTabWrapper } from "../index";
1212
import { type TabItem, TabItemBase } from "../shared";
13+
import { CaretPositionProvider } from "./caret-position-context";
1314
import { FloatingActionButton } from "./floating";
1415
import { NoteInput } from "./note-input";
1516
import { SearchBar } from "./note-input/transcript/search-bar";
@@ -77,11 +78,13 @@ export function TabContentNote({
7778
listenerStatus === "inactive";
7879

7980
return (
80-
<SearchProvider>
81-
<AudioPlayer.Provider sessionId={tab.id} url={audioUrl ?? ""}>
82-
<TabContentNoteInner tab={tab} showTimeline={showTimeline} />
83-
</AudioPlayer.Provider>
84-
</SearchProvider>
81+
<CaretPositionProvider>
82+
<SearchProvider>
83+
<AudioPlayer.Provider sessionId={tab.id} url={audioUrl ?? ""}>
84+
<TabContentNoteInner tab={tab} showTimeline={showTimeline} />
85+
</AudioPlayer.Provider>
86+
</SearchProvider>
87+
</CaretPositionProvider>
8588
);
8689
}
8790

apps/desktop/src/components/main/body/sessions/note-input/index.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import { useAutoTitle } from "../../../../../hooks/useAutoTitle";
99
import { useScrollPreservation } from "../../../../../hooks/useScrollPreservation";
1010
import { type Tab, useTabs } from "../../../../../store/zustand/tabs";
1111
import { type EditorView } from "../../../../../store/zustand/tabs/schema";
12+
import { useCaretPosition } from "../caret-position-context";
1213
import { useCurrentNoteTab } from "../shared";
1314
import { Enhanced } from "./enhanced";
1415
import { Header, useEditorTabs } from "./header";
1516
import { RawEditor } from "./raw";
1617
import { Transcript } from "./transcript";
1718

19+
const BOTTOM_THRESHOLD = 112;
20+
1821
export function NoteInput({
1922
tab,
2023
}: {
@@ -23,7 +26,9 @@ export function NoteInput({
2326
const editorTabs = useEditorTabs({ sessionId: tab.id });
2427
const updateSessionTabState = useTabs((state) => state.updateSessionTabState);
2528
const editorRef = useRef<{ editor: TiptapEditor | null }>(null);
29+
const containerRef = useRef<HTMLDivElement>(null);
2630
const [isEditing, setIsEditing] = useState(false);
31+
const caretPosition = useCaretPosition();
2732

2833
const sessionId = tab.id;
2934
useAutoEnhance(tab);
@@ -59,6 +64,50 @@ export function NoteInput({
5964
}
6065
}, [currentTab]);
6166

67+
useEffect(() => {
68+
const editor = editorRef.current?.editor;
69+
const container = containerRef.current;
70+
if (
71+
!editor ||
72+
!caretPosition ||
73+
!container ||
74+
currentTab.type === "transcript"
75+
) {
76+
caretPosition?.setCaretNearBottom(false);
77+
return;
78+
}
79+
80+
const checkCaretPosition = () => {
81+
if (!containerRef.current || !editor.isFocused) return;
82+
83+
const { view } = editor;
84+
const { from } = view.state.selection;
85+
const coords = view.coordsAtPos(from);
86+
87+
const distanceFromViewportBottom = window.innerHeight - coords.bottom;
88+
89+
caretPosition.setCaretNearBottom(
90+
distanceFromViewportBottom < BOTTOM_THRESHOLD,
91+
);
92+
};
93+
94+
const handleBlur = () => caretPosition.setCaretNearBottom(false);
95+
96+
editor.on("selectionUpdate", checkCaretPosition);
97+
editor.on("focus", checkCaretPosition);
98+
editor.on("blur", handleBlur);
99+
container.addEventListener("scroll", checkCaretPosition);
100+
101+
checkCaretPosition();
102+
103+
return () => {
104+
editor.off("selectionUpdate", checkCaretPosition);
105+
editor.off("focus", checkCaretPosition);
106+
editor.off("blur", handleBlur);
107+
container.removeEventListener("scroll", checkCaretPosition);
108+
};
109+
}, [editorRef.current?.editor, caretPosition, currentTab.type]);
110+
62111
const handleContainerClick = () => {
63112
if (currentTab.type !== "transcript") {
64113
editorRef.current?.editor?.commands.focus();
@@ -79,13 +128,14 @@ export function NoteInput({
79128
</div>
80129

81130
<div
82-
ref={
83-
currentTab.type !== "transcript"
84-
? (node) => {
85-
scrollRef.current = node;
86-
}
87-
: undefined
88-
}
131+
ref={(node) => {
132+
if (currentTab.type !== "transcript") {
133+
scrollRef.current = node;
134+
containerRef.current = node;
135+
} else {
136+
containerRef.current = null;
137+
}
138+
}}
89139
onClick={handleContainerClick}
90140
className={cn([
91141
"flex-1 mt-2 px-3",

0 commit comments

Comments
 (0)