From 944d4628cf021b70e0a339817aa178218c2273bb Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:17:56 +0900 Subject: [PATCH] fix(toast): raise sidebar toast placement Move the upgrade toast higher when the left sidebar timeline is active and cover the placement behavior with focused tests. --- apps/desktop/src/main/shell-frame.test.tsx | 14 +- apps/desktop/src/main/shell-frame.tsx | 2 +- apps/desktop/src/sidebar/toast/index.test.tsx | 214 ++++++++++++++++++ apps/desktop/src/sidebar/toast/index.tsx | 98 +++++++- 4 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/sidebar/toast/index.test.tsx diff --git a/apps/desktop/src/main/shell-frame.test.tsx b/apps/desktop/src/main/shell-frame.test.tsx index aeeaa0294d..7faa4b5dcf 100644 --- a/apps/desktop/src/main/shell-frame.test.tsx +++ b/apps/desktop/src/main/shell-frame.test.tsx @@ -47,7 +47,9 @@ vi.mock("~/shared/config", () => ({ })); vi.mock("~/sidebar/toast", () => ({ - ToastArea: () =>
, + ToastArea: ({ placement }: { placement?: "default" | "left-sidebar" }) => ( +
+ ), })); vi.mock("~/store/zustand/tabs", () => ({ @@ -72,7 +74,9 @@ describe("ClassicMainShellFrame", () => { it("uses top-edge main surface chrome in top timeline mode", () => { render(); - expect(screen.getByTestId("toast-area")).toBeTruthy(); + expect( + screen.getByTestId("toast-area").getAttribute("data-placement"), + ).toBe("default"); expect( screen .getByTestId("main-shell-scaffold") @@ -85,6 +89,9 @@ describe("ClassicMainShellFrame", () => { render(); + expect( + screen.getByTestId("toast-area").getAttribute("data-placement"), + ).toBe("left-sidebar"); expect( screen .getByTestId("main-shell-scaffold") @@ -98,6 +105,9 @@ describe("ClassicMainShellFrame", () => { render(); + expect( + screen.getByTestId("toast-area").getAttribute("data-placement"), + ).toBe("default"); expect( screen .getByTestId("main-shell-scaffold") diff --git a/apps/desktop/src/main/shell-frame.tsx b/apps/desktop/src/main/shell-frame.tsx index 7571219824..d352b2f2d4 100644 --- a/apps/desktop/src/main/shell-frame.tsx +++ b/apps/desktop/src/main/shell-frame.tsx @@ -53,7 +53,7 @@ export function ClassicMainShellFrame() { - + ); } diff --git a/apps/desktop/src/sidebar/toast/index.test.tsx b/apps/desktop/src/sidebar/toast/index.test.tsx new file mode 100644 index 0000000000..94556d7404 --- /dev/null +++ b/apps/desktop/src/sidebar/toast/index.test.tsx @@ -0,0 +1,214 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + signIn: vi.fn(), + dismissToast: vi.fn(), + openNew: vi.fn(), + updateSettingsTabState: vi.fn(), + clearDevtoolsPreview: vi.fn(), + setToastActionTarget: vi.fn(), +})); + +vi.mock("~/auth", () => ({ + useAuth: () => ({ + session: null, + signIn: mocks.signIn, + }), +})); + +vi.mock("~/contexts/notifications", () => ({ + useNotifications: () => ({ + hasActiveDownload: false, + downloadProgress: null, + downloadingModel: null, + activeDownloads: [], + localSttStatus: null, + isLocalSttModel: false, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValues: () => ({ + current_llm_provider: "local", + current_llm_model: "model", + current_stt_provider: "local", + current_stt_model: "model", + }), +})); + +vi.mock("~/store/zustand/devtools-toast-preview", () => ({ + useDevtoolsToastPreview: ( + selector: (state: { preview: null; clearPreview: () => void }) => unknown, + ) => + selector({ + preview: null, + clearPreview: mocks.clearDevtoolsPreview, + }), +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: ( + selector: (state: { + currentTab: { type: string }; + openNew: () => void; + updateSettingsTabState: () => void; + }) => unknown, + ) => + selector({ + currentTab: { type: "empty" }, + openNew: mocks.openNew, + updateSettingsTabState: mocks.updateSettingsTabState, + }), +})); + +vi.mock("~/store/zustand/toast-action", () => ({ + useToastAction: ( + selector: (state: { setTarget: (target: "stt" | null) => void }) => unknown, + ) => selector({ setTarget: mocks.setToastActionTarget }), +})); + +vi.mock("./useDismissedToasts", () => ({ + useDismissedToasts: () => ({ + dismissToast: mocks.dismissToast, + isDismissed: () => false, + }), +})); + +import { ToastArea } from "./index"; + +describe("ToastArea", () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.signIn.mockClear(); + mocks.dismissToast.mockClear(); + mocks.openNew.mockClear(); + mocks.updateSettingsTabState.mockClear(); + mocks.clearDevtoolsPreview.mockClear(); + mocks.setToastActionTarget.mockClear(); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + it("keeps the default toast placement fixed to the existing top timeline position", () => { + render(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + const toastContainer = screen + .getByText("Pro features available") + .closest(".fixed") as HTMLElement | null; + + expect(toastContainer?.style.left).toBe("calc(50% + 0px)"); + expect(toastContainer?.style.top).toBe("56px"); + }); + + it("positions the left sidebar toast relative to the main white surface", () => { + const mainSurface = document.createElement("div"); + mainSurface.setAttribute("data-chat-floating-anchor", ""); + vi.spyOn(mainSurface, "getBoundingClientRect").mockReturnValue({ + bottom: 520, + height: 500, + left: 200, + right: 800, + top: 20, + width: 600, + x: 200, + y: 20, + toJSON: () => ({}), + }); + document.body.appendChild(mainSurface); + + render(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + const toastContainer = screen + .getByText("Pro features available") + .closest(".fixed") as HTMLElement | null; + + expect(toastContainer?.style.left).toBe("500px"); + expect(toastContainer?.style.top).toBe("56px"); + }); + + it("repositions the left sidebar toast when the main surface scrolls", () => { + const mainSurface = document.createElement("div"); + mainSurface.setAttribute("data-chat-floating-anchor", ""); + let top = 20; + + vi.spyOn(mainSurface, "getBoundingClientRect").mockImplementation(() => ({ + bottom: top + 500, + height: 500, + left: 200, + right: 800, + top, + width: 600, + x: 200, + y: top, + toJSON: () => ({}), + })); + document.body.appendChild(mainSurface); + + render(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + const toastContainer = screen + .getByText("Pro features available") + .closest(".fixed") as HTMLElement | null; + + expect(toastContainer?.style.top).toBe("56px"); + + act(() => { + top = 52; + window.dispatchEvent(new Event("scroll")); + }); + + expect(toastContainer?.style.top).toBe("88px"); + }); + + it("uses fallback placement immediately when left sidebar anchoring is disabled", () => { + const mainSurface = document.createElement("div"); + mainSurface.setAttribute("data-chat-floating-anchor", ""); + vi.spyOn(mainSurface, "getBoundingClientRect").mockReturnValue({ + bottom: 520, + height: 500, + left: 200, + right: 800, + top: 20, + width: 600, + x: 200, + y: 20, + toJSON: () => ({}), + }); + document.body.appendChild(mainSurface); + + const { rerender } = render(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + const toastContainer = screen + .getByText("Pro features available") + .closest(".fixed") as HTMLElement | null; + + expect(toastContainer?.style.left).toBe("500px"); + expect(toastContainer?.style.top).toBe("56px"); + + rerender(); + + expect(toastContainer?.style.left).toBe("calc(50% + 0px)"); + expect(toastContainer?.style.top).toBe("56px"); + }); +}); diff --git a/apps/desktop/src/sidebar/toast/index.tsx b/apps/desktop/src/sidebar/toast/index.tsx index 5bf4220200..dcd34d197f 100644 --- a/apps/desktop/src/sidebar/toast/index.tsx +++ b/apps/desktop/src/sidebar/toast/index.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { cn } from "@hypr/utils"; @@ -20,11 +20,28 @@ import { useDevtoolsToastPreview } from "~/store/zustand/devtools-toast-preview" import { useTabs } from "~/store/zustand/tabs"; import { useToastAction } from "~/store/zustand/toast-action"; -export function ToastArea() { +type ToastAreaPlacement = "default" | "left-sidebar"; +type ToastAreaPosition = { + left: number | string; + top: number; +}; + +const DEFAULT_TOP_OFFSET_PX = 56; +const LEFT_SIDEBAR_TOP_OFFSET_PX = 36; +const MAIN_SURFACE_SELECTOR = "[data-chat-floating-anchor]"; + +export function ToastArea({ + placement = "default", +}: { + placement?: ToastAreaPlacement; +}) { const auth = useAuth(); const { dismissToast, isDismissed } = useDismissedToasts(); const shouldShowToast = useShouldShowToast(); const contentOffset = useMainContentCenterOffset(); + const mainSurfacePosition = useMainSurfaceToastPosition( + placement === "left-sidebar", + ); const { hasActiveDownload, downloadProgress, @@ -177,6 +194,12 @@ export function ToastArea() { : displayToast?.id; const dismissAction = displayToast?.dismissible ? handleDismiss : undefined; + const position = + mainSurfacePosition ?? + getFallbackPosition({ + contentOffset, + placement, + }); if (!shouldShowToast || !displayToast) { return null; @@ -191,12 +214,10 @@ export function ToastArea() { exit={{ opacity: 0, y: -20, scale: 0.95 }} transition={{ duration: 0.2, ease: "easeOut" }} style={{ - left: `calc(50% + ${contentOffset}px)`, + left: position.left, + top: position.top, }} - className={cn([ - "fixed top-14 z-40 -translate-x-1/2", - "pointer-events-none", - ])} + className={cn(["fixed z-40 -translate-x-1/2", "pointer-events-none"])} >
@@ -261,3 +282,66 @@ function useMainContentCenterOffset() { return contentOffset; } + +function useMainSurfaceToastPosition(enabled: boolean) { + const [position, setPosition] = useState(null); + + useEffect(() => { + if (!enabled) { + setPosition(null); + return; + } + + const computePosition = () => { + const mainSurface = document.querySelector(MAIN_SURFACE_SELECTOR); + if (!mainSurface) { + setPosition(null); + return; + } + + const rect = mainSurface.getBoundingClientRect(); + setPosition({ + left: rect.left + rect.width / 2, + top: rect.top + LEFT_SIDEBAR_TOP_OFFSET_PX, + }); + }; + + computePosition(); + window.addEventListener("resize", computePosition); + window.addEventListener("scroll", computePosition, true); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(computePosition) + : null; + + const mainSurface = document.querySelector(MAIN_SURFACE_SELECTOR); + if (mainSurface) { + resizeObserver?.observe(mainSurface); + } + + return () => { + window.removeEventListener("resize", computePosition); + window.removeEventListener("scroll", computePosition, true); + resizeObserver?.disconnect(); + }; + }, [enabled]); + + return enabled ? position : null; +} + +function getFallbackPosition({ + contentOffset, + placement, +}: { + contentOffset: number; + placement: ToastAreaPlacement; +}): ToastAreaPosition { + return { + left: `calc(50% + ${contentOffset}px)`, + top: + placement === "left-sidebar" + ? LEFT_SIDEBAR_TOP_OFFSET_PX + : DEFAULT_TOP_OFFSET_PX, + }; +}