diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index a1e18e4af..47bee930c 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); @@ -671,6 +665,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(); @@ -699,20 +709,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; @@ -729,7 +726,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 b3972d253..cf1e79891 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 9f25ea5cc..15b8430c8 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 000000000..d1feb6211 --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,66 @@ +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 onCopyRef = React.useRef(onCopy); + const onErrorRef = React.useRef(onError); + const timeoutRef = React.useRef(timeout); + + 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) { + onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); + return; + } + + if (!value) return; + + navigator.clipboard.writeText(value).then( + () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + setIsCopied(true); + + onCopyRef.current?.(ctx); + + if (timeoutRef.current !== 0) { + timeoutIdRef.current = setTimeout(() => { + setIsCopied(false); + timeoutIdRef.current = null; + }, timeoutRef.current); + } + }, + (error) => { + if (onErrorRef.current) { + onErrorRef.current(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 1488eb0bf..e7e007b0b 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: 768, + 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 d7dfd56a7..8e7a5d3ba 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -221,7 +221,7 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( <> - +