diff --git a/apps/desktop/src/calendar/components/calendar-view.tsx b/apps/desktop/src/calendar/components/calendar-view.tsx index 83b497a182..ca45b01449 100644 --- a/apps/desktop/src/calendar/components/calendar-view.tsx +++ b/apps/desktop/src/calendar/components/calendar-view.tsx @@ -124,9 +124,10 @@ export function CalendarView() { return (
@@ -139,11 +140,11 @@ export function CalendarView() {
- + - - {title} - {shortcutLabel && ( - - {shortcutLabel} - - )} - + {title} ); } @@ -95,11 +114,14 @@ function ChatActionButton({ function ChatGroups({ currentChatGroupId, onSelectChat, + surface = "light", }: { currentChatGroupId: string | undefined; onSelectChat: (chatGroupId: string) => void; + surface?: "light" | "dark"; }) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const isDark = surface === "dark"; const currentChatTitle = main.UI.useCell( "chat_groups", @@ -122,16 +144,26 @@ function ChatGroups({
-
+ {onSearchChange && ( -
-
+
+
state.currentTab); - useClassicMainTabsShortcuts(); + const goBack = useTabs((state) => state.goBack); + const goNext = useTabs((state) => state.goNext); + const canGoBack = useTabs((state) => state.canGoBack); + const canGoNext = useTabs((state) => state.canGoNext); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); + const { runEscapeShortcut } = useClassicMainTabsShortcuts(); const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); + const hasLeftSurfaceCustomSidebar = + hasLeftSurfaceCustomSidebarTab(currentTab); + const showSidebarTimeline = + sidebarTimelineEnabled && + leftsidebar.expanded && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; const showTopTimeline = leftsidebar.expanded && + !showSidebarTimeline && !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; const showFloatingToast = - !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; + !showSidebarTimeline && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + const showLeftSurfaceChromeBack = hasLeftSurfaceCustomSidebar; + const enableMainAreaTopDrag = + showSidebarTimeline || hasLeftSurfaceCustomSidebar; + const mainAreaTopDrag = useMainAreaTopWindowDrag(enableMainAreaTopDrag); return (
-
+ {showSidebarTimeline ? (
- {showTopTimeline ? ( -
- -
- ) : null} +
+ +
-
+ ) : hasLeftSurfaceCustomSidebar ? ( +
+ ) : ( +
+
+ {showTopTimeline ? ( +
+ +
+ ) : null} +
+
+ )} + {showLeftSurfaceChromeBack ? ( +
+
+ + + +
+
+ ) : null}
-
+
{currentTab ? ( ); } + +function useMainAreaTopWindowDrag(enabled: boolean) { + const windowDragStartRef = useRef(null); + const suppressNextClickRef = useRef(false); + + const handlePointerDown = useCallback( + (event: PointerEvent) => { + suppressNextClickRef.current = false; + + if ( + !enabled || + event.button !== 0 || + !isWithinMainAreaTopDragRegion(event) + ) { + windowDragStartRef.current = null; + return; + } + + windowDragStartRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + dragging: false, + }; + }, + [enabled], + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const dragStart = windowDragStartRef.current; + + if ( + !dragStart || + dragStart.dragging || + dragStart.pointerId !== event.pointerId || + !isMainAreaWindowDrag(dragStart, event) + ) { + return; + } + + dragStart.dragging = true; + suppressNextClickRef.current = true; + event.preventDefault(); + + if (isTauri()) { + void getCurrentWindow() + .startDragging() + .catch(() => {}); + } + }, + [], + ); + + const handlePointerEnd = useCallback( + (event: PointerEvent) => { + const dragStart = windowDragStartRef.current; + + if (!dragStart || dragStart.pointerId !== event.pointerId) { + return; + } + + windowDragStartRef.current = null; + }, + [], + ); + + const handleClickCapture = useCallback( + (event: MouseEvent) => { + if (!suppressNextClickRef.current) { + return; + } + + suppressNextClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + return { + onClickCapture: handleClickCapture, + onPointerDown: handlePointerDown, + onPointerEnd: handlePointerEnd, + onPointerMove: handlePointerMove, + }; +} + +function isWithinMainAreaTopDragRegion( + event: PointerEvent, +): boolean { + const rect = event.currentTarget.getBoundingClientRect(); + const offsetY = event.clientY - rect.top; + + return offsetY >= 0 && offsetY < MAIN_AREA_TOP_DRAG_HEIGHT_PX; +} + +function isMainAreaWindowDrag( + start: { clientX: number; clientY: number }, + current: { clientX: number; clientY: number }, +): boolean { + const deltaX = current.clientX - start.clientX; + const deltaY = current.clientY - start.clientY; + + return ( + deltaX * deltaX + deltaY * deltaY >= + MAIN_AREA_WINDOW_DRAG_THRESHOLD_PX * MAIN_AREA_WINDOW_DRAG_THRESHOLD_PX + ); +} + +function SidebarTimelineChrome({ + canGoBack, + canGoNext, + onBack, + onForward, +}: { + canGoBack: boolean; + canGoNext: boolean; + onBack: () => void; + onForward: () => void; +}) { + return ( +
+ + + + + + +
+ ); +} + +function SidebarTimelineChromeButton({ + ariaLabel, + children, + disabled = false, + onClick, +}: { + ariaLabel: string; + children: React.ReactNode; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/main/shell-frame.test.tsx b/apps/desktop/src/main/shell-frame.test.tsx new file mode 100644 index 0000000000..a3a2714071 --- /dev/null +++ b/apps/desktop/src/main/shell-frame.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + currentTab: { type: "empty" } as { type: string } | null, + leftsidebar: { + expanded: true, + showDevtool: false, + }, + sidebarTimelineEnabled: false, +})); + +vi.mock("./body", () => ({ + ClassicMainBody: () =>
, +})); + +vi.mock("~/shared/main", () => ({ + MainShellBodyFrame: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MainShellScaffold: ({ + children, + edgeToEdge, + mainSurfaceChrome, + }: { + children: React.ReactNode; + edgeToEdge?: boolean; + mainSurfaceChrome?: "default" | "top" | "left"; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + leftsidebar: mocks.leftsidebar, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: ( + selector: (state: { currentTab: typeof mocks.currentTab }) => unknown, + ) => selector({ currentTab: mocks.currentTab }), +})); + +import { ClassicMainShellFrame } from "./shell-frame"; + +describe("ClassicMainShellFrame", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + mocks.currentTab = { type: "empty" }; + mocks.leftsidebar.expanded = true; + mocks.leftsidebar.showDevtool = false; + mocks.sidebarTimelineEnabled = false; + }); + + it("uses top-edge main surface chrome in top timeline mode", () => { + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-main-surface-chrome"), + ).toBe("top"); + }); + + it("uses left-edge main surface chrome in sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = true; + + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-main-surface-chrome"), + ).toBe("left"); + }); + + it("uses left-edge main surface chrome for custom sidebar tabs", () => { + mocks.currentTab = { type: "settings" }; + + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-main-surface-chrome"), + ).toBe("left"); + }); +}); diff --git a/apps/desktop/src/main/shell-frame.tsx b/apps/desktop/src/main/shell-frame.tsx index aafb7d1c86..c1e399bacd 100644 --- a/apps/desktop/src/main/shell-frame.tsx +++ b/apps/desktop/src/main/shell-frame.tsx @@ -1,11 +1,45 @@ import { ClassicMainBody } from "./body"; +import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { MainShellBodyFrame, MainShellScaffold } from "~/shared/main"; +import { + hasCustomSidebarTab, + hasLeftSurfaceCustomSidebarTab, +} from "~/sidebar/use-custom-sidebar"; +import { useTabs } from "~/store/zustand/tabs"; export function ClassicMainShellFrame() { + const { leftsidebar } = useShell(); + const currentTab = useTabs((state) => state.currentTab); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); + + const isOnboarding = currentTab?.type === "onboarding"; + const hasCustomSidebar = hasCustomSidebarTab(currentTab); + const hasLeftSurfaceCustomSidebar = + hasLeftSurfaceCustomSidebarTab(currentTab); + const showSidebarTimeline = + sidebarTimelineEnabled && + leftsidebar.expanded && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + const showTopTimeline = + leftsidebar.expanded && + !showSidebarTimeline && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + const mainSurfaceChrome = + showSidebarTimeline || hasLeftSurfaceCustomSidebar + ? "left" + : showTopTimeline + ? "top" + : "default"; + return ( - - + + diff --git a/apps/desktop/src/main/shell-sidebar.tsx b/apps/desktop/src/main/shell-sidebar.tsx index 54f7c20ba2..6c5c950071 100644 --- a/apps/desktop/src/main/shell-sidebar.tsx +++ b/apps/desktop/src/main/shell-sidebar.tsx @@ -1,4 +1,5 @@ import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { LeftSidebar } from "~/sidebar"; import { hasCustomSidebarTab, @@ -9,6 +10,7 @@ import { useTabs } from "~/store/zustand/tabs"; export function ClassicMainSidebar() { const { leftsidebar } = useShell(); const currentTab = useTabs((state) => state.currentTab); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); @@ -19,7 +21,7 @@ export function ClassicMainSidebar() { return null; } - if (leftsidebar.showDevtool || hasCustomSidebar) { + if (leftsidebar.showDevtool || hasCustomSidebar || sidebarTimelineEnabled) { return ; } diff --git a/apps/desktop/src/main/useTabsShortcuts.test.tsx b/apps/desktop/src/main/useTabsShortcuts.test.tsx index 58b53eadf6..a05a2dc334 100644 --- a/apps/desktop/src/main/useTabsShortcuts.test.tsx +++ b/apps/desktop/src/main/useTabsShortcuts.test.tsx @@ -2,7 +2,7 @@ import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ - chatMode: "FloatingClosed" as "FloatingClosed" | "RightPanelOpen", + chatMode: "FloatingClosed" as "FloatingClosed" | "FloatingOpen", currentTab: null as null | { active: boolean; slotId: string; type: string }, handlers: new Map void>(), openCurrent: vi.fn(), @@ -130,6 +130,20 @@ describe("useClassicMainTabsShortcuts", () => { expect(hoisted.openCurrent).toHaveBeenCalledWith({ type: "empty" }); }); + it("returns the escape shortcut action", () => { + hoisted.currentTab = { + active: true, + slotId: "slot-session", + type: "sessions", + }; + + const { result } = renderHook(() => useClassicMainTabsShortcuts()); + + result.current.runEscapeShortcut(); + + expect(hoisted.openCurrent).toHaveBeenCalledWith({ type: "empty" }); + }); + it("opens the home view even when the editor stops escape propagation", () => { hoisted.currentTab = { active: true, @@ -172,8 +186,8 @@ describe("useClassicMainTabsShortcuts", () => { expect(hoisted.openCurrent).not.toHaveBeenCalled(); }); - it("closes the chat panel before going home on escape", () => { - hoisted.chatMode = "RightPanelOpen"; + it("closes the floating chat before going home on escape", () => { + hoisted.chatMode = "FloatingOpen"; hoisted.currentTab = { active: true, slotId: "slot-session", diff --git a/apps/desktop/src/session/components/bottom-accessory/index.test.tsx b/apps/desktop/src/session/components/bottom-accessory/index.test.tsx index 5f485d80e9..6c5f24137c 100644 --- a/apps/desktop/src/session/components/bottom-accessory/index.test.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/index.test.tsx @@ -120,7 +120,7 @@ describe("useSessionBottomAccessory", () => { it("defers transcript escape handling while chat is open", () => { useShellMock.mockReturnValue({ chat: { - mode: "RightPanelOpen", + mode: "FloatingOpen", }, }); diff --git a/apps/desktop/src/session/components/bottom-accessory/index.tsx b/apps/desktop/src/session/components/bottom-accessory/index.tsx index 8298349ad9..70e459eaaa 100644 --- a/apps/desktop/src/session/components/bottom-accessory/index.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/index.tsx @@ -42,7 +42,7 @@ export function useSessionBottomAccessory({ const canExpandLiveTranscript = showLiveAccessory; const effectiveExpanded = isLive && !canExpandLiveTranscript ? false : isExpanded; - const isChatVisible = chat.mode === "RightPanelOpen"; + const isChatVisible = chat.mode === "FloatingOpen"; const prevLive = useRef(isLive); useEffect(() => { diff --git a/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx b/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx index c6b806abe0..2d8d408807 100644 --- a/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx @@ -237,6 +237,8 @@ describe("PostSessionAccessory", () => { const transcriptCard = scrollArea?.parentElement; const transcriptSlot = transcriptCard?.parentElement; + expect(transcriptCard?.className).toContain("rounded-b-xl"); + expect(transcriptCard?.className).toContain("border"); expect(transcriptCard?.className).toContain("h-full"); expect(transcriptCard?.className).toContain("min-h-[114px]"); expect(transcriptCard?.className).not.toContain("min-h-[96px]"); diff --git a/apps/desktop/src/session/components/bottom-accessory/post-session.tsx b/apps/desktop/src/session/components/bottom-accessory/post-session.tsx index e619064672..336951182f 100644 --- a/apps/desktop/src/session/components/bottom-accessory/post-session.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/post-session.tsx @@ -530,7 +530,7 @@ function TranscriptCard({ return (
({ + leftsidebar: { + expanded: true, + showDevtool: false, + }, + sessionModes: {} as Record, + sidebarTimelineEnabled: false, + stopListening: vi.fn(), +})); + +vi.mock("./metadata", () => ({ + MetadataButton: () => , +})); + +vi.mock("./overflow", () => ({ + OverflowButton: () => , +})); + +vi.mock("@hypr/ui/components/ui/dancing-sticks", () => ({ + DancingSticks: () => , +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + leftsidebar: mocks.leftsidebar, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + +vi.mock("~/stt/contexts", () => ({ + useListener: vi.fn((selector: (state: unknown) => unknown) => + selector({ + getSessionMode: (sessionId: string) => + mocks.sessionModes[sessionId] ?? "inactive", + live: { + amplitude: { + mic: 0.5, + speaker: 0.25, + }, + degraded: null, + muted: false, + }, + stop: mocks.stopListening, + }), + ), +})); + +import { OuterHeader } from "./index"; + +describe("OuterHeader", () => { + beforeEach(() => { + mocks.leftsidebar.expanded = true; + mocks.leftsidebar.showDevtool = false; + mocks.sessionModes = {}; + mocks.sidebarTimelineEnabled = false; + mocks.stopListening.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows a stop listening button for active sessions in sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = true; + mocks.sessionModes = { "session-1": "active" }; + + render( + Session title} + />, + ); + + const stopButton = screen.getByRole("button", { + name: "Stop listening", + }); + + fireEvent.click(stopButton); + + expect(screen.getByTestId("dancing-sticks")).not.toBeNull(); + expect(stopButton.className).toContain("h-7"); + expect(stopButton.className).toContain("w-20"); + expect(stopButton.className).toContain("rounded-full"); + expect(stopButton.textContent).toContain("Stop"); + expect(mocks.stopListening).toHaveBeenCalledTimes(1); + }); + + it("keeps the session header at 48px tall", () => { + const { container } = render( + Session title} + />, + ); + + expect(container.firstElementChild?.className).toContain("h-12"); + }); + + it("keeps the header content row full width", () => { + const { container } = render( + Session title} + />, + ); + + expect(container.firstElementChild?.firstElementChild?.className).toContain( + "w-full", + ); + }); + + it("keeps the dedicated stop button hidden outside sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = false; + mocks.sessionModes = { "session-1": "active" }; + + render( + Session title} + />, + ); + + expect(screen.queryByRole("button", { name: "Stop listening" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/session/components/outer-header/index.tsx b/apps/desktop/src/session/components/outer-header/index.tsx index a42744ca29..1fb6e95af9 100644 --- a/apps/desktop/src/session/components/outer-header/index.tsx +++ b/apps/desktop/src/session/components/outer-header/index.tsx @@ -1,7 +1,15 @@ +import { MicOff } from "lucide-react"; + +import { DancingSticks } from "@hypr/ui/components/ui/dancing-sticks"; +import { cn } from "@hypr/utils"; + import { MetadataButton } from "./metadata"; import { OverflowButton } from "./overflow"; +import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import type { EditorView } from "~/store/zustand/tabs/schema"; +import { useListener } from "~/stt/contexts"; export function OuterHeader({ sessionId, @@ -13,10 +21,11 @@ export function OuterHeader({ title?: React.ReactNode; }) { return ( -
-
+
+
{title ?
{title}
: null} -
+
+
@@ -24,3 +33,87 @@ export function OuterHeader({
); } + +function SidebarModeStopButton({ sessionId }: { sessionId: string }) { + const { leftsidebar } = useShell(); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); + const { amplitude, degraded, mode, muted, stop } = useListener((state) => ({ + amplitude: state.live.amplitude, + degraded: state.live.degraded, + mode: state.getSessionMode(sessionId), + muted: state.live.muted, + stop: state.stop, + })); + const active = mode === "active" || mode === "finalizing"; + const finalizing = mode === "finalizing"; + + if ( + !sidebarTimelineEnabled || + !leftsidebar.expanded || + leftsidebar.showDevtool || + !active + ) { + return null; + } + + const accent = degraded ? "amber" : "red"; + const colors = { + red: { + button: "text-red-500 hover:text-red-600 bg-red-50 hover:bg-red-100", + sticks: "#ef4444", + stop: "bg-red-500", + }, + amber: { + button: + "text-amber-500 hover:text-amber-600 bg-amber-50 hover:bg-amber-100", + sticks: "#f59e0b", + stop: "bg-amber-500", + }, + }[accent]; + + return ( + + ); +} diff --git a/apps/desktop/src/session/components/outer-header/metadata/index.tsx b/apps/desktop/src/session/components/outer-header/metadata/index.tsx index 57f373c701..8f1ec46b05 100644 --- a/apps/desktop/src/session/components/outer-header/metadata/index.tsx +++ b/apps/desktop/src/session/components/outer-header/metadata/index.tsx @@ -71,6 +71,7 @@ const TriggerInner = forwardRef< variant="ghost" size="sm" className={cn([ + "rounded-full px-3", "text-neutral-600 hover:text-black", open && "bg-neutral-100", hasEvent && "max-w-50", diff --git a/apps/desktop/src/session/components/outer-header/overflow/index.tsx b/apps/desktop/src/session/components/outer-header/overflow/index.tsx index c77f4114d0..86f2245471 100644 --- a/apps/desktop/src/session/components/outer-header/overflow/index.tsx +++ b/apps/desktop/src/session/components/outer-header/overflow/index.tsx @@ -74,7 +74,7 @@ export function OverflowButton({ diff --git a/apps/desktop/src/session/components/title-input.tsx b/apps/desktop/src/session/components/title-input.tsx index 4ccef7c16e..b700112f0b 100644 --- a/apps/desktop/src/session/components/title-input.tsx +++ b/apps/desktop/src/session/components/title-input.tsx @@ -85,7 +85,7 @@ export const TitleInput = forwardRef< if (isGenerating) { return (
- + Generating title...
@@ -95,7 +95,7 @@ export const TitleInput = forwardRef< if (showRevealAnimation && generatedTitle) { return (
- + {generatedTitle}
@@ -350,7 +350,7 @@ const TitleInputInner = memo( className={cn([ "min-w-0 flex-1 transition-opacity duration-200", "border-none bg-transparent focus:outline-hidden", - "placeholder:text-muted-foreground text-sm font-semibold", + "placeholder:text-muted-foreground text-xl font-semibold", ])} /> {onGenerateTitle && !localTitle.trim() && ( diff --git a/apps/desktop/src/settings/general/app-settings.tsx b/apps/desktop/src/settings/general/app-settings.tsx index d8673f0362..46a5d6505b 100644 --- a/apps/desktop/src/settings/general/app-settings.tsx +++ b/apps/desktop/src/settings/general/app-settings.tsx @@ -14,6 +14,7 @@ interface AppSettingsViewProps { autoStartScheduledMeetings: SettingItem; autoStopMeetings: SettingItem; floatingBar: SettingItem; + sidebarTimeline: SettingItem; telemetryConsent: SettingItem; } @@ -22,45 +23,62 @@ export function AppSettingsView({ autoStartScheduledMeetings, autoStopMeetings, floatingBar, + sidebarTimeline, telemetryConsent, }: AppSettingsViewProps) { return ( -
-

- App -

-
- - - - - -
+
+
+

+ App +

+
+ + + +
+
+ +
+

+ Meetings +

+
+ + + +
+
); } diff --git a/apps/desktop/src/settings/general/index.tsx b/apps/desktop/src/settings/general/index.tsx index f54890f0c6..8a7c781136 100644 --- a/apps/desktop/src/settings/general/index.tsx +++ b/apps/desktop/src/settings/general/index.tsx @@ -29,6 +29,7 @@ function useSettingsForm() { "auto_start_scheduled_meetings", "auto_stop_meetings", "floating_bar_enabled", + "sidebar_timeline_enabled", "notification_detect", "telemetry_consent", "ai_language", @@ -66,6 +67,7 @@ function useSettingsForm() { auto_start_scheduled_meetings: value.auto_start_scheduled_meetings, auto_stop_meetings: value.auto_stop_meetings, floating_bar_enabled: value.floating_bar_enabled, + sidebar_timeline_enabled: value.sidebar_timeline_enabled, notification_detect: value.notification_detect, telemetry_consent: value.telemetry_consent, ai_language: value.ai_language, @@ -109,6 +111,7 @@ function useSettingsForm() { normalizedValue.auto_start_scheduled_meetings, auto_stop_meetings: normalizedValue.auto_stop_meetings, floating_bar_enabled: normalizedValue.floating_bar_enabled, + sidebar_timeline_enabled: normalizedValue.sidebar_timeline_enabled, notification_detect: normalizedValue.notification_detect, telemetry_consent: normalizedValue.telemetry_consent, }); @@ -150,48 +153,62 @@ export function SettingsApp() { {(autoStopMeetingsField) => ( {(floatingBarEnabledField) => ( - - {(telemetryConsentField) => ( - - autostartField.handleChange(val), - }} - autoStartScheduledMeetings={{ - title: t`Start when meeting begins`, - description: t`Automatically start listening when an event-backed note reaches its scheduled start time.`, - value: - autoStartScheduledMeetingsField.state.value, - onChange: (val) => - autoStartScheduledMeetingsField.handleChange( - val, - ), - }} - autoStopMeetings={{ - title: t`Stop when meeting ends`, - description: t`Automatically stop listening when the meeting app releases the microphone.`, - value: autoStopMeetingsField.state.value, - onChange: (val) => - autoStopMeetingsField.handleChange(val), - }} - floatingBar={{ - title: t`Show floating bar`, - description: t`Show the compact floating control while listening.`, - value: floatingBarEnabledField.state.value, - onChange: (val) => - floatingBarEnabledField.handleChange(val), - }} - telemetryConsent={{ - title: t`Share usage data`, - description: t`Send anonymous usage analytics to help improve Anarlog.`, - value: telemetryConsentField.state.value, - onChange: (val) => - telemetryConsentField.handleChange(val), - }} - /> + + {(sidebarTimelineEnabledField) => ( + + {(telemetryConsentField) => ( + + autostartField.handleChange(val), + }} + autoStartScheduledMeetings={{ + title: t`Start when meeting begins`, + description: t`Automatically start listening when an event-backed note reaches its scheduled start time.`, + value: + autoStartScheduledMeetingsField.state.value, + onChange: (val) => + autoStartScheduledMeetingsField.handleChange( + val, + ), + }} + autoStopMeetings={{ + title: t`Stop when meeting ends`, + description: t`Automatically stop listening when the meeting app releases the microphone.`, + value: autoStopMeetingsField.state.value, + onChange: (val) => + autoStopMeetingsField.handleChange(val), + }} + floatingBar={{ + title: t`Show floating bar`, + description: t`Show the compact floating control while listening.`, + value: floatingBarEnabledField.state.value, + onChange: (val) => + floatingBarEnabledField.handleChange(val), + }} + sidebarTimeline={{ + title: t`Show timeline in sidebar`, + description: t`Use the left sidebar timeline instead of the top timeline.`, + value: + sidebarTimelineEnabledField.state.value, + onChange: (val) => + sidebarTimelineEnabledField.handleChange( + val, + ), + }} + telemetryConsent={{ + title: t`Share usage data`, + description: t`Send anonymous usage analytics to help improve Anarlog.`, + value: telemetryConsentField.state.value, + onChange: (val) => + telemetryConsentField.handleChange(val), + }} + /> + )} + )} )} diff --git a/apps/desktop/src/shared/chat-cta.test.tsx b/apps/desktop/src/shared/chat-cta.test.tsx new file mode 100644 index 0000000000..d3ab5906df --- /dev/null +++ b/apps/desktop/src/shared/chat-cta.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + chatMode: "FloatingClosed" as "FloatingClosed" | "FloatingOpen", + sendEvent: vi.fn(), +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + chat: { + mode: mocks.chatMode, + sendEvent: mocks.sendEvent, + }, + }), +})); + +import { ChatCTA } from "./chat-cta"; + +describe("ChatCTA", () => { + beforeEach(() => { + cleanup(); + mocks.chatMode = "FloatingClosed"; + mocks.sendEvent.mockClear(); + }); + + it("opens the floating chat", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Ask Anarlog anything" }), + ); + + expect(mocks.sendEvent).toHaveBeenCalledWith({ type: "OPEN" }); + }); + + it("hides while the floating chat is open", () => { + mocks.chatMode = "FloatingOpen"; + + render(); + + expect( + screen.queryByRole("button", { name: "Ask Anarlog anything" }), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/chat-cta.tsx b/apps/desktop/src/shared/chat-cta.tsx index 5564d48f57..5c97617940 100644 --- a/apps/desktop/src/shared/chat-cta.tsx +++ b/apps/desktop/src/shared/chat-cta.tsx @@ -10,15 +10,10 @@ export function ChatCTA({ label?: string; }) { const { chat } = useShell(); - const isChatOpen = chat.mode === "RightPanelOpen"; + const isChatOpen = chat.mode === "FloatingOpen"; const handleClick = () => { - if (isChatOpen) { - chat.sendEvent({ type: "TOGGLE" }); - return; - } - - chat.sendEvent({ type: "OPEN_RIGHT_PANEL" }); + chat.sendEvent({ type: "OPEN" }); }; if (isChatOpen) { diff --git a/apps/desktop/src/shared/main/body-frame.tsx b/apps/desktop/src/shared/main/body-frame.tsx index e8687085d8..2f63d93e17 100644 --- a/apps/desktop/src/shared/main/body-frame.tsx +++ b/apps/desktop/src/shared/main/body-frame.tsx @@ -4,25 +4,14 @@ import { SessionStatusBannerProvider, } from "./session-status-banner"; -import { useShell } from "~/contexts/shell"; - export function MainShellBodyFrame({ - autoSaveId, children, }: { - autoSaveId: string; children: React.ReactNode; }) { - const { chat } = useShell(); - return ( - - {children} - + {children} ); diff --git a/apps/desktop/src/shared/main/body.test.tsx b/apps/desktop/src/shared/main/body.test.tsx index 99bc3c8064..2781a08a09 100644 --- a/apps/desktop/src/shared/main/body.test.tsx +++ b/apps/desktop/src/shared/main/body.test.tsx @@ -1,8 +1,44 @@ -import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + openNew: vi.fn(), + goBack: vi.fn(), + goNext: vi.fn(), + runEscapeShortcut: vi.fn(), + isTauri: vi.fn(() => true), + startDragging: vi.fn().mockResolvedValue(undefined), + canGoBack: false, + canGoNext: false, + currentTab: { + active: true, + pinned: false, + slotId: "slot-1", + type: "empty", + }, + sidebarTimelineEnabled: false, +})); + +vi.mock("@tauri-apps/api/core", () => ({ + isTauri: mocks.isTauri, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + startDragging: mocks.startDragging, + }), +})); vi.mock("~/main/useTabsShortcuts", () => ({ - useClassicMainTabsShortcuts: vi.fn(), + useClassicMainTabsShortcuts: vi.fn(() => ({ + runEscapeShortcut: mocks.runEscapeShortcut, + })), })); vi.mock("~/main/tab-content", () => ({ @@ -28,6 +64,10 @@ vi.mock("~/contexts/shell", () => ({ }), })); +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + vi.mock("~/sidebar/toast", () => ({ ToastArea: () =>
, })); @@ -37,12 +77,12 @@ vi.mock("~/store/zustand/tabs", () => ({ useTabs: vi.fn((selector: (state: unknown) => unknown) => selector({ tabs: [{ active: true, pinned: false, slotId: "slot-1", type: "empty" }], - currentTab: { - active: true, - pinned: false, - slotId: "slot-1", - type: "empty", - }, + currentTab: mocks.currentTab, + canGoBack: mocks.canGoBack, + canGoNext: mocks.canGoNext, + goBack: mocks.goBack, + goNext: mocks.goNext, + openNew: mocks.openNew, }), ), })); @@ -50,6 +90,28 @@ vi.mock("~/store/zustand/tabs", () => ({ import { ClassicMainBody } from "~/main/body"; describe("ClassicMainBody", () => { + beforeEach(() => { + mocks.openNew.mockClear(); + mocks.goBack.mockClear(); + mocks.goNext.mockClear(); + mocks.runEscapeShortcut.mockClear(); + mocks.isTauri.mockReturnValue(true); + mocks.startDragging.mockClear(); + mocks.canGoBack = false; + mocks.canGoNext = false; + mocks.currentTab = { + active: true, + pinned: false, + slotId: "slot-1", + type: "empty", + }; + mocks.sidebarTimelineEnabled = false; + }); + + afterEach(() => { + cleanup(); + }); + it("renders the shell and current tab content", () => { render(); @@ -70,6 +132,140 @@ describe("ClassicMainBody", () => { ); }); + it("hides the top timeline when the sidebar timeline is enabled", () => { + mocks.sidebarTimelineEnabled = true; + + render(); + + expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); + expect(screen.queryByTestId("toast-area")).toBeNull(); + const sidebar = screen.getByTestId("main-sidebar"); + const backButton = screen.getByRole("button", { name: "Go back" }); + const topArea = backButton.parentElement?.parentElement?.parentElement; + + expect(sidebar).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Open calendar" })).toBeNull(); + expect(backButton.hasAttribute("disabled")).toBe(true); + expect( + screen + .getByRole("button", { name: "Go forward" }) + .hasAttribute("disabled"), + ).toBe(true); + expect(topArea?.className).toContain("h-12"); + expect(topArea?.className).toContain("absolute"); + expect(backButton.parentElement?.parentElement?.className).toContain( + "pt-[9px]", + ); + expect(sidebar.parentElement?.className).toContain("flex min-h-0"); + expect(sidebar.parentElement?.className).not.toContain("pt-12"); + }); + + it("navigates history from the sidebar timeline chrome", () => { + mocks.sidebarTimelineEnabled = true; + mocks.canGoBack = true; + mocks.canGoNext = true; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + fireEvent.click(screen.getByRole("button", { name: "Go forward" })); + + expect(mocks.openNew).not.toHaveBeenCalled(); + expect(mocks.goBack).toHaveBeenCalledTimes(1); + expect(mocks.goNext).toHaveBeenCalledTimes(1); + }); + + it.each(["calendar", "settings", "contacts", "templates"])( + "runs the escape shortcut from the %s left chrome back button", + (type) => { + mocks.currentTab = { + active: true, + pinned: false, + slotId: "slot-1", + type, + }; + + render(); + + const backButton = screen.getByRole("button", { name: "Go back" }); + const topArea = backButton.parentElement?.parentElement; + + fireEvent.click(backButton); + + expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); + expect(screen.queryByRole("button", { name: "Go forward" })).toBeNull(); + expect(backButton.hasAttribute("disabled")).toBe(false); + expect(topArea?.className).toContain("h-12"); + expect(topArea?.className).toContain("absolute"); + expect(mocks.goBack).not.toHaveBeenCalled(); + expect(mocks.runEscapeShortcut).toHaveBeenCalledTimes(1); + }, + ); + + it("starts window dragging from the top 48px of the main area in sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = true; + + render(); + + const mainContent = screen.getByTestId("main-tab-content"); + + fireEvent.pointerDown(mainContent, { + button: 0, + clientX: 12, + clientY: 12, + pointerId: 1, + }); + fireEvent.pointerMove(mainContent, { + clientX: 20, + clientY: 12, + pointerId: 1, + }); + + expect(mocks.startDragging).toHaveBeenCalledTimes(1); + }); + + it("does not start window dragging below the main area drag strip", () => { + mocks.sidebarTimelineEnabled = true; + + render(); + + const mainContent = screen.getByTestId("main-tab-content"); + + fireEvent.pointerDown(mainContent, { + button: 0, + clientX: 12, + clientY: 56, + pointerId: 1, + }); + fireEvent.pointerMove(mainContent, { + clientX: 20, + clientY: 56, + pointerId: 1, + }); + + expect(mocks.startDragging).not.toHaveBeenCalled(); + }); + + it("does not add main area dragging when the top timeline owns the titlebar", () => { + render(); + + const mainContent = screen.getByTestId("main-tab-content"); + + fireEvent.pointerDown(mainContent, { + button: 0, + clientX: 12, + clientY: 12, + pointerId: 1, + }); + fireEvent.pointerMove(mainContent, { + clientX: 20, + clientY: 12, + pointerId: 1, + }); + + expect(mocks.startDragging).not.toHaveBeenCalled(); + }); + it("renders the shell while the initial tab is still loading", async () => { const { useTabs } = await import("~/store/zustand/tabs"); diff --git a/apps/desktop/src/shared/main/chat-panels.test.tsx b/apps/desktop/src/shared/main/chat-panels.test.tsx index 50199a7035..fd99e4b5f5 100644 --- a/apps/desktop/src/shared/main/chat-panels.test.tsx +++ b/apps/desktop/src/shared/main/chat-panels.test.tsx @@ -1,101 +1,44 @@ -import { cleanup, render, waitFor } from "@testing-library/react"; -import * as React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getSizeMock, resizeMock, windowExpandWidthMock } = vi.hoisted(() => ({ - getSizeMock: vi.fn(() => 100), - resizeMock: vi.fn(), - windowExpandWidthMock: vi.fn(() => Promise.resolve()), -})); - -vi.mock("@hypr/plugin-windows", () => ({ - commands: { - windowExpandWidth: windowExpandWidthMock, - windowRestoreWidth: vi.fn(() => Promise.resolve()), - }, +const mocks = vi.hoisted(() => ({ + persistentChatPanel: vi.fn(), })); vi.mock("~/chat/components/persistent-chat", () => ({ - PersistentChatPanel: () => null, + PersistentChatPanel: ({ + floatingContainerRef, + }: { + floatingContainerRef: { current: HTMLDivElement | null }; + }) => { + mocks.persistentChatPanel(floatingContainerRef); + return
; + }, })); -vi.mock("@hypr/ui/components/ui/resizable", async () => { - const React = await vi.importActual("react"); - - return { - ResizablePanelGroup: ({ - children, - direction, - }: { - children: React.ReactNode; - direction: string; - }) => ( -
- {children} -
- ), - ResizablePanel: React.forwardRef< - { getSize: () => number; resize: (size: number) => void }, - { - children: React.ReactNode; - className?: string; - defaultSize?: number; - maxSize?: number; - minSize?: number; - } - >(function ResizablePanel( - { children, className, defaultSize, maxSize, minSize }, - ref, - ) { - React.useImperativeHandle(ref, () => ({ - getSize: getSizeMock, - resize: resizeMock, - })); - - return ( -
- {children} -
- ); - }), - ResizableHandle: ({ className }: { className?: string }) => ( -
- ), - }; -}); - import { MainChatPanels } from "./chat-panels"; describe("MainChatPanels", () => { beforeEach(() => { cleanup(); - getSizeMock.mockClear(); - resizeMock.mockClear(); - windowExpandWidthMock.mockClear(); + mocks.persistentChatPanel.mockClear(); }); - it("only asks the native window to expand while below the chat replacement width", async () => { - const { rerender } = render( - + it("renders the main content and persistent floating chat host", () => { + render( +
, ); - rerender( - -
- , + expect(screen.getByTestId("main-content")).toBeTruthy(); + expect(screen.getByTestId("persistent-chat-panel")).toBeTruthy(); + expect(mocks.persistentChatPanel).toHaveBeenCalledTimes(1); + expect(mocks.persistentChatPanel.mock.calls[0]?.[0].current).toBeInstanceOf( + HTMLDivElement, ); - - await waitFor(() => { - expect(windowExpandWidthMock).toHaveBeenCalledWith(400, 720, true, false); - }); - expect(resizeMock).toHaveBeenCalledWith(100); + expect(screen.queryByTestId("resize-handle")).toBeNull(); + expect(screen.queryByTestId("panel")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); }); }); diff --git a/apps/desktop/src/shared/main/chat-panels.tsx b/apps/desktop/src/shared/main/chat-panels.tsx index 20fa08fcaf..28dd0d186c 100644 --- a/apps/desktop/src/shared/main/chat-panels.tsx +++ b/apps/desktop/src/shared/main/chat-panels.tsx @@ -1,87 +1,22 @@ -import { useEffect, useRef } from "react"; - -import { commands as windowsCommands } from "@hypr/plugin-windows"; -import { - type ImperativePanelHandle, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@hypr/ui/components/ui/resizable"; +import { useRef } from "react"; import { PersistentChatPanel } from "~/chat/components/persistent-chat"; -const CHAT_MIN_WIDTH_PX = 280; -const CHAT_EXPANSION_WIDTH_PX = 400; -const CHAT_REPLACE_MIN_WINDOW_WIDTH_PX = 720; - -export function MainChatPanels({ - autoSaveId, - isRightPanelOpen, - children, -}: { - autoSaveId: string; - isRightPanelOpen: boolean; - children: React.ReactNode; -}) { - const previousOpenRef = useRef(isRightPanelOpen); - const bodyPanelRef = useRef(null); - const chatPanelContainerRef = useRef(null); - - useEffect(() => { - if (isRightPanelOpen && !previousOpenRef.current) { - if (bodyPanelRef.current) { - const currentSize = bodyPanelRef.current.getSize(); - bodyPanelRef.current.resize(currentSize); - } - windowsCommands - .windowExpandWidth( - CHAT_EXPANSION_WIDTH_PX, - CHAT_REPLACE_MIN_WINDOW_WIDTH_PX, - true, - false, - ) - .catch(console.error); - } else if (!isRightPanelOpen && previousOpenRef.current) { - windowsCommands.windowRestoreWidth().catch(console.error); - } - - previousOpenRef.current = isRightPanelOpen; - }, [isRightPanelOpen]); +export function MainChatPanels({ children }: { children: React.ReactNode }) { + const bodyPanelContainerRef = useRef(null); return ( <> - - +
-
{children}
- - {isRightPanelOpen && ( - <> - - -
- - - )} - + {children} +
+
- + ); } diff --git a/apps/desktop/src/shared/main/index.test.tsx b/apps/desktop/src/shared/main/index.test.tsx index c7d50c5ff6..69f0b87a9f 100644 --- a/apps/desktop/src/shared/main/index.test.tsx +++ b/apps/desktop/src/shared/main/index.test.tsx @@ -92,6 +92,11 @@ describe("StandardTabWrapper", () => { expect(screen.getByTestId("resize-handle").dataset.className).toContain( "data-[panel-group-direction=vertical]:-mb-px", ); + expect( + document + .querySelector("[data-chat-floating-anchor]") + ?.hasAttribute("data-main-has-after-border"), + ).toBe(true); const panels = screen.getAllByTestId("panel"); expect(panels[0]?.dataset.defaultSize).toBe("78"); diff --git a/apps/desktop/src/shared/main/index.tsx b/apps/desktop/src/shared/main/index.tsx index 668c7f688c..3582ab9dff 100644 --- a/apps/desktop/src/shared/main/index.tsx +++ b/apps/desktop/src/shared/main/index.tsx @@ -146,6 +146,7 @@ function MainPanel({ >
({ + useTabs: (selector: (state: { currentTab: { type: string } }) => unknown) => + selector({ currentTab: { type: "empty" } }), +})); + +import { MainShellScaffold } from "./shell-scaffold"; + +describe("MainShellScaffold", () => { + afterEach(() => { + cleanup(); + }); + + it("keeps only the left outer padding by default", () => { + render( + +
+ , + ); + + const shell = screen.getByTestId("main-app-shell"); + + expect(shell.className).toContain("pl-1"); + expect(shell.className).not.toContain("px-1"); + expect(shell.className).not.toContain("pb-1"); + }); + + it("removes outer padding for the top-edge main surface", () => { + render( + +
+ , + ); + + const shell = screen.getByTestId("main-app-shell"); + + expect(shell.className).not.toContain("px-1"); + expect(shell.className).not.toContain("pb-1"); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-x-0", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-b-0", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-t", + ); + }); + + it("keeps only left chrome for the left-edge main surface", () => { + render( + +
+ , + ); + + const shell = screen.getByTestId("main-app-shell"); + + expect(shell.className).toContain("pl-1"); + expect(shell.className).not.toContain("pb-1"); + expect(shell.className).not.toContain("px-1"); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:rounded-l-xl", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:rounded-r-none", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor][data-main-has-after-border]]:rounded-bl-none", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-y-0", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-r-0", + ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor]]:border-l", + ); + }); +}); diff --git a/apps/desktop/src/shared/main/shell-scaffold.tsx b/apps/desktop/src/shared/main/shell-scaffold.tsx index fd2bb35277..6750671621 100644 --- a/apps/desktop/src/shared/main/shell-scaffold.tsx +++ b/apps/desktop/src/shared/main/shell-scaffold.tsx @@ -1,17 +1,46 @@ import { Fragment } from "react"; +import { cn } from "@hypr/utils"; + import { SyncProvider } from "~/calendar/components/context"; import { useTabs } from "~/store/zustand/tabs"; -export function MainShellScaffold({ children }: { children: React.ReactNode }) { +export function MainShellScaffold({ + children, + edgeToEdge = false, + mainSurfaceChrome, +}: { + children: React.ReactNode; + edgeToEdge?: boolean; + mainSurfaceChrome?: "default" | "top" | "left"; +}) { const currentTab = useTabs((state) => state.currentTab); const isCalendarMode = currentTab?.type === "calendar"; const SyncWrapper = isCalendarMode ? SyncProvider : Fragment; + const resolvedMainSurfaceChrome = + mainSurfaceChrome ?? (edgeToEdge ? "top" : "default"); return (
{children} diff --git a/apps/desktop/src/shared/main/shell-sidebar.test.tsx b/apps/desktop/src/shared/main/shell-sidebar.test.tsx index f7845ef0f3..4190871af0 100644 --- a/apps/desktop/src/shared/main/shell-sidebar.test.tsx +++ b/apps/desktop/src/shared/main/shell-sidebar.test.tsx @@ -1,7 +1,8 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ + sidebarTimelineEnabled: false, setExpanded: vi.fn(), setLocked: vi.fn(), })); @@ -33,12 +34,17 @@ vi.mock("~/sidebar", () => ({ LeftSidebar: () =>
, })); +vi.mock("~/shared/config", () => ({ + useConfigValue: () => hoisted.sidebarTimelineEnabled, +})); + import { ClassicMainSidebar } from "~/main/shell-sidebar"; describe("ClassicMainSidebar", () => { beforeEach(() => { mockCurrentTab = { type: "empty" }; mockLeftSidebar.expanded = false; + hoisted.sidebarTimelineEnabled = false; setExpanded.mockClear(); setLocked.mockClear(); }); @@ -72,4 +78,13 @@ describe("ClassicMainSidebar", () => { expect(setLocked).toHaveBeenLastCalledWith(false); expect(setExpanded).toHaveBeenLastCalledWith(false); }); + + it("renders the default timeline sidebar when the setting is enabled", () => { + mockLeftSidebar.expanded = true; + hoisted.sidebarTimelineEnabled = true; + + render(); + + expect(screen.getByTestId("left-sidebar")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/shared/useTabsShortcuts.tsx b/apps/desktop/src/shared/useTabsShortcuts.tsx index 7a1bf79fb6..249bf7f5db 100644 --- a/apps/desktop/src/shared/useTabsShortcuts.tsx +++ b/apps/desktop/src/shared/useTabsShortcuts.tsx @@ -58,8 +58,17 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { openCurrent({ type: "empty" }); }, [currentTab, openCurrent, select, tabs]); - const escapeShortcutRef = useRef({ chat, openHome }); - escapeShortcutRef.current = { chat, openHome }; + const runEscapeShortcut = useCallback(() => { + if (chat.mode === "FloatingOpen") { + chat.sendEvent({ type: "CLOSE" }); + return; + } + + openHome(); + }, [chat, openHome]); + + const escapeShortcutRef = useRef(runEscapeShortcut); + escapeShortcutRef.current = runEscapeShortcut; useMountEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -72,13 +81,7 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { return; } - const { chat, openHome } = escapeShortcutRef.current; - if (chat.mode === "RightPanelOpen") { - chat.sendEvent({ type: "CLOSE" }); - return; - } - - openHome(); + escapeShortcutRef.current(); }); }; @@ -229,13 +232,13 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { [newNoteAndListen], ); - return {}; + return { runEscapeShortcut }; } function isPersistentChatInputFocused( mode: ReturnType["chat"]["mode"], ) { - if (mode !== "RightPanelOpen") { + if (mode !== "FloatingOpen") { return false; } diff --git a/apps/desktop/src/sidebar/calendar.tsx b/apps/desktop/src/sidebar/calendar.tsx index 9fdcbb6c2d..4c86f792b7 100644 --- a/apps/desktop/src/sidebar/calendar.tsx +++ b/apps/desktop/src/sidebar/calendar.tsx @@ -1,11 +1,11 @@ +import { CustomSidebarHeader } from "./custom-sidebar-header"; + import { CalendarSidebarContent } from "~/calendar/components/sidebar"; export function CalendarNav() { return (
-
-

Calendar

-
+
diff --git a/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx b/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx new file mode 100644 index 0000000000..b9d62179e0 --- /dev/null +++ b/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx @@ -0,0 +1,110 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + canGoBack: false, + canGoNext: false, + chatMode: "FloatingClosed", + currentTab: { type: "settings" } as { type: string } | null, + goBack: vi.fn(), + goNext: vi.fn(), + openCurrent: vi.fn(), + select: vi.fn(), + sendEvent: vi.fn(), + tabs: [] as { type: string }[], +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + chat: { + mode: mocks.chatMode, + sendEvent: mocks.sendEvent, + }, + }), +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: (selector: (state: unknown) => unknown) => + selector({ + currentTab: mocks.currentTab, + canGoBack: mocks.canGoBack, + canGoNext: mocks.canGoNext, + goBack: mocks.goBack, + goNext: mocks.goNext, + openCurrent: mocks.openCurrent, + select: mocks.select, + tabs: mocks.tabs, + }), +})); + +import { CustomSidebarHeader } from "./custom-sidebar-header"; + +describe("CustomSidebarHeader", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + mocks.canGoBack = false; + mocks.canGoNext = false; + mocks.chatMode = "FloatingClosed"; + mocks.currentTab = { type: "settings" }; + mocks.goBack.mockClear(); + mocks.goNext.mockClear(); + mocks.openCurrent.mockClear(); + mocks.select.mockClear(); + mocks.sendEvent.mockClear(); + mocks.tabs = []; + }); + + it("opens home from the back button", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.openCurrent).toHaveBeenCalledWith({ type: "empty" }); + }); + + it("selects an existing home tab from the back button", () => { + const homeTab = { type: "empty" }; + mocks.tabs = [homeTab]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.select).toHaveBeenCalledWith(homeTab); + expect(mocks.openCurrent).not.toHaveBeenCalled(); + }); + + it("closes floating chat before opening home", () => { + mocks.chatMode = "FloatingOpen"; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.sendEvent).toHaveBeenCalledWith({ type: "CLOSE" }); + expect(mocks.openCurrent).not.toHaveBeenCalled(); + }); + + it("renders history controls when requested", () => { + mocks.canGoBack = true; + mocks.canGoNext = true; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + fireEvent.click(screen.getByRole("button", { name: "Go forward" })); + + expect(mocks.goBack).toHaveBeenCalledTimes(1); + expect(mocks.goNext).toHaveBeenCalledTimes(1); + }); + + it("hides history controls by default", () => { + render(); + + expect(screen.queryByRole("button", { name: "Go back" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Go forward" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/sidebar/custom-sidebar-header.tsx b/apps/desktop/src/sidebar/custom-sidebar-header.tsx new file mode 100644 index 0000000000..821d22c5b8 --- /dev/null +++ b/apps/desktop/src/sidebar/custom-sidebar-header.tsx @@ -0,0 +1,128 @@ +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { cn } from "@hypr/utils"; + +import { useShell } from "~/contexts/shell"; +import { useTabs } from "~/store/zustand/tabs"; + +export function CustomSidebarHeader({ + title, + children, + showHistoryControls = false, +}: { + title: string; + children?: React.ReactNode; + showHistoryControls?: boolean; +}) { + const { chat } = useShell(); + const currentTab = useTabs((state) => state.currentTab); + const tabs = useTabs((state) => state.tabs); + const select = useTabs((state) => state.select); + const openCurrent = useTabs((state) => state.openCurrent); + const goBack = useTabs((state) => state.goBack); + const goNext = useTabs((state) => state.goNext); + const canGoBack = useTabs((state) => state.canGoBack); + const canGoNext = useTabs((state) => state.canGoNext); + + const handleBack = useCallback(() => { + if (chat.mode === "FloatingOpen") { + chat.sendEvent({ type: "CLOSE" }); + return; + } + + if (currentTab?.type === "onboarding" || currentTab?.type === "empty") { + return; + } + + const existingHomeTab = tabs.find((tab) => tab.type === "empty"); + if (existingHomeTab) { + select(existingHomeTab); + return; + } + + openCurrent({ type: "empty" }); + }, [chat, currentTab, openCurrent, select, tabs]); + + return ( +
+
+ + + + {showHistoryControls ? ( + <> + + + + + + + + ) : null} +

+ {title} +

+
+ {children ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +function CustomSidebarHeaderButton({ + children, + disabled = false, + label, + onClick, + title, +}: { + children: React.ReactNode; + disabled?: boolean; + label: string; + onClick: () => void; + title?: string; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/sidebar/index.test.tsx b/apps/desktop/src/sidebar/index.test.tsx new file mode 100644 index 0000000000..21a23a4a43 --- /dev/null +++ b/apps/desktop/src/sidebar/index.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + currentTab: { type: "empty" } as { type: string } | null, + showDevtool: false, + sidebarTimelineEnabled: false, +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + leftsidebar: { + showDevtool: mocks.showDevtool, + }, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: ( + selector: (state: { currentTab: typeof mocks.currentTab }) => unknown, + ) => selector({ currentTab: mocks.currentTab }), +})); + +vi.mock("~/sidebar/timeline", () => ({ + TimelineView: ({ + showOpenCalendarButton = true, + topChromeInset = false, + }: { + showOpenCalendarButton?: boolean; + topChromeInset?: boolean; + }) => ( +
+ ), +})); + +vi.mock("~/sidebar/toast", () => ({ + ToastArea: () =>
, +})); + +vi.mock("~/sidebar/calendar", () => ({ + CalendarNav: () =>
, +})); + +vi.mock("~/sidebar/contacts", () => ({ + ContactsNav: () =>
, +})); + +vi.mock("~/sidebar/settings", () => ({ + SettingsNav: () =>
, +})); + +vi.mock("~/sidebar/templates", () => ({ + TemplatesNav: () =>
, +})); + +vi.mock("~/sidebar/devtool", () => ({ + DevtoolView: () =>
, +})); + +import { LeftSidebar } from "./index"; + +describe("LeftSidebar", () => { + beforeEach(() => { + mocks.currentTab = { type: "empty" }; + mocks.showDevtool = false; + mocks.sidebarTimelineEnabled = false; + }); + + it("uses the timeline layout without a duplicate sidebar top offset", () => { + mocks.sidebarTimelineEnabled = true; + + const { container } = render(); + + expect(screen.getByTestId("timeline-view")).toBeTruthy(); + expect( + screen + .getByTestId("timeline-view") + .getAttribute("data-show-open-calendar-button"), + ).toBe("true"); + expect( + screen.getByTestId("timeline-view").getAttribute("data-top-chrome-inset"), + ).toBe("true"); + expect(container.firstElementChild?.className).toContain("pt-0"); + }); + + it.each([ + ["settings", "settings-nav"], + ["calendar", "calendar-nav"], + ["contacts", "contacts-nav"], + ["templates", "templates-nav"], + ])("keeps %s below the window chrome", (type, testId) => { + mocks.sidebarTimelineEnabled = true; + mocks.currentTab = { type }; + + const { container } = render(); + const classList = container.firstElementChild?.className.split(" ") ?? []; + + expect(screen.getByTestId(testId)).toBeTruthy(); + expect(classList).toContain("pt-11"); + expect(classList).not.toContain("pt-0"); + }); +}); diff --git a/apps/desktop/src/sidebar/index.tsx b/apps/desktop/src/sidebar/index.tsx index e392f264f5..476413fed7 100644 --- a/apps/desktop/src/sidebar/index.tsx +++ b/apps/desktop/src/sidebar/index.tsx @@ -1,5 +1,7 @@ import { lazy, Suspense } from "react"; +import { cn } from "@hypr/utils"; + import { CalendarNav } from "./calendar"; import { ContactsNav } from "./contacts"; import { SettingsNav } from "./settings"; @@ -8,6 +10,7 @@ import { TimelineView } from "./timeline"; import { ToastArea } from "./toast"; import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { useTabs } from "~/store/zustand/tabs"; const DevtoolView = lazy(() => @@ -17,6 +20,7 @@ const DevtoolView = lazy(() => export function LeftSidebar() { const { leftsidebar } = useShell(); const currentTab = useTabs((state) => state.currentTab); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); const isSettingsMode = currentTab?.type === "settings"; const isCalendarMode = currentTab?.type === "calendar"; @@ -24,9 +28,16 @@ export function LeftSidebar() { const isTemplatesMode = currentTab?.type === "templates"; const isSpecialMode = isSettingsMode || isCalendarMode || isContactsMode || isTemplatesMode; + const isTimelineSidebarLayout = + sidebarTimelineEnabled && !leftsidebar.showDevtool && !isSpecialMode; return ( -
+
{leftsidebar.showDevtool ? ( @@ -42,7 +53,7 @@ export function LeftSidebar() { ) : isTemplatesMode ? ( ) : ( - + )} {!leftsidebar.showDevtool && !isSpecialMode && }
diff --git a/apps/desktop/src/sidebar/settings.tsx b/apps/desktop/src/sidebar/settings.tsx index fdfa77e49a..482833a11a 100644 --- a/apps/desktop/src/sidebar/settings.tsx +++ b/apps/desktop/src/sidebar/settings.tsx @@ -16,6 +16,8 @@ import { useCallback } from "react"; import { cn } from "@hypr/utils"; +import { CustomSidebarHeader } from "./custom-sidebar-header"; + import { type SettingsTab, useTabs } from "~/store/zustand/tabs"; type SettingsNavItem = @@ -110,9 +112,7 @@ export function SettingsNav() { return (
-
-

Settings

-
+
{groups.map((group) => ( diff --git a/apps/desktop/src/sidebar/timeline/anchor.ts b/apps/desktop/src/sidebar/timeline/anchor.ts index cd919a078d..e761a0db64 100644 --- a/apps/desktop/src/sidebar/timeline/anchor.ts +++ b/apps/desktop/src/sidebar/timeline/anchor.ts @@ -18,25 +18,32 @@ export function useAnchor() { ); }, []); - const scrollToAnchor = useCallback(() => { - const container = containerRef.current; - if (!container || !anchorNode) { - return; - } + const scrollToAnchor = useCallback( + (options?: { behavior?: ScrollBehavior; viewportRatio?: number }) => { + const container = containerRef.current; + if (!container || !anchorNode) { + return; + } - const containerRect = container.getBoundingClientRect(); - const anchorRect = anchorNode.getBoundingClientRect(); - const anchorCenter = - anchorRect.top - - containerRect.top + - container.scrollTop + - anchorRect.height / 2; - const targetScrollTop = Math.max( - anchorCenter - container.clientHeight / 2, - 0, - ); - container.scrollTo({ top: targetScrollTop, behavior: "smooth" }); - }, [anchorNode]); + const containerRect = container.getBoundingClientRect(); + const anchorRect = anchorNode.getBoundingClientRect(); + const anchorCenter = + anchorRect.top - + containerRect.top + + container.scrollTop + + anchorRect.height / 2; + const viewportRatio = options?.viewportRatio ?? 0.5; + const targetScrollTop = Math.max( + anchorCenter - container.clientHeight * viewportRatio, + 0, + ); + container.scrollTo({ + top: targetScrollTop, + behavior: options?.behavior ?? "smooth", + }); + }, + [anchorNode], + ); useEffect(() => { const container = containerRef.current; @@ -79,7 +86,10 @@ export function useAutoScrollToAnchor({ anchorNode, deps = [], }: { - scrollFn: () => void; + scrollFn: (options?: { + behavior?: ScrollBehavior; + viewportRatio?: number; + }) => void; isVisible: boolean; anchorNode: HTMLDivElement | null; deps?: DependencyList; @@ -94,7 +104,7 @@ export function useAutoScrollToAnchor({ hasInitialScrolledRef.current = true; requestAnimationFrame(() => { - scrollFn(); + scrollFn({ behavior: "auto", viewportRatio: 0.15 }); }); }, [anchorNode, scrollFn]); diff --git a/apps/desktop/src/sidebar/timeline/index.tsx b/apps/desktop/src/sidebar/timeline/index.tsx index 9c27a7442f..8117186e4a 100644 --- a/apps/desktop/src/sidebar/timeline/index.tsx +++ b/apps/desktop/src/sidebar/timeline/index.tsx @@ -1,4 +1,9 @@ -import { CalendarDaysIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { + ArrowDownIcon, + ArrowUpIcon, + CalendarDaysIcon, + SunIcon, +} from "lucide-react"; import { type ReactNode, useCallback, @@ -44,7 +49,13 @@ import { useTabs } from "~/store/zustand/tabs"; import { useTimelineSelection } from "~/store/zustand/timeline-selection"; import { useUndoDelete } from "~/store/zustand/undo-delete"; -export function TimelineView() { +export function TimelineView({ + showOpenCalendarButton = true, + topChromeInset = false, +}: { + showOpenCalendarButton?: boolean; + topChromeInset?: boolean; +} = {}) { const timezone = useConfigValue("timezone") || undefined; const { timelineEventsTable, timelineSessionsTable } = useTimelineTables(); const allBuckets = useTimelineData({ @@ -54,6 +65,7 @@ export function TimelineView() { }); const [showIgnored, setShowIgnored] = useState(false); const [isScrolledToTop, setIsScrolledToTop] = useState(true); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); const { isIgnored } = useIgnoredEvents(); const openNew = useTabs((state) => state.openNew); @@ -90,8 +102,9 @@ export function TimelineView() { ); }, [timelineEventsTable, showIgnored, isIgnored]); - const showOpenCalendarButton = useMemo( + const showOpenCalendarChip = useMemo( () => + showOpenCalendarButton && isScrolledToTop && hasTimelineItemsAfterTomorrow({ timelineEventsTable: visibleTimelineEventsTable, @@ -103,6 +116,7 @@ export function TimelineView() { visibleTimelineEventsTable, timelineSessionsTable, timezone, + showOpenCalendarButton, ], ); @@ -116,6 +130,9 @@ export function TimelineView() { [visibleTimelineEventsTable, timelineSessionsTable, timezone], ); + const reserveOpenCalendarChipSpace = + topChromeInset && showOpenCalendarButton && hasMoreFutureItems; + const hasToday = useMemo( () => buckets.some((bucket) => bucket.label === "Today"), [buckets], @@ -161,7 +178,12 @@ export function TimelineView() { } const updateScrollPosition = () => { + const maxScrollTop = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); setIsScrolledToTop(container.scrollTop <= 12); + setIsScrolledToBottom(maxScrollTop - container.scrollTop <= 12); }; updateScrollPosition(); @@ -172,7 +194,14 @@ export function TimelineView() { return () => { container.removeEventListener("scroll", updateScrollPosition); }; - }, [containerRef]); + }, [containerRef, buckets.length, flatItemKeys.length]); + + const scrollFadeMask = useMemo(() => { + const topFadeEnd = isScrolledToTop ? "0px" : "28px"; + const bottomFadeStart = isScrolledToBottom ? "100%" : "calc(100% - 28px)"; + + return `linear-gradient(to bottom, transparent 0, #000 ${topFadeEnd}, #000 ${bottomFadeStart}, transparent 100%)`; + }, [isScrolledToTop, isScrolledToBottom]); const todayBucketLength = useMemo(() => { const b = buckets.find((bucket) => bucket.label === "Today"); @@ -295,8 +324,24 @@ export function TimelineView() { "scrollbar-hide flex h-full flex-col overflow-y-auto", "rounded-xl", ])} + style={{ + WebkitMaskImage: scrollFadeMask, + maskImage: scrollFadeMask, + }} > - {hasMoreFutureItems &&
} + {(topChromeInset || hasMoreFutureItems) && ( +
+ )} {buckets.map((bucket, index) => { const isToday = bucket.label === "Today"; const shouldRenderIndicatorBefore = @@ -361,9 +406,26 @@ export function TimelineView() { )}
- {(showOpenCalendarButton || (!isTodayVisible && isScrolledPastToday)) && ( -
- {showOpenCalendarButton && ( + {topChromeInset && ( +
+ )} + + {(showOpenCalendarChip || (!isTodayVisible && isScrolledPastToday)) && ( +
+ {showOpenCalendarChip && ( + )}
)} {!isTodayVisible && !isScrolledPastToday && ( - + /> )}
); } +function TimelineNowChip({ + className, + direction, + onClick, +}: { + className?: string; + direction: "up" | "down"; + onClick: () => void; +}) { + const DirectionIcon = direction === "up" ? ArrowUpIcon : ArrowDownIcon; + + return ( + + ); +} + function TodayBucket({ items, precision, diff --git a/apps/desktop/src/sidebar/timeline/item.tsx b/apps/desktop/src/sidebar/timeline/item.tsx index 541c0a4959..bf8bab2a7f 100644 --- a/apps/desktop/src/sidebar/timeline/item.tsx +++ b/apps/desktop/src/sidebar/timeline/item.tsx @@ -173,7 +173,6 @@ const EventItem = memo( const eventId = item.id; const trackingIdEvent = item.data.tracking_id_event; const title = item.data.title || "Untitled"; - const calendarId = item.data.calendar_id ?? null; const recurrenceSeriesId = item.data.recurrence_series_id; const { @@ -297,7 +296,7 @@ const EventItem = memo( -
+
diff --git a/apps/desktop/src/sidebar/use-custom-sidebar.ts b/apps/desktop/src/sidebar/use-custom-sidebar.ts index 6a51c1cad7..e2d8340b21 100644 --- a/apps/desktop/src/sidebar/use-custom-sidebar.ts +++ b/apps/desktop/src/sidebar/use-custom-sidebar.ts @@ -9,10 +9,21 @@ const CUSTOM_SIDEBAR_TYPES: Tab["type"][] = [ "templates", ]; +const LEFT_SURFACE_CUSTOM_SIDEBAR_TYPES: Tab["type"][] = [ + "calendar", + "settings", + "contacts", + "templates", +]; + export function hasCustomSidebarTab(tab: Tab | null): boolean { return tab !== null && CUSTOM_SIDEBAR_TYPES.includes(tab.type); } +export function hasLeftSurfaceCustomSidebarTab(tab: Tab | null): boolean { + return tab !== null && LEFT_SURFACE_CUSTOM_SIDEBAR_TYPES.includes(tab.type); +} + export function useCustomSidebarEffect( active: boolean, leftsidebar: { diff --git a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts index 8f087ba85b..5ddbd7c0b2 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts @@ -424,6 +424,7 @@ describe("settingsPersister roundtrip", () => { general: { autostart: true, floating_bar_enabled: false, + sidebar_timeline_enabled: true, save_recordings: true, }, notification: { @@ -441,6 +442,7 @@ describe("settingsPersister roundtrip", () => { expect(result.general).toEqual({ autostart: true, floating_bar_enabled: false, + sidebar_timeline_enabled: true, }); expect(result.notification).toEqual({ event: false }); }); diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index b3c6f2146f..bb959a9b45 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -46,6 +46,11 @@ export const SETTINGS_MAPPING = { path: ["general", "floating_bar_enabled"], default: true as boolean, }, + sidebar_timeline_enabled: { + type: "boolean", + path: ["general", "sidebar_timeline_enabled"], + default: false as boolean, + }, save_recordings: { type: "boolean", path: ["general", "save_recordings"], diff --git a/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts b/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts index f095d48b0b..13dd8c155a 100644 --- a/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts +++ b/apps/desktop/src/store/zustand/tabs/chat-mode.test.ts @@ -12,25 +12,20 @@ describe("Chat Mode", () => { expect(useTabs.getState().chatMode).toBe("FloatingClosed"); }); - test("TOGGLE from FloatingClosed → RightPanelOpen", () => { + test("TOGGLE from FloatingClosed to FloatingOpen", () => { useTabs.getState().transitionChatMode({ type: "TOGGLE" }); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); + expect(useTabs.getState().chatMode).toBe("FloatingOpen"); }); - test("TOGGLE from RightPanelOpen → FloatingClosed", () => { + test("TOGGLE from FloatingOpen to FloatingClosed", () => { useTabs.getState().transitionChatMode({ type: "TOGGLE" }); useTabs.getState().transitionChatMode({ type: "TOGGLE" }); expect(useTabs.getState().chatMode).toBe("FloatingClosed"); }); - test("OPEN from FloatingClosed → RightPanelOpen", () => { + test("OPEN from FloatingClosed to FloatingOpen", () => { useTabs.getState().transitionChatMode({ type: "OPEN" }); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); - }); - - test("OPEN_RIGHT_PANEL from FloatingClosed → RightPanelOpen", () => { - useTabs.getState().transitionChatMode({ type: "OPEN_RIGHT_PANEL" }); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); + expect(useTabs.getState().chatMode).toBe("FloatingOpen"); }); test("no-op when event is irrelevant for current state", () => { @@ -42,21 +37,21 @@ describe("Chat Mode", () => { const session = createSessionTab(); useTabs.getState().openNew(session); useTabs.getState().transitionChatMode({ type: "OPEN" }); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); + expect(useTabs.getState().chatMode).toBe("FloatingOpen"); const sessionTab = useTabs .getState() .tabs.find((t) => t.type === "sessions")!; useTabs.getState().close(sessionTab); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); + expect(useTabs.getState().chatMode).toBe("FloatingOpen"); }); - test("closeAll leaves the right panel chat mode unchanged", () => { + test("closeAll leaves the floating chat mode unchanged", () => { const session = createSessionTab(); useTabs.getState().openNew(session); useTabs.getState().transitionChatMode({ type: "OPEN" }); useTabs.getState().closeAll(); - expect(useTabs.getState().chatMode).toBe("RightPanelOpen"); + expect(useTabs.getState().chatMode).toBe("FloatingOpen"); }); }); diff --git a/apps/desktop/src/store/zustand/tabs/chat-mode.ts b/apps/desktop/src/store/zustand/tabs/chat-mode.ts index 18e9435c7d..f2585790bc 100644 --- a/apps/desktop/src/store/zustand/tabs/chat-mode.ts +++ b/apps/desktop/src/store/zustand/tabs/chat-mode.ts @@ -1,10 +1,9 @@ import type { StoreApi } from "zustand"; -export type ChatMode = "RightPanelOpen" | "FloatingClosed"; +export type ChatMode = "FloatingOpen" | "FloatingClosed"; export type ChatEvent = | { type: "OPEN" } - | { type: "OPEN_RIGHT_PANEL" } | { type: "CLOSE" } | { type: "TOGGLE" }; @@ -18,18 +17,14 @@ export type ChatModeActions = { const computeNextChatMode = (state: ChatMode, event: ChatEvent): ChatMode => { switch (state) { - case "RightPanelOpen": + case "FloatingOpen": if (event.type === "CLOSE" || event.type === "TOGGLE") { return "FloatingClosed"; } return state; case "FloatingClosed": - if ( - event.type === "OPEN" || - event.type === "OPEN_RIGHT_PANEL" || - event.type === "TOGGLE" - ) { - return "RightPanelOpen"; + if (event.type === "OPEN" || event.type === "TOGGLE") { + return "FloatingOpen"; } return state; default: diff --git a/apps/desktop/src/templates/template-sidebar.tsx b/apps/desktop/src/templates/template-sidebar.tsx index 0b9c5e4def..c2058395ef 100644 --- a/apps/desktop/src/templates/template-sidebar.tsx +++ b/apps/desktop/src/templates/template-sidebar.tsx @@ -16,6 +16,7 @@ import { getTemplateCopyTitle, type UserTemplate } from "./queries"; import { useTemplateTab } from "./utils"; import { useNativeContextMenu } from "~/shared/hooks/useNativeContextMenu"; +import { CustomSidebarHeader } from "~/sidebar/custom-sidebar-header"; import { type Tab } from "~/store/zustand/tabs"; type SortOption = "alphabetical" | "reverse-alphabetical"; @@ -292,52 +293,49 @@ export function TemplatesSidebarContent({ return (
-
-

Templates

-
- {userTemplates.length > 1 && ( - - - + + + + setSortOption("alphabetical")} > - - - - - - setSortOption("alphabetical")} - > - A to Z - - setSortOption("reverse-alphabetical")} - > - Z to A - - - - - )} - - -
-
+ A to Z + + setSortOption("reverse-alphabetical")} + > + Z to A + + + + + )} + + + -
+
diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index f3603c0870..46377c425a 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -158,6 +158,7 @@ export const valueSchemaForTinybase = { auto_stop_meetings: { type: "boolean" }, auto_start_scheduled_meetings: { type: "boolean" }, floating_bar_enabled: { type: "boolean" }, + sidebar_timeline_enabled: { type: "boolean" }, save_recordings: { type: "boolean" }, audio_retention: { type: "string" }, notification_event: { type: "boolean" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index ceb6b3d9cc..b42803c840 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -265,6 +265,7 @@ export const generalSchema = z.object({ auto_stop_meetings: z.boolean().default(true), auto_start_scheduled_meetings: z.boolean().default(true), floating_bar_enabled: z.boolean().default(true), + sidebar_timeline_enabled: z.boolean().default(false), telemetry_consent: z.boolean().default(true), save_recordings: z.boolean().default(true), audio_retention: z.string().default("forever"), diff --git a/plugins/windows/src/window/v1.rs b/plugins/windows/src/window/v1.rs index e41b1b23ab..817848b054 100644 --- a/plugins/windows/src/window/v1.rs +++ b/plugins/windows/src/window/v1.rs @@ -77,7 +77,7 @@ impl AppWindow { .decorations(true) .hidden_title(true) .theme(Some(tauri::Theme::Light)) - .traffic_light_position(tauri::LogicalPosition::new(12.0, traffic_light_y + 4.0)) + .traffic_light_position(tauri::LogicalPosition::new(12.0, traffic_light_y)) .title_bar_style(tauri::TitleBarStyle::Overlay); }