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
56 changes: 46 additions & 10 deletions desktop/src/app/useWebviewZoomShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { getCurrentWebview } from "@tauri-apps/api/webview";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";

const DEFAULT_ZOOM_FACTOR = 1;
const MIN_ZOOM_FACTOR = 0.2;
const MAX_ZOOM_FACTOR = 10;
const ZOOM_STEP = 0.2;
const MIN_ZOOM_FACTOR = 0.75;
const MAX_ZOOM_FACTOR = 1.5;
const ZOOM_STEP = 0.1;
const BASE_FONT_SIZE_PX = 16;
const TEXT_SCALE_STORAGE_KEY = "sprout:text-scale";

type ZoomAction = "increase" | "decrease" | "reset";

function roundZoomFactor(zoomFactor: number) {
return Math.round(zoomFactor * 10) / 10;
}

function getZoomAction(event: KeyboardEvent): ZoomAction | null {
if (!hasPrimaryShortcutModifier(event) || event.altKey) {
return null;
Expand Down Expand Up @@ -49,17 +55,51 @@ function getNextZoomFactor(action: ZoomAction, zoomFactor: number) {
}

if (action === "increase") {
return Math.min(zoomFactor + ZOOM_STEP, MAX_ZOOM_FACTOR);
return Math.min(roundZoomFactor(zoomFactor + ZOOM_STEP), MAX_ZOOM_FACTOR);
}

return Math.max(zoomFactor - ZOOM_STEP, MIN_ZOOM_FACTOR);
return Math.max(roundZoomFactor(zoomFactor - ZOOM_STEP), MIN_ZOOM_FACTOR);
}

function readStoredZoomFactor() {
const raw = window.localStorage.getItem(TEXT_SCALE_STORAGE_KEY);
if (!raw) {
return DEFAULT_ZOOM_FACTOR;
}

const parsed = Number.parseFloat(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_ZOOM_FACTOR;
}

return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR);
}

function applyTextScale(zoomFactor: number) {
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
document.documentElement.style.fontSize = "";
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
return;
}

document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
}

export function useWebviewZoomShortcuts() {
const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR);

React.useLayoutEffect(() => {
const webview = getCurrentWebview();
const storedZoomFactor = readStoredZoomFactor();

zoomFactorRef.current = storedZoomFactor;
applyTextScale(storedZoomFactor);

// Keep the webview coordinate system stable; only text should scale.
void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => {
console.error("Failed to reset webview zoom", error);
});

function handleKeyDown(event: KeyboardEvent) {
const action = getZoomAction(event);
Expand All @@ -77,11 +117,7 @@ export function useWebviewZoomShortcuts() {
}

zoomFactorRef.current = nextZoomFactor;

void webview.setZoom(nextZoomFactor).catch((error) => {
zoomFactorRef.current = previousZoomFactor;
console.error("Failed to update webview zoom", error);
});
applyTextScale(nextZoomFactor);
}

window.addEventListener("keydown", handleKeyDown);
Expand Down
10 changes: 5 additions & 5 deletions desktop/src/features/chat/ui/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type ChatHeaderProps = {
statusBadge?: React.ReactNode;
};

const HEADER_ICON_CLASS = "h-3.5 w-3.5 text-muted-foreground";
const HEADER_ICON_CLASS = "h-[14px] w-[14px] text-muted-foreground";

function ChannelIcon({
channelType,
Expand Down Expand Up @@ -90,15 +90,15 @@ export function ChatHeader({
return (
<header
className={cn(
"relative z-30 flex min-h-11 min-w-0 shrink-0 cursor-default select-none items-center gap-2.5 bg-background/70 py-1.5 pl-4 pr-2 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl transition-[margin,padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:pl-6 sm:pr-3",
overlaysContent && "-mb-11",
reserveGlobalControls && "md:pl-40",
"relative z-30 flex min-h-[44px] min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-background/70 py-[6px] pl-[16px] pr-[8px] backdrop-blur-xl transition-[margin,padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 sm:pl-[24px] sm:pr-[12px]",
overlaysContent && "-mb-[44px]",
reserveGlobalControls && "md:pl-[160px]",
)}
data-testid="chat-header"
data-tauri-drag-region
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
<ChannelIcon
channelType={channelType}
mode={mode}
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/shared/ui/ViewLoadingFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ function LoadingHeaderSkeleton() {
return (
<header
className={cn(
"flex min-h-11 min-w-0 cursor-default select-none items-center gap-2.5 bg-background/70 py-1.5 pl-4 pr-2 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl transition-[padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:pl-6 sm:pr-3",
sidebarState === "collapsed" && "md:pl-40",
"flex min-h-[44px] min-w-0 cursor-default select-none items-center gap-[10px] bg-background/70 py-[6px] pl-[16px] pr-[8px] shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl transition-[padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:pl-[24px] sm:pr-[12px]",
sidebarState === "collapsed" && "md:pl-[160px]",
)}
data-tauri-drag-region
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="flex min-w-0 items-center gap-[6px]">
<Skeleton className="h-3.5 w-3.5 rounded-sm" />
<Skeleton className="h-4 w-28 max-w-[50vw]" />
</div>
Expand Down
10 changes: 5 additions & 5 deletions desktop/src/shared/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import {

const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_WIDTH = "256px";
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";

type SidebarContextProps = {
Expand Down Expand Up @@ -237,7 +237,7 @@ const Sidebar = React.forwardRef<
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_16px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
Expand All @@ -249,7 +249,7 @@ const Sidebar = React.forwardRef<
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
? "p-[8px] group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_18px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
className,
)}
Expand Down
83 changes: 55 additions & 28 deletions desktop/tests/e2e/profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,40 +346,67 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");

await page.keyboard.press(
process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal",
);
const getTextScaleState = () =>
page.evaluate(() => ({
fontSize: getComputedStyle(document.documentElement).fontSize,
storedScale: localStorage.getItem("sprout:text-scale"),
webviewZoom: window.__SPROUT_E2E_WEBVIEW_ZOOM__,
}));
const dispatchPrimaryShortcut = (
key: string,
code: string,
shiftKey = false,
) =>
page.evaluate(
({ code, key, shiftKey }) => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code,
ctrlKey: !isMac,
key,
metaKey: isMac,
shiftKey,
}),
);
},
{ code, key, shiftKey },
);

await dispatchPrimaryShortcut("+", "Equal", true);

await expect.poll(getTextScaleState).toEqual({
fontSize: "17.6px",
storedScale: "1.1",
webviewZoom: 1,
});

await expect
.poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__))
.toBe(1.2);
await dispatchPrimaryShortcut("-", "Minus");

await page.keyboard.press(
process.platform === "darwin" ? "Meta+Minus" : "Control+Minus",
);
await expect.poll(getTextScaleState).toEqual({
fontSize: "16px",
storedScale: null,
webviewZoom: 1,
});

await expect
.poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__))
.toBe(1);
await dispatchPrimaryShortcut("+", "Equal", true);
await dispatchPrimaryShortcut("+", "Equal", true);

await page.keyboard.press(
process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal",
);
await page.keyboard.press(
process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal",
);

await expect
.poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__))
.toBe(1.4);
await expect.poll(getTextScaleState).toEqual({
fontSize: "19.2px",
storedScale: "1.2",
webviewZoom: 1,
});

await page.keyboard.press(
process.platform === "darwin" ? "Meta+Digit0" : "Control+Digit0",
);
await dispatchPrimaryShortcut("0", "Digit0");

await expect
.poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__))
.toBe(1);
await expect.poll(getTextScaleState).toEqual({
fontSize: "16px",
storedScale: null,
webviewZoom: 1,
});
});

test("shows doctor checks for local sprout tooling", async ({ page }) => {
Expand Down
Loading