diff --git a/apps/desktop/src/routes/app/main/_layout.index.tsx b/apps/desktop/src/routes/app/main/_layout.index.tsx index 00ef2b4840..8044b4c62a 100644 --- a/apps/desktop/src/routes/app/main/_layout.index.tsx +++ b/apps/desktop/src/routes/app/main/_layout.index.tsx @@ -72,7 +72,7 @@ function Component() { return (
{leftsidebar.expanded && !isOnboarding && } diff --git a/apps/desktop/src/session/components/outer-header/listen.tsx b/apps/desktop/src/session/components/outer-header/listen.tsx index 754fe48f22..b64c2b4770 100644 --- a/apps/desktop/src/session/components/outer-header/listen.tsx +++ b/apps/desktop/src/session/components/outer-header/listen.tsx @@ -1,4 +1,3 @@ -import { useHover } from "@uidotdev/usehooks"; import { MicOff } from "lucide-react"; import { useCallback } from "react"; @@ -12,7 +11,6 @@ import { cn } from "@hypr/utils"; import { ActionableTooltipContent, - RecordingIcon, useHasTranscript, useListenButtonState, } from "~/session/components/shared"; @@ -25,7 +23,7 @@ export function ListenButton({ sessionId }: { sessionId: string }) { const hasTranscript = useHasTranscript(sessionId); if (!shouldRender) { - return ; + return ; } if (hasTranscript) { @@ -57,7 +55,6 @@ function StartButton({ sessionId }: { sessionId: string }) { "disabled:pointer-events-none disabled:opacity-50", ])} > - Resume listening @@ -95,83 +92,28 @@ function StartButton({ sessionId }: { sessionId: string }) { ); } -function InMeetingIndicator({ sessionId }: { sessionId: string }) { - const [ref, hovered] = useHover(); - - const { mode, stop, amplitude, muted } = useListener((state) => ({ +function DancingSticksIndicator({ sessionId }: { sessionId: string }) { + const { mode, amplitude, muted } = useListener((state) => ({ mode: state.getSessionMode(sessionId), - stop: state.stop, amplitude: state.live.amplitude, muted: state.live.muted, })); - const active = mode === "active" || mode === "finalizing"; - const finalizing = mode === "finalizing"; + const active = mode === "active"; if (!active) { return null; } return ( - - - - - - {finalizing ? "Finalizing..." : "Stop listening"} - - +
+ {muted && } + +
); } diff --git a/apps/desktop/src/session/components/shared.tsx b/apps/desktop/src/session/components/shared.tsx index 5e9f1305ed..cfff13b0cc 100644 --- a/apps/desktop/src/session/components/shared.tsx +++ b/apps/desktop/src/session/components/shared.tsx @@ -59,7 +59,7 @@ export function useCurrentNoteTab( } export function RecordingIcon() { - return
; + return
; } export function useListenButtonState(sessionId: string) { diff --git a/apps/desktop/src/shared/main/header-listen-button.tsx b/apps/desktop/src/shared/main/header-listen-button.tsx new file mode 100644 index 0000000000..8612b9b325 --- /dev/null +++ b/apps/desktop/src/shared/main/header-listen-button.tsx @@ -0,0 +1,213 @@ +import { ChevronDownIcon } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@hypr/ui/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; +import { cn } from "@hypr/utils"; + +import { useNewNote, useNewNoteAndListen } from "./useNewNote"; + +import { useTabs } from "~/store/zustand/tabs"; +import { useListener } from "~/stt/contexts"; + +const LABEL_WIDTH_ESTIMATE_PX = 90; + +export function HeaderListenButton({ + contentOverflowPx, +}: { + contentOverflowPx: number; +}) { + const liveSessionId = useListener((state) => state.live.sessionId); + const liveStatus = useListener((state) => state.live.status); + const stop = useListener((state) => state.stop); + + const isActive = liveStatus === "active"; + const isFinalizing = liveStatus === "finalizing"; + + const select = useTabs((state) => state.select); + const tabs = useTabs((state) => state.tabs); + + const handleStop = useCallback(() => { + stop(); + if (liveSessionId) { + const tab = tabs.find( + (t) => t.type === "sessions" && t.id === liveSessionId, + ); + if (tab) { + select(tab); + } + } + }, [stop, liveSessionId, tabs, select]); + + if (isActive || isFinalizing) { + return ( + 0} + finalizing={isFinalizing} + onStop={handleStop} + /> + ); + } + + return ; +} + +function StopButton({ + compact, + finalizing, + onStop, +}: { + compact: boolean; + finalizing: boolean; + onStop: () => void; +}) { + return ( + + + + + + {finalizing ? "Finalizing..." : "Stop listening"} + + + ); +} + +function DefaultButton({ contentOverflowPx }: { contentOverflowPx: number }) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showLabel, setShowLabel] = useState(true); + + useEffect(() => { + if (showLabel) { + if (contentOverflowPx > 0) { + setShowLabel(false); + } + } else { + if (contentOverflowPx + LABEL_WIDTH_ESTIMATE_PX <= 0) { + setShowLabel(true); + } + } + }, [contentOverflowPx, showLabel]); + + const handleNewRecording = useNewNoteAndListen(); + + return ( +
+ + + + + + + setDropdownOpen(false)} /> + + +
+ ); +} + +function UploadOptions({ onDone }: { onDone: () => void }) { + const handleNewNote = useNewNote({ behavior: "new" }); + + const handleOption = useCallback(() => { + onDone(); + handleNewNote(); + }, [onDone, handleNewNote]); + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/shared/main/index.tsx b/apps/desktop/src/shared/main/index.tsx index bc6cc366b1..38044a2eb9 100644 --- a/apps/desktop/src/shared/main/index.tsx +++ b/apps/desktop/src/shared/main/index.tsx @@ -7,7 +7,7 @@ import { PlusIcon, } from "lucide-react"; import { Reorder } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useResizeObserver } from "usehooks-ts"; import { useShallow } from "zustand/shallow"; @@ -23,6 +23,7 @@ import { import { cn } from "@hypr/utils"; import { TabContentEmpty, TabItemEmpty } from "./empty"; +import { HeaderListenButton } from "./header-listen-button"; import { useNewNote, useNewNoteAndListen } from "./useNewNote"; import { TabContentAI, TabItemAI } from "~/ai"; @@ -40,11 +41,9 @@ import { TabContentOnboarding, TabItemOnboarding } from "~/onboarding"; import { TabContentPlugin, TabItemPlugin } from "~/plugins"; import { loadPlugins } from "~/plugins/loader"; import { TabContentSearch, TabItemSearch } from "~/search/advanced"; -import { Search } from "~/search/components/search"; import { TabContentNote, TabItemNote } from "~/session"; import { useCaretPosition } from "~/session/components/caret-position-context"; import { TabContentSettings, TabItemSettings } from "~/settings"; -import { useNativeContextMenu } from "~/shared/hooks/useNativeContextMenu"; import { NotificationBadge } from "~/shared/ui/notification-badge"; import { TrafficLights } from "~/shared/ui/traffic-lights"; import { TabContentChangelog, TabItemChangelog } from "~/sidebar/changelog"; @@ -118,46 +117,11 @@ function Header({ tabs }: { tabs: Tab[] }) { })), ); - const liveSessionId = useListener((state) => state.live.sessionId); - const liveStatus = useListener((state) => state.live.status); - const isListening = liveStatus === "active" || liveStatus === "finalizing"; - - const listeningTab = useMemo( - () => - isListening && liveSessionId - ? tabs.find((t) => t.type === "sessions" && t.id === liveSessionId) - : null, - [isListening, liveSessionId, tabs], - ); - const regularTabs = useMemo( - () => - listeningTab - ? tabs.filter((t) => !(t.type === "sessions" && t.id === liveSessionId)) - : tabs, - [listeningTab, tabs, liveSessionId], - ); - const tabsScrollContainerRef = useRef(null); const handleNewEmptyTab = useNewEmptyTab(); - const handleNewNote = useNewNote({ behavior: "new" }); - const handleNewNoteAndListen = useNewNoteAndListen(); - const showNewTabMenu = useNativeContextMenu([ - { id: "empty-tab", text: "Open Empty Tab", action: handleNewEmptyTab }, - { id: "new-note", text: "Create New Note", action: handleNewNote }, - { - id: "new-note-listen", - text: "Create and Start Listening", - action: handleNewNoteAndListen, - }, - ]); - const [isSearchManuallyExpanded, setIsSearchManuallyExpanded] = - useState(false); - const scrollState = useScrollState( - tabsScrollContainerRef, - regularTabs.length, - ); + const scrollState = useScrollState(tabsScrollContainerRef, tabs.length); - const setTabRef = useScrollActiveTabIntoView(regularTabs); + const setTabRef = useScrollActiveTabIntoView(tabs); useTabsShortcuts(); return ( @@ -212,53 +176,27 @@ function Header({ tabs }: { tabs: Tab[] }) {
)} - {listeningTab && ( -
- -
- )} - -
+
- {regularTabs.map((tab, index) => { - const isLastTab = index === regularTabs.length - 1; - const shortcutIndex = listeningTab - ? index < 7 - ? index + 2 - : isLastTab - ? 9 - : undefined - : index < 8 - ? index + 1 - : isLastTab - ? 9 - : undefined; + {tabs.map((tab, index) => { + const isLastTab = index === tabs.length - 1; + const shortcutIndex = + index < 8 ? index + 1 : isLastTab ? 9 : undefined; return ( + +
+ +
{!scrollState.atStart && ( -
+
)} {!scrollState.atEnd && ( -
+
)}
- {!isSearchManuallyExpanded && ( - + + {!isOnboarding && ( + )} - -
- - {!isOnboarding && ( - - )} -
); @@ -681,7 +618,7 @@ export function StandardTabWrapper({ }) { return (
-
+
{children} {floatingButton} @@ -714,6 +651,7 @@ function useScrollState( const [scrollState, setScrollState] = useState({ atStart: true, atEnd: true, + contentOverflowPx: 0, }); const updateScrollState = useCallback(() => { @@ -722,12 +660,21 @@ function useScrollState( const { scrollLeft, scrollWidth, clientWidth } = container; const hasOverflow = scrollWidth > clientWidth + 1; + const contentWidth = Array.from(container.children).reduce( + (sum, child) => sum + child.getBoundingClientRect().width, + 0, + ); const newState = { atStart: !hasOverflow || scrollLeft <= 1, atEnd: !hasOverflow || scrollLeft + clientWidth >= scrollWidth - 1, + contentOverflowPx: Math.round(contentWidth - clientWidth), }; setScrollState((prev) => { - if (prev.atStart === newState.atStart && prev.atEnd === newState.atEnd) { + if ( + prev.atStart === newState.atStart && + prev.atEnd === newState.atEnd && + prev.contentOverflowPx === newState.contentOverflowPx + ) { return prev; } return newState; diff --git a/apps/desktop/src/shared/tabs.tsx b/apps/desktop/src/shared/tabs.tsx index ec8805c8fe..b80017c37d 100644 --- a/apps/desktop/src/shared/tabs.tsx +++ b/apps/desktop/src/shared/tabs.tsx @@ -37,8 +37,18 @@ const accentColors: Record< } > = { neutral: { - selected: ["bg-neutral-50", "text-black", "border-stone-400"], - unselected: ["bg-neutral-50", "text-neutral-500", "border-transparent"], + selected: [ + "bg-neutral-50", + "text-black", + "border-stone-400", + "hover:bg-stone-100", + ], + unselected: [ + "bg-neutral-50", + "text-neutral-500", + "border-transparent", + "hover:bg-stone-100", + ], hover: { selected: "text-neutral-700 hover:text-neutral-900", unselected: "text-neutral-500 hover:text-neutral-700", diff --git a/apps/desktop/src/sidebar/index.tsx b/apps/desktop/src/sidebar/index.tsx index cc3fa8f36b..6665408b80 100644 --- a/apps/desktop/src/sidebar/index.tsx +++ b/apps/desktop/src/sidebar/index.tsx @@ -1,7 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { platform } from "@tauri-apps/plugin-os"; -import { AxeIcon, PanelLeftCloseIcon } from "lucide-react"; -import { lazy, Suspense, useState } from "react"; +import { + AxeIcon, + Loader2Icon, + PanelLeftCloseIcon, + SearchIcon, + XIcon, +} from "lucide-react"; +import { lazy, Suspense, useMemo, useRef, useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; import { Kbd } from "@hypr/ui/components/ui/kbd"; @@ -20,6 +26,7 @@ import { useShell } from "~/contexts/shell"; import { SearchResults } from "~/search/components/sidebar"; import { useSearch } from "~/search/contexts/ui"; import { TrafficLights } from "~/shared/ui/traffic-lights"; +import { useTabs } from "~/store/zustand/tabs"; import { commands } from "~/types/tauri.gen"; const DevtoolView = lazy(() => @@ -48,7 +55,6 @@ export function LeftSidebar() { "h-9 w-full py-1", isLinux ? "justify-between pl-3" : "justify-end pl-20", "shrink-0", - "rounded-xl bg-neutral-50", ])} > {isLinux && } @@ -80,6 +86,8 @@ export function LeftSidebar() {
+ +
{leftsidebar.showDevtool ? ( @@ -102,3 +110,126 @@ export function LeftSidebar() {
); } + +function SidebarSearchInput() { + const { + query, + setQuery, + isSearching, + isIndexing, + inputRef, + results, + selectedIndex, + setSelectedIndex, + } = useSearch(); + const openNew = useTabs((state) => state.openNew); + const inputLocalRef = useRef(null); + + const flatResults = useMemo(() => { + if (!results) return []; + return results.groups.flatMap((g) => g.results); + }, [results]); + + const showLoading = isSearching || isIndexing; + + const ref = inputRef ?? inputLocalRef; + + return ( +
+
+ {showLoading ? ( + + ) : ( + + )} + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + if (query.trim()) { + setQuery(""); + setSelectedIndex(-1); + } else { + e.currentTarget.blur(); + } + } + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && query.trim()) { + e.preventDefault(); + openNew({ + type: "search", + state: { + selectedTypes: null, + initialQuery: query.trim(), + }, + }); + setQuery(""); + e.currentTarget.blur(); + } + if (e.key === "ArrowDown" && flatResults.length > 0) { + e.preventDefault(); + setSelectedIndex( + Math.min(selectedIndex + 1, flatResults.length - 1), + ); + } + if (e.key === "ArrowUp" && flatResults.length > 0) { + e.preventDefault(); + setSelectedIndex(Math.max(selectedIndex - 1, -1)); + } + if ( + e.key === "Enter" && + !e.metaKey && + !e.ctrlKey && + selectedIndex >= 0 && + selectedIndex < flatResults.length + ) { + e.preventDefault(); + const item = flatResults[selectedIndex]; + if (item.type === "session") { + openNew({ type: "sessions", id: item.id }); + } else if (item.type === "human") { + openNew({ + type: "contacts", + state: { + selected: { type: "person", id: item.id }, + }, + }); + } else if (item.type === "organization") { + openNew({ + type: "contacts", + state: { + selected: { type: "organization", id: item.id }, + }, + }); + } + e.currentTarget.blur(); + } + }} + className={cn([ + "min-w-0 flex-1 bg-transparent text-sm", + "placeholder:text-neutral-400", + "focus:outline-hidden", + ])} + /> + {query && ( + + )} + {!query && ⌘ K} +
+
+ ); +} diff --git a/apps/desktop/src/sidebar/profile/index.tsx b/apps/desktop/src/sidebar/profile/index.tsx index e5b6647864..6f0eeba9de 100644 --- a/apps/desktop/src/sidebar/profile/index.tsx +++ b/apps/desktop/src/sidebar/profile/index.tsx @@ -212,7 +212,7 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) { transition={{ duration: 0.2, ease: "easeInOut" }} className="absolute right-0 bottom-full left-0 mb-1" > -
+
{currentView === "main" ? ( @@ -269,7 +269,7 @@ export function ProfileSection({ onExpandChange }: ProfileSectionProps = {}) { )} -
+
setIsExpanded(!isExpanded)} @@ -316,8 +316,8 @@ function ProfileButton({ "px-4 py-2", "text-left", "transition-all duration-300", - "hover:bg-neutral-100", - isExpanded && "border-t border-neutral-100 bg-neutral-50", + "rounded-lg hover:bg-neutral-200/50", + isExpanded && "border-neutral-300 bg-neutral-200/50", ])} onClick={onClick} > diff --git a/apps/desktop/src/sidebar/timeline/index.tsx b/apps/desktop/src/sidebar/timeline/index.tsx index 81d0619420..2dfdefc0be 100644 --- a/apps/desktop/src/sidebar/timeline/index.tsx +++ b/apps/desktop/src/sidebar/timeline/index.tsx @@ -210,10 +210,7 @@ export function TimelineView() {
{buckets.map((bucket, index) => { const isToday = bucket.label === "Today"; @@ -225,12 +222,7 @@ export function TimelineView() { {shouldRenderIndicatorBefore && ( )} -
+
{bucket.label}
diff --git a/apps/desktop/src/sidebar/timeline/item.tsx b/apps/desktop/src/sidebar/timeline/item.tsx index c1d783e04c..f951857d05 100644 --- a/apps/desktop/src/sidebar/timeline/item.tsx +++ b/apps/desktop/src/sidebar/timeline/item.tsx @@ -113,7 +113,7 @@ function ItemBase({ "w-full cursor-pointer rounded-lg px-3 py-2 text-left", multiSelected && "bg-neutral-200", !multiSelected && selected && "bg-neutral-200", - !multiSelected && !selected && "hover:bg-neutral-100", + !multiSelected && !selected && "hover:bg-neutral-200/50", ignored && "opacity-40", ])} > diff --git a/apps/desktop/src/store/zustand/tabs/basic.ts b/apps/desktop/src/store/zustand/tabs/basic.ts index 73720f42fe..1a0deaf7e5 100644 --- a/apps/desktop/src/store/zustand/tabs/basic.ts +++ b/apps/desktop/src/store/zustand/tabs/basic.ts @@ -22,7 +22,7 @@ export type BasicState = { export type BasicActions = { openCurrent: (tab: TabInput) => void; - openNew: (tab: TabInput) => void; + openNew: (tab: TabInput, options?: { position?: "start" | "end" }) => void; select: (tab: Tab) => void; selectNext: () => void; selectPrev: () => void; @@ -72,9 +72,9 @@ export const createBasicSlice = < view: tab.type, }); }, - openNew: (tab) => { + openNew: (tab, options) => { const { tabs, history, addRecentlyOpened } = get(); - set(openTab(tabs, tab, history, true)); + set(openTab(tabs, tab, history, true, options?.position)); if (tab.type === "sessions") { addRecentlyOpened(tab.id); @@ -271,6 +271,7 @@ const openTab = ( newTab: TabInput, history: Map, forceNewTab: boolean, + position?: "start" | "end", ): Partial => { const tabWithDefaults: Tab = { ...getDefaultState(newTab), @@ -317,7 +318,17 @@ const openTab = ( } else { activeTab = { ...tabWithDefaults, active: true, slotId: id() }; const deactivated = deactivateAll(tabs); - nextTabs = [...deactivated, activeTab]; + + if (position === "start") { + const pinnedCount = deactivated.filter((t) => t.pinned).length; + nextTabs = [ + ...deactivated.slice(0, pinnedCount), + activeTab, + ...deactivated.slice(pinnedCount), + ]; + } else { + nextTabs = [...deactivated, activeTab]; + } return updateWithHistory(nextTabs, activeTab, history); } diff --git a/plugins/tray/src/menu_items/tray_start.rs b/plugins/tray/src/menu_items/tray_start.rs index a5a0348de5..ec481704a3 100644 --- a/plugins/tray/src/menu_items/tray_start.rs +++ b/plugins/tray/src/menu_items/tray_start.rs @@ -11,7 +11,7 @@ impl MenuItemHandler for TrayStart { const ID: &'static str = "hypr_tray_start"; fn build(app: &AppHandle) -> Result> { - let item = MenuItem::with_id(app, Self::ID, "Start a new recording", true, None::<&str>)?; + let item = MenuItem::with_id(app, Self::ID, "Start a new meeting", true, None::<&str>)?; Ok(MenuItemKind::MenuItem(item)) } @@ -42,7 +42,7 @@ impl TrayStart { MenuItem::with_id( app, Self::ID, - "Start a new recording", + "Start a new meeting", !disabled, None::<&str>, )