Skip to content

Commit 876bbd7

Browse files
juliusmarmingemacroscopeapp[bot]cursoragentcodexcursor[bot]
authored
Extract reusable clipboard hook and standardize media queries (#1006)
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
1 parent ac0d1f5 commit 876bbd7

File tree

8 files changed

+189
-83
lines changed

8 files changed

+189
-83
lines changed

apps/web/src/components/PlanSidebar.tsx

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useState, useCallback, useRef, useEffect } from "react";
1+
import { memo, useState, useCallback } from "react";
22
import { type TimestampFormat } from "../appSettings";
33
import { Badge } from "./ui/badge";
44
import { Button } from "./ui/button";
@@ -26,6 +26,7 @@ import {
2626
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
2727
import { readNativeApi } from "~/nativeApi";
2828
import { toastManager } from "./ui/toast";
29+
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2930

3031
function stepStatusIcon(status: string): React.ReactNode {
3132
if (status === "completed") {
@@ -68,35 +69,16 @@ const PlanSidebar = memo(function PlanSidebar({
6869
}: PlanSidebarProps) {
6970
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
7071
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
71-
const [copied, setCopied] = useState(false);
72-
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
72+
const { copyToClipboard, isCopied } = useCopyToClipboard();
7373

7474
const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
7575
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
7676
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
7777

7878
const handleCopyPlan = useCallback(() => {
7979
if (!planMarkdown) return;
80-
void navigator.clipboard.writeText(planMarkdown);
81-
if (copiedTimerRef.current != null) {
82-
clearTimeout(copiedTimerRef.current);
83-
}
84-
85-
setCopied(true);
86-
copiedTimerRef.current = setTimeout(() => {
87-
setCopied(false);
88-
copiedTimerRef.current = null;
89-
}, 2000);
90-
}, [planMarkdown]);
91-
92-
// Cleanup timeout on unmount
93-
useEffect(() => {
94-
return () => {
95-
if (copiedTimerRef.current != null) {
96-
clearTimeout(copiedTimerRef.current);
97-
}
98-
};
99-
}, []);
80+
copyToClipboard(planMarkdown);
81+
}, [planMarkdown, copyToClipboard]);
10082

10183
const handleDownload = useCallback(() => {
10284
if (!planMarkdown) return;
@@ -169,7 +151,7 @@ const PlanSidebar = memo(function PlanSidebar({
169151
</MenuTrigger>
170152
<MenuPopup align="end">
171153
<MenuItem onClick={handleCopyPlan}>
172-
{copied ? "Copied!" : "Copy to clipboard"}
154+
{isCopied ? "Copied!" : "Copy to clipboard"}
173155
</MenuItem>
174156
<MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
175157
<MenuItem

apps/web/src/components/Sidebar.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,11 @@ import {
8989
resolveThreadStatusPill,
9090
shouldClearThreadSelectionOnMouseDown,
9191
} from "./Sidebar.logic";
92+
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
9293

9394
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
9495
const THREAD_PREVIEW_LIMIT = 6;
9596

96-
async function copyTextToClipboard(text: string): Promise<void> {
97-
if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
98-
throw new Error("Clipboard API unavailable.");
99-
}
100-
await navigator.clipboard.writeText(text);
101-
}
102-
10397
function formatRelativeTime(iso: string): string {
10498
const diff = Date.now() - new Date(iso).getTime();
10599
const minutes = Math.floor(diff / 60_000);
@@ -671,6 +665,22 @@ export default function Sidebar() {
671665
],
672666
);
673667

668+
const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
669+
onCopy: (ctx) => {
670+
toastManager.add({
671+
type: "success",
672+
title: "Thread ID copied",
673+
description: ctx.threadId,
674+
});
675+
},
676+
onError: (error) => {
677+
toastManager.add({
678+
type: "error",
679+
title: "Failed to copy thread ID",
680+
description: error instanceof Error ? error.message : "An error occurred.",
681+
});
682+
},
683+
});
674684
const handleThreadContextMenu = useCallback(
675685
async (threadId: ThreadId, position: { x: number; y: number }) => {
676686
const api = readNativeApi();
@@ -699,20 +709,7 @@ export default function Sidebar() {
699709
return;
700710
}
701711
if (clicked === "copy-thread-id") {
702-
try {
703-
await copyTextToClipboard(threadId);
704-
toastManager.add({
705-
type: "success",
706-
title: "Thread ID copied",
707-
description: threadId,
708-
});
709-
} catch (error) {
710-
toastManager.add({
711-
type: "error",
712-
title: "Failed to copy thread ID",
713-
description: error instanceof Error ? error.message : "An error occurred.",
714-
});
715-
}
712+
copyToClipboard(threadId, { threadId });
716713
return;
717714
}
718715
if (clicked !== "delete") return;
@@ -729,7 +726,7 @@ export default function Sidebar() {
729726
}
730727
await deleteThread(threadId);
731728
},
732-
[appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads],
729+
[appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads],
733730
);
734731

735732
const handleMultiSelectContextMenu = useCallback(
Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1-
import { memo, useCallback, useState } from "react";
1+
import { memo } from "react";
22
import { CopyIcon, CheckIcon } from "lucide-react";
33
import { Button } from "../ui/button";
4+
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
45

56
export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
6-
const [copied, setCopied] = useState(false);
7-
8-
const handleCopy = useCallback(() => {
9-
void navigator.clipboard.writeText(text);
10-
setCopied(true);
11-
setTimeout(() => setCopied(false), 2000);
12-
}, [text]);
7+
const { copyToClipboard, isCopied } = useCopyToClipboard();
138

149
return (
15-
<Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message">
16-
{copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
10+
<Button
11+
type="button"
12+
size="xs"
13+
variant="outline"
14+
onClick={() => copyToClipboard(text)}
15+
title="Copy message"
16+
>
17+
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
1718
</Button>
1819
);
1920
});

apps/web/src/components/ui/sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from "~/components/ui/sheet";
1818
import { Skeleton } from "~/components/ui/skeleton";
1919
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20-
import { useMediaQuery } from "~/hooks/useMediaQuery";
20+
import { useIsMobile } from "~/hooks/useMediaQuery";
2121
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2222
import { Schema } from "effect";
2323

@@ -98,7 +98,7 @@ function SidebarProvider({
9898
open?: boolean;
9999
onOpenChange?: (open: boolean) => void;
100100
}) {
101-
const isMobile = useMediaQuery("(max-width: 767px)");
101+
const isMobile = useIsMobile();
102102
const [openMobile, setOpenMobile] = React.useState(false);
103103

104104
// This is the internal state of the sidebar.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as React from "react";
2+
3+
export function useCopyToClipboard<TContext = void>({
4+
timeout = 2000,
5+
onCopy,
6+
onError,
7+
}: {
8+
timeout?: number;
9+
onCopy?: (ctx: TContext) => void;
10+
onError?: (error: Error, ctx: TContext) => void;
11+
} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
12+
const [isCopied, setIsCopied] = React.useState(false);
13+
const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
14+
const onCopyRef = React.useRef(onCopy);
15+
const onErrorRef = React.useRef(onError);
16+
const timeoutRef = React.useRef(timeout);
17+
18+
onCopyRef.current = onCopy;
19+
onErrorRef.current = onError;
20+
timeoutRef.current = timeout;
21+
22+
const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => {
23+
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
24+
onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx);
25+
return;
26+
}
27+
28+
if (!value) return;
29+
30+
navigator.clipboard.writeText(value).then(
31+
() => {
32+
if (timeoutIdRef.current) {
33+
clearTimeout(timeoutIdRef.current);
34+
}
35+
setIsCopied(true);
36+
37+
onCopyRef.current?.(ctx);
38+
39+
if (timeoutRef.current !== 0) {
40+
timeoutIdRef.current = setTimeout(() => {
41+
setIsCopied(false);
42+
timeoutIdRef.current = null;
43+
}, timeoutRef.current);
44+
}
45+
},
46+
(error) => {
47+
if (onErrorRef.current) {
48+
onErrorRef.current(error, ctx);
49+
} else {
50+
console.error(error);
51+
}
52+
},
53+
);
54+
}, []);
55+
56+
// Cleanup timeout on unmount
57+
React.useEffect(() => {
58+
return (): void => {
59+
if (timeoutIdRef.current) {
60+
clearTimeout(timeoutIdRef.current);
61+
}
62+
};
63+
}, []);
64+
65+
return { copyToClipboard, isCopied };
66+
}
Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,87 @@
1-
import { useEffect, useState } from "react";
1+
import { useCallback, useSyncExternalStore } from "react";
22

3-
function getMediaQueryMatch(query: string): boolean {
4-
if (typeof window === "undefined") {
5-
return false;
3+
const BREAKPOINTS = {
4+
"2xl": 1536,
5+
"3xl": 1600,
6+
"4xl": 2000,
7+
lg: 1024,
8+
md: 768,
9+
sm: 640,
10+
xl: 1280,
11+
} as const;
12+
13+
type Breakpoint = keyof typeof BREAKPOINTS;
14+
15+
type BreakpointQuery = Breakpoint | `max-${Breakpoint}` | `${Breakpoint}:max-${Breakpoint}`;
16+
17+
function resolveMin(value: Breakpoint | number): string {
18+
const px = typeof value === "number" ? value : BREAKPOINTS[value];
19+
return `(min-width: ${px}px)`;
20+
}
21+
22+
function resolveMax(value: Breakpoint | number): string {
23+
const px = typeof value === "number" ? value : BREAKPOINTS[value];
24+
return `(max-width: ${px - 1}px)`;
25+
}
26+
27+
function parseQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): string {
28+
if (typeof query !== "string") {
29+
const parts: string[] = [];
30+
if (query.min != null) parts.push(resolveMin(query.min));
31+
if (query.max != null) parts.push(resolveMax(query.max));
32+
if (query.pointer === "coarse") parts.push("(pointer: coarse)");
33+
if (query.pointer === "fine") parts.push("(pointer: fine)");
34+
if (parts.length === 0) return "(min-width: 0px)";
35+
return parts.join(" and ");
36+
}
37+
38+
if (query.startsWith("(")) return query;
39+
40+
const parts: string[] = [];
41+
for (const segment of query.split(":")) {
42+
if (segment.startsWith("max-")) {
43+
const bp = segment.slice(4);
44+
if (bp in BREAKPOINTS) parts.push(resolveMax(bp as Breakpoint));
45+
} else if (segment in BREAKPOINTS) {
46+
parts.push(resolveMin(segment as Breakpoint));
47+
}
648
}
7-
return window.matchMedia(query).matches;
49+
50+
return parts.length > 0 ? parts.join(" and ") : query;
51+
}
52+
53+
function getServerSnapshot(): boolean {
54+
return false;
855
}
956

10-
export function useMediaQuery(query: string): boolean {
11-
const [matches, setMatches] = useState(() => getMediaQueryMatch(query));
57+
export type MediaQueryInput = {
58+
min?: Breakpoint | number;
59+
max?: Breakpoint | number;
60+
/** Touch-like input (finger). Use "fine" for mouse/trackpad. */
61+
pointer?: "coarse" | "fine";
62+
};
63+
64+
export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): boolean {
65+
const mediaQuery = parseQuery(query);
1266

13-
useEffect(() => {
14-
const mediaQueryList = window.matchMedia(query);
15-
const handleChange = () => {
16-
setMatches(mediaQueryList.matches);
17-
};
67+
const subscribe = useCallback(
68+
(callback: () => void) => {
69+
if (typeof window === "undefined") return () => {};
70+
const mql = window.matchMedia(mediaQuery);
71+
mql.addEventListener("change", callback);
72+
return () => mql.removeEventListener("change", callback);
73+
},
74+
[mediaQuery],
75+
);
1876

19-
setMatches(mediaQueryList.matches);
20-
mediaQueryList.addEventListener("change", handleChange);
21-
return () => {
22-
mediaQueryList.removeEventListener("change", handleChange);
23-
};
24-
}, [query]);
77+
const getSnapshot = useCallback(() => {
78+
if (typeof window === "undefined") return false;
79+
return window.matchMedia(mediaQuery).matches;
80+
}, [mediaQuery]);
81+
82+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
83+
}
2584

26-
return matches;
85+
export function useIsMobile(): boolean {
86+
return useMediaQuery("max-md");
2787
}

apps/web/src/routes/_chat.$threadId.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ function ChatThreadRouteView() {
221221
if (!shouldUseDiffSheet) {
222222
return (
223223
<>
224-
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
224+
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
225225
<ChatView key={threadId} threadId={threadId} />
226226
</SidebarInset>
227227
<DiffPanelInlineSidebar

bun.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)