Skip to content

Commit 3a8dc62

Browse files
feat: force pin listening tab with fixed left section (#2890)
* feat: force pin listening tab and stop on close - Auto-pin session tab when listening starts - Allow closing active listening tabs (removed canClose restriction) - Stop listening and trigger auto-enhance when closing an active session tab Co-Authored-By: [email protected] <[email protected]> * feat: move listening tab to fixed section on far left - Listening tab is now rendered in a separate fixed section before navigation buttons - Tab is always visible when actively listening - Filtered out from the regular reorderable tab group Co-Authored-By: [email protected] <[email protected]> * Move pinned listening tab next to navigation buttons Place the pinned listening tab (listeningTab TabItem) to the left of the back/forward navigation buttons so the <- and -> controls appear on the left of the pinned listening tab. This reorders the JSX so the listening tab is rendered immediately after the navigation button group, achieving the requested UI placement change. * refactor(tabs): optimize tabs state management * refactor(tabs): optimize scroll state and active tab handling --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: John Jeong <[email protected]>
1 parent ce765d9 commit 3a8dc62

File tree

2 files changed

+94
-20
lines changed

2 files changed

+94
-20
lines changed

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

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useShallow } from "zustand/shallow";
1414
import { Button } from "@hypr/ui/components/ui/button";
1515
import { cn } from "@hypr/utils";
1616

17+
import { useListener } from "../../../contexts/listener";
1718
import { useNotifications } from "../../../contexts/notifications";
1819
import { useShell } from "../../../contexts/shell";
1920
import {
@@ -105,15 +106,31 @@ function Header({ tabs }: { tabs: Tab[] }) {
105106
unpin: state.unpin,
106107
})),
107108
);
109+
110+
const liveSessionId = useListener((state) => state.live.sessionId);
111+
const liveStatus = useListener((state) => state.live.status);
112+
const isListening = liveStatus === "active" || liveStatus === "finalizing";
113+
114+
const listeningTab =
115+
isListening && liveSessionId
116+
? tabs.find((t) => t.type === "sessions" && t.id === liveSessionId)
117+
: null;
118+
const regularTabs = listeningTab
119+
? tabs.filter((t) => !(t.type === "sessions" && t.id === liveSessionId))
120+
: tabs;
121+
108122
const tabsScrollContainerRef = useRef<HTMLDivElement>(null);
109123
const handleNewEmptyTab = useNewEmptyTab();
110124
const [isSearchManuallyExpanded, setIsSearchManuallyExpanded] =
111125
useState(false);
112126
const { ref: rightContainerRef, hasSpace: hasSpaceForSearch } =
113127
useHasSpaceForSearch();
114-
const scrollState = useScrollState(tabsScrollContainerRef, [tabs]);
128+
const scrollState = useScrollState(
129+
tabsScrollContainerRef,
130+
regularTabs.length,
131+
);
115132

116-
const setTabRef = useScrollActiveTabIntoView(tabs);
133+
const setTabRef = useScrollActiveTabIntoView(regularTabs);
117134
useTabsShortcuts();
118135

119136
return (
@@ -158,6 +175,21 @@ function Header({ tabs }: { tabs: Tab[] }) {
158175
</Button>
159176
</div>
160177

178+
{listeningTab && (
179+
<div className="flex items-center h-full shrink-0 mr-1">
180+
<TabItem
181+
tab={listeningTab}
182+
handleClose={close}
183+
handleSelect={select}
184+
handleCloseOthersCallback={closeOthers}
185+
handleCloseAll={closeAll}
186+
handlePin={pin}
187+
handleUnpin={unpin}
188+
tabIndex={1}
189+
/>
190+
</div>
191+
)}
192+
161193
<div className="relative h-full min-w-0">
162194
<div
163195
ref={tabsScrollContainerRef}
@@ -171,14 +203,23 @@ function Header({ tabs }: { tabs: Tab[] }) {
171203
key={leftsidebar.expanded ? "expanded" : "collapsed"}
172204
as="div"
173205
axis="x"
174-
values={tabs}
206+
values={regularTabs}
175207
onReorder={reorder}
176208
className="flex w-max gap-1 h-full"
177209
>
178-
{tabs.map((tab, index) => {
179-
const isLastTab = index === tabs.length - 1;
180-
const shortcutIndex =
181-
index < 8 ? index + 1 : isLastTab ? 9 : undefined;
210+
{regularTabs.map((tab, index) => {
211+
const isLastTab = index === regularTabs.length - 1;
212+
const shortcutIndex = listeningTab
213+
? index < 7
214+
? index + 2
215+
: isLastTab
216+
? 9
217+
: undefined
218+
: index < 8
219+
? index + 1
220+
: isLastTab
221+
? 9
222+
: undefined;
182223

183224
return (
184225
<Reorder.Item
@@ -566,7 +607,7 @@ function useHasSpaceForSearch() {
566607

567608
function useScrollState(
568609
ref: React.RefObject<HTMLDivElement | null>,
569-
deps: unknown[] = [],
610+
tabCount: number,
570611
) {
571612
const [scrollState, setScrollState] = useState({
572613
atStart: true,
@@ -578,9 +619,15 @@ function useScrollState(
578619
if (!container) return;
579620

580621
const { scrollLeft, scrollWidth, clientWidth } = container;
581-
setScrollState({
622+
const newState = {
582623
atStart: scrollLeft <= 1,
583624
atEnd: scrollLeft + clientWidth >= scrollWidth - 1,
625+
};
626+
setScrollState((prev) => {
627+
if (prev.atStart === newState.atStart && prev.atEnd === newState.atEnd) {
628+
return prev;
629+
}
630+
return newState;
584631
});
585632
}, [ref]);
586633

@@ -599,19 +646,19 @@ function useScrollState(
599646
return () => {
600647
container.removeEventListener("scroll", updateScrollState);
601648
};
602-
}, [updateScrollState, ...deps]);
649+
}, [updateScrollState, tabCount]);
603650

604651
return scrollState;
605652
}
606653

607654
function useScrollActiveTabIntoView(tabs: Tab[]) {
608655
const tabRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
656+
const activeTab = tabs.find((tab) => tab.active);
657+
const activeTabKey = activeTab ? uniqueIdfromTab(activeTab) : null;
609658

610659
useEffect(() => {
611-
const activeTab = tabs.find((tab) => tab.active);
612-
if (activeTab) {
613-
const tabKey = uniqueIdfromTab(activeTab);
614-
const tabElement = tabRefsMap.current.get(tabKey);
660+
if (activeTabKey) {
661+
const tabElement = tabRefsMap.current.get(activeTabKey);
615662
if (tabElement) {
616663
tabElement.scrollIntoView({
617664
behavior: "smooth",
@@ -620,7 +667,7 @@ function useScrollActiveTabIntoView(tabs: Tab[]) {
620667
});
621668
}
622669
}
623-
}, [tabs]);
670+
}, [activeTabKey]);
624671

625672
const setTabRef = useCallback((tab: Tab, el: HTMLDivElement | null) => {
626673
if (el) {

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Outlet,
44
useRouteContext,
55
} from "@tanstack/react-router";
6+
import { usePrevious } from "@uidotdev/usehooks";
67
import { useCallback, useEffect, useRef } from "react";
78

89
import { events as deeplink2Events } from "@hypr/plugin-deeplink2";
@@ -28,9 +29,15 @@ function Component() {
2829
const { persistedStore, aiTaskStore, toolRegistry } = useRouteContext({
2930
from: "__root__",
3031
});
31-
const { registerOnEmpty, registerCanClose, openNew, tabs } = useTabs();
32+
const { registerOnEmpty, registerCanClose, registerOnClose, openNew, pin } =
33+
useTabs();
34+
const tabs = useTabs((state) => state.tabs);
3235
const hasOpenedInitialTab = useRef(false);
36+
const liveSessionId = useListener((state) => state.live.sessionId);
37+
const liveStatus = useListener((state) => state.live.status);
38+
const prevLiveStatus = usePrevious(liveStatus);
3339
const getSessionMode = useListener((state) => state.getSessionMode);
40+
const stop = useListener((state) => state.stop);
3441

3542
useDeeplinkHandler();
3643

@@ -48,14 +55,34 @@ function Component() {
4855
}, [tabs.length, openDefaultEmptyTab, registerOnEmpty]);
4956

5057
useEffect(() => {
51-
registerCanClose((tab) => {
58+
const justStartedListening =
59+
prevLiveStatus !== "active" && liveStatus === "active";
60+
if (justStartedListening && liveSessionId) {
61+
const currentTabs = useTabs.getState().tabs;
62+
const sessionTab = currentTabs.find(
63+
(t) => t.type === "sessions" && t.id === liveSessionId,
64+
);
65+
if (sessionTab && !sessionTab.pinned) {
66+
pin(sessionTab);
67+
}
68+
}
69+
}, [liveStatus, prevLiveStatus, liveSessionId, pin]);
70+
71+
useEffect(() => {
72+
registerOnClose((tab) => {
5273
if (tab.type !== "sessions") {
53-
return true;
74+
return;
5475
}
5576
const mode = getSessionMode(tab.id);
56-
return mode !== "active" && mode !== "finalizing";
77+
if (mode === "active" || mode === "finalizing") {
78+
stop();
79+
}
5780
});
58-
}, [registerCanClose, getSessionMode]);
81+
}, [registerOnClose, getSessionMode, stop]);
82+
83+
useEffect(() => {
84+
registerCanClose(() => true);
85+
}, [registerCanClose]);
5986

6087
if (!aiTaskStore) {
6188
return null;

0 commit comments

Comments
 (0)