Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/desktop/src/main/shell-frame.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ vi.mock("~/shared/config", () => ({
}));

vi.mock("~/sidebar/toast", () => ({
ToastArea: () => <div data-testid="toast-area" />,
ToastArea: ({ placement }: { placement?: "default" | "left-sidebar" }) => (
<div data-placement={placement} data-testid="toast-area" />
),
}));

vi.mock("~/store/zustand/tabs", () => ({
Expand All @@ -72,7 +74,9 @@ describe("ClassicMainShellFrame", () => {
it("uses top-edge main surface chrome in top timeline mode", () => {
render(<ClassicMainShellFrame />);

expect(screen.getByTestId("toast-area")).toBeTruthy();
expect(
screen.getByTestId("toast-area").getAttribute("data-placement"),
).toBe("default");
expect(
screen
.getByTestId("main-shell-scaffold")
Expand All @@ -85,6 +89,9 @@ describe("ClassicMainShellFrame", () => {

render(<ClassicMainShellFrame />);

expect(
screen.getByTestId("toast-area").getAttribute("data-placement"),
).toBe("left-sidebar");
expect(
screen
.getByTestId("main-shell-scaffold")
Expand All @@ -98,6 +105,9 @@ describe("ClassicMainShellFrame", () => {

render(<ClassicMainShellFrame />);

expect(
screen.getByTestId("toast-area").getAttribute("data-placement"),
).toBe("default");
expect(
screen
.getByTestId("main-shell-scaffold")
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/shell-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function ClassicMainShellFrame() {
<MainShellBodyFrame>
<ClassicMainBody />
</MainShellBodyFrame>
<ToastArea />
<ToastArea placement={showSidebarTimeline ? "left-sidebar" : "default"} />
</MainShellScaffold>
);
}
214 changes: 214 additions & 0 deletions apps/desktop/src/sidebar/toast/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ToastArea />);

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(<ToastArea placement="left-sidebar" />);

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(<ToastArea placement="left-sidebar" />);

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(<ToastArea placement="left-sidebar" />);

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(<ToastArea />);

expect(toastContainer?.style.left).toBe("calc(50% + 0px)");
expect(toastContainer?.style.top).toBe("56px");
});
});
98 changes: 91 additions & 7 deletions apps/desktop/src/sidebar/toast/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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"])}
>
<div className="pointer-events-auto">
<Toast toast={displayToast} onDismiss={dismissAction} />
Expand Down Expand Up @@ -261,3 +282,66 @@ function useMainContentCenterOffset() {

return contentOffset;
}

function useMainSurfaceToastPosition(enabled: boolean) {
const [position, setPosition] = useState<ToastAreaPosition | null>(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();
Comment thread
cursor[bot] marked this conversation as resolved.
};
}, [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,
};
}
Loading