Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
30 changes: 6 additions & 24 deletions apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -68,35 +69,16 @@ const PlanSidebar = memo(function PlanSidebar({
}: PlanSidebarProps) {
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { copyToClipboard, isCopied } = useCopyToClipboard();

const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;

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;
Expand Down Expand Up @@ -169,7 +151,7 @@ const PlanSidebar = memo(function PlanSidebar({
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={handleCopyPlan}>
{copied ? "Copied!" : "Copy to clipboard"}
{isCopied ? "Copied!" : "Copy to clipboard"}
</MenuItem>
<MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
<MenuItem
Expand Down
41 changes: 19 additions & 22 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,11 @@ import {
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
} from "./Sidebar.logic";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const THREAD_PREVIEW_LIMIT = 6;

async function copyTextToClipboard(text: string): Promise<void> {
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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down
21 changes: 11 additions & 10 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message">
{copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
<Button
type="button"
size="xs"
variant="outline"
onClick={() => copyToClipboard(text)}
title="Copy message"
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
</Button>
);
});
4 changes: 2 additions & 2 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from "react";

export function useCopyToClipboard<TContext = void>({
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<NodeJS.Timeout | null>(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 };
}
98 changes: 79 additions & 19 deletions apps/web/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -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");
}
5 changes: 2 additions & 3 deletions apps/web/src/routes/_chat.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -202,7 +201,7 @@ function ChatThreadRouteView() {
if (!shouldUseDiffSheet) {
return (
<>
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<ChatView key={threadId} threadId={threadId} />
</SidebarInset>
<DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
Expand Down
Loading
Loading