From 6de9eb4623f189267c03a6fb7e0ecb50780f400f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 20:13:52 -0700 Subject: [PATCH 1/4] Extract reusable clipboard hook and standardize media queries - add `useCopyToClipboard` and adopt it in plan, sidebar, and message copy UI - extend `useMediaQuery` with breakpoint-aware queries and add `useIsMobile` - switch chat diff layout and sidebar mobile checks to semantic breakpoint queries --- apps/web/src/components/PlanSidebar.tsx | 30 ++---- apps/web/src/components/Sidebar.tsx | 41 ++++---- .../src/components/chat/MessageCopyButton.tsx | 21 ++-- apps/web/src/components/ui/sidebar.tsx | 4 +- apps/web/src/hooks/useCopyToClipboard.ts | 60 ++++++++++++ apps/web/src/hooks/useMediaQuery.ts | 98 +++++++++++++++---- apps/web/src/routes/_chat.$threadId.tsx | 5 +- bun.lock | 10 +- 8 files changed, 184 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/hooks/useCopyToClipboard.ts diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index a1e18e4afd..47bee930cc 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback, useRef, useEffect } from "react"; +import { memo, useState, useCallback } from "react"; import { type TimestampFormat } from "../appSettings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -26,6 +26,7 @@ import { import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { readNativeApi } from "~/nativeApi"; import { toastManager } from "./ui/toast"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { @@ -68,8 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({ }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); + const { copyToClipboard, isCopied } = useCopyToClipboard(); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; @@ -77,26 +77,8 @@ const PlanSidebar = memo(function PlanSidebar({ const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; - void navigator.clipboard.writeText(planMarkdown); - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 2000); - }, [planMarkdown]); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - }; - }, []); + copyToClipboard(planMarkdown); + }, [planMarkdown, copyToClipboard]); const handleDownload = useCallback(() => { if (!planMarkdown) return; @@ -169,7 +151,7 @@ const PlanSidebar = memo(function PlanSidebar({ - {copied ? "Copied!" : "Copy to clipboard"} + {isCopied ? "Copied!" : "Copy to clipboard"} Download as markdown { - if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { - throw new Error("Clipboard API unavailable."); - } - await navigator.clipboard.writeText(text); -} - function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -667,6 +661,22 @@ export default function Sidebar() { ], ); + const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: ctx.threadId, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -695,20 +705,7 @@ export default function Sidebar() { return; } if (clicked === "copy-thread-id") { - try { - await copyTextToClipboard(threadId); - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: threadId, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } + copyToClipboard(threadId, { threadId }); return; } if (clicked !== "delete") return; @@ -725,7 +722,7 @@ export default function Sidebar() { } await deleteThread(threadId); }, - [appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads], + [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads], ); const handleMultiSelectContextMenu = useCallback( diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index b3972d2530..cf1e798912 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,19 +1,20 @@ -import { memo, useCallback, useState } from "react"; +import { memo } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - void navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text]); + const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( - ); }); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 9f25ea5cc8..15b8430c82 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -17,7 +17,7 @@ import { } from "~/components/ui/sheet"; import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { Schema } from "effect"; @@ -98,7 +98,7 @@ function SidebarProvider({ open?: boolean; onOpenChange?: (open: boolean) => void; }) { - const isMobile = useMediaQuery("(max-width: 767px)"); + const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000000..083bed86ae --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +export function useCopyToClipboard({ + timeout = 2000, + onCopy, + onError, +}: { + timeout?: number; + onCopy?: (ctx: TContext) => void; + onError?: (error: Error, ctx: TContext) => void; +} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { + const [isCopied, setIsCopied] = React.useState(false); + const timeoutIdRef = React.useRef(null); + + const copyToClipboard = (value: string, ctx: TContext): void => { + if (typeof window === "undefined" || !navigator.clipboard.writeText) { + return; + } + + if (!value) return; + + navigator.clipboard.writeText(value).then( + () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + setIsCopied(true); + + if (onCopy) { + onCopy(ctx); + } + + if (timeout !== 0) { + timeoutIdRef.current = setTimeout(() => { + setIsCopied(false); + timeoutIdRef.current = null; + }, timeout); + } + }, + (error) => { + if (onError) { + onError(error, ctx); + } else { + console.error(error); + } + }, + ); + }; + + // Cleanup timeout on unmount + React.useEffect(() => { + return (): void => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + }; + }, []); + + return { copyToClipboard, isCopied }; +} diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index 1488eb0bf8..fe07aa0b21 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -1,27 +1,87 @@ -import { useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; -function getMediaQueryMatch(query: string): boolean { - if (typeof window === "undefined") { - return false; +const BREAKPOINTS = { + "2xl": 1536, + "3xl": 1600, + "4xl": 2000, + lg: 1024, + md: 800, + sm: 640, + xl: 1280, +} as const; + +type Breakpoint = keyof typeof BREAKPOINTS; + +type BreakpointQuery = Breakpoint | `max-${Breakpoint}` | `${Breakpoint}:max-${Breakpoint}`; + +function resolveMin(value: Breakpoint | number): string { + const px = typeof value === "number" ? value : BREAKPOINTS[value]; + return `(min-width: ${px}px)`; +} + +function resolveMax(value: Breakpoint | number): string { + const px = typeof value === "number" ? value : BREAKPOINTS[value]; + return `(max-width: ${px - 1}px)`; +} + +function parseQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): string { + if (typeof query !== "string") { + const parts: string[] = []; + if (query.min != null) parts.push(resolveMin(query.min)); + if (query.max != null) parts.push(resolveMax(query.max)); + if (query.pointer === "coarse") parts.push("(pointer: coarse)"); + if (query.pointer === "fine") parts.push("(pointer: fine)"); + if (parts.length === 0) return "(min-width: 0px)"; + return parts.join(" and "); + } + + if (query.startsWith("(")) return query; + + const parts: string[] = []; + for (const segment of query.split(":")) { + if (segment.startsWith("max-")) { + const bp = segment.slice(4); + if (bp in BREAKPOINTS) parts.push(resolveMax(bp as Breakpoint)); + } else if (segment in BREAKPOINTS) { + parts.push(resolveMin(segment as Breakpoint)); + } } - return window.matchMedia(query).matches; + + return parts.length > 0 ? parts.join(" and ") : query; +} + +function getServerSnapshot(): boolean { + return false; } -export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(() => getMediaQueryMatch(query)); +export type MediaQueryInput = { + min?: Breakpoint | number; + max?: Breakpoint | number; + /** Touch-like input (finger). Use "fine" for mouse/trackpad. */ + pointer?: "coarse" | "fine"; +}; + +export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): boolean { + const mediaQuery = parseQuery(query); - useEffect(() => { - const mediaQueryList = window.matchMedia(query); - const handleChange = () => { - setMatches(mediaQueryList.matches); - }; + const subscribe = useCallback( + (callback: () => void) => { + if (typeof window === "undefined") return () => {}; + const mql = window.matchMedia(mediaQuery); + mql.addEventListener("change", callback); + return () => mql.removeEventListener("change", callback); + }, + [mediaQuery], + ); - setMatches(mediaQueryList.matches); - mediaQueryList.addEventListener("change", handleChange); - return () => { - mediaQueryList.removeEventListener("change", handleChange); - }; - }, [query]); + const getSnapshot = useCallback(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(mediaQuery).matches; + }, [mediaQuery]); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} - return matches; +export function useIsMobile(): boolean { + return useMediaQuery("max-md"); } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 5e05b51fbd..930957af09 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -15,7 +15,6 @@ import { Sheet, SheetPopup } from "../components/ui/sheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; @@ -165,7 +164,7 @@ function ChatThreadRouteView() { ); const routeThreadExists = threadExists || draftThreadExists; const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const shouldUseDiffSheet = useMediaQuery("max-xl"); const closeDiff = useCallback(() => { void navigate({ to: "/$threadId", @@ -202,7 +201,7 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( <> - + diff --git a/bun.lock b/bun.lock index 452ae30126..1c88d955b6 100644 --- a/bun.lock +++ b/bun.lock @@ -258,13 +258,13 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }, "sha512-lOVb9aLpFnbkG4dWxY8lSTXihldd6kbcFI936X5UMvHz0HAYGrxsu1a+kHt/rYjiw1gjCmmnU6pE2pwKfO9erg=="], + "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }, "sha512-73FDHSTVLh/ZHeWrHFEbnCbjFE9gKi1YdJ9LEatDj0doZ8ouuTJW2BbqLDwMEfSf4zNQrxMEg+Jj/pb612HyWg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }, "sha512-7Tcfkx/NfQILRHyO5c52I8NYrg6jBcxDAguTYx7tgbGHYU9WPQRGh/AkcRgQ+1fsUGjWdojSfd+WGb/r4ltYtQ=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-QQxLK156FZVPGYqSUPnEcybDm6e45KLcXb30XcimIKbIxuJLkuyxHQJW2hUu7lxjCoCotj2B4vc96gPKH/3Deg=="], + "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1020,7 +1020,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-tZcsKgsLpQ5p1T5/duJHebWAqyG8owTuJdASj/2V+WPMuDlnB+uKvetMMXJsrNOyElUb3lVFeF2Mq00BbCnL9g=="], + "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], From bf7e673a3e53164e4cf2f9976c03c1e9d2d7a900 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 20:17:38 -0700 Subject: [PATCH 2/4] Update apps/web/src/hooks/useCopyToClipboard.ts Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- apps/web/src/hooks/useCopyToClipboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 083bed86ae..6b606106b9 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -13,7 +13,7 @@ export function useCopyToClipboard({ const timeoutIdRef = React.useRef(null); const copyToClipboard = (value: string, ctx: TContext): void => { - if (typeof window === "undefined" || !navigator.clipboard.writeText) { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { return; } From 7d16b8eae7c5f185e0dc7ef6d1bde6e5f25ef084 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 03:43:43 +0000 Subject: [PATCH 3/4] Fix breakpoint mismatch and stabilize copyToClipboard reference - Change BREAKPOINTS.md from 800 to 768 to match Tailwind CSS v4's default md breakpoint, fixing the 768-799px gap where the sidebar trigger was hidden but the sidebar was in mobile sheet mode. - Wrap copyToClipboard in useCallback with refs for onCopy/onError/timeout so the returned function identity is stable across renders, preventing unnecessary re-creation of downstream useCallback hooks. Co-authored-by: Julius Marminge Applied via @cursor push command --- apps/web/src/hooks/useCopyToClipboard.ts | 23 ++++++++++++++--------- apps/web/src/hooks/useMediaQuery.ts | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 6b606106b9..896af53085 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -11,8 +11,15 @@ export function useCopyToClipboard({ } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { const [isCopied, setIsCopied] = React.useState(false); const timeoutIdRef = React.useRef(null); + const onCopyRef = React.useRef(onCopy); + const onErrorRef = React.useRef(onError); + const timeoutRef = React.useRef(timeout); - const copyToClipboard = (value: string, ctx: TContext): void => { + onCopyRef.current = onCopy; + onErrorRef.current = onError; + timeoutRef.current = timeout; + + const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { return; } @@ -26,26 +33,24 @@ export function useCopyToClipboard({ } setIsCopied(true); - if (onCopy) { - onCopy(ctx); - } + onCopyRef.current?.(ctx); - if (timeout !== 0) { + if (timeoutRef.current !== 0) { timeoutIdRef.current = setTimeout(() => { setIsCopied(false); timeoutIdRef.current = null; - }, timeout); + }, timeoutRef.current); } }, (error) => { - if (onError) { - onError(error, ctx); + if (onErrorRef.current) { + onErrorRef.current(error, ctx); } else { console.error(error); } }, ); - }; + }, []); // Cleanup timeout on unmount React.useEffect(() => { diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index fe07aa0b21..e7e007b0b7 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -5,7 +5,7 @@ const BREAKPOINTS = { "3xl": 1600, "4xl": 2000, lg: 1024, - md: 800, + md: 768, sm: 640, xl: 1280, } as const; From 6b03ce9bea0480bbd18c4671dca1cbc0af8a0e82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 03:51:03 +0000 Subject: [PATCH 4/4] fix: invoke onError callback when Clipboard API is unavailable Co-authored-by: Julius Marminge --- apps/web/src/hooks/useCopyToClipboard.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 896af53085..d1feb62115 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -21,6 +21,7 @@ export function useCopyToClipboard({ const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); return; }