authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-12 21:03:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 21:03:21-07:00
log876bbd715ae6aa8e1d663455747e17c92e0a287c
tree7881f15b09c9f4e7095776b7b2e384aa62751c9b
parentac0d1f52a087c63819b2b032ac4535d18d3c23af
signature Signed by PGP key B5690EEEBB952194

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>

8 files changed, 189 insertions(+), 83 deletions(-)

apps/web/src/components/PlanSidebar.tsx+6-24
......@@ -1,4 +1,4 @@
1import { memo, useState, useCallback, useRef, useEffect } from "react";
1import { memo, useState, useCallback } from "react";
22import { type TimestampFormat } from "../appSettings";
33import { Badge } from "./ui/badge";
44import { Button } from "./ui/button";
......@@ -26,6 +26,7 @@ import {
2626import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
2727import { readNativeApi } from "~/nativeApi";
2828import { toastManager } from "./ui/toast";
29import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2930
3031function stepStatusIcon(status: string): React.ReactNode {
3132 if (status === "completed") {
......@@ -68,8 +69,7 @@ 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;
......@@ -77,26 +77,8 @@ const PlanSidebar = memo(function PlanSidebar({
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+19-22
......@@ -89,17 +89,11 @@ import {
8989 resolveThreadStatusPill,
9090 shouldClearThreadSelectionOnMouseDown,
9191} from "./Sidebar.logic";
92import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
9293
9394const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
9495const THREAD_PREVIEW_LIMIT = 6;
9596
96async 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
10397function 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(
apps/web/src/components/chat/MessageCopyButton.tsx+11-10
......@@ -1,19 +1,20 @@
1import { memo, useCallback, useState } from "react";
1import { memo } from "react";
22import { CopyIcon, CheckIcon } from "lucide-react";
33import { Button } from "../ui/button";
4import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
45
56export 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+2-2
......@@ -17,7 +17,7 @@ import {
1717} from "~/components/ui/sheet";
1818import { Skeleton } from "~/components/ui/skeleton";
1919import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useMediaQuery } from "~/hooks/useMediaQuery";
20import { useIsMobile } from "~/hooks/useMediaQuery";
2121import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2222import { 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.
apps/web/src/hooks/useCopyToClipboard.ts created+66
......@@ -0,0 +1,66 @@
1import * as React from "react";
2
3export 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}
apps/web/src/hooks/useMediaQuery.ts+79-19
......@@ -1,27 +1,87 @@
1import { useEffect, useState } from "react";
1import { useCallback, useSyncExternalStore } from "react";
22
3function getMediaQueryMatch(query: string): boolean {
4 if (typeof window === "undefined") {
5 return false;
3const 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
13type Breakpoint = keyof typeof BREAKPOINTS;
14
15type BreakpointQuery = Breakpoint | `max-${Breakpoint}` | `${Breakpoint}:max-${Breakpoint}`;
16
17function resolveMin(value: Breakpoint | number): string {
18 const px = typeof value === "number" ? value : BREAKPOINTS[value];
19 return `(min-width: ${px}px)`;
20}
21
22function resolveMax(value: Breakpoint | number): string {
23 const px = typeof value === "number" ? value : BREAKPOINTS[value];
24 return `(max-width: ${px - 1}px)`;
25}
26
27function 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
53function getServerSnapshot(): boolean {
54 return false;
855}
956
10export function useMediaQuery(query: string): boolean {
11 const [matches, setMatches] = useState(() => getMediaQueryMatch(query));
57export 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
64export 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;
85export function useIsMobile(): boolean {
86 return useMediaQuery("max-md");
2787}
apps/web/src/routes/_chat.$threadId.tsx+1-1
......@@ -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+5-5
......@@ -262,13 +262,13 @@
262262
263263 "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="],
264264
265 "@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=="],
265 "@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" } }],
266266
267 "@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=="],
267 "@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" } }],
268268
269 "@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=="],
269 "@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" } }],
270270
271 "@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=="],
271 "@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" } }],
272272
273273 "@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=="],
274274
......@@ -1010,7 +1010,7 @@
10101010
10111011 "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=="],
10121012
1013 "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=="],
1013 "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" } }],
10141014
10151015 "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=="],
10161016