Skip to content

Commit 26be944

Browse files
authored
Merge branch 'main' into cursor-pointer
2 parents a2c57b6 + 876bbd7 commit 26be944

11 files changed

Lines changed: 337 additions & 138 deletions

File tree

apps/web/src/components/DiffPanel.tsx

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ import { cn } from "~/lib/utils";
1919
import { readNativeApi } from "../nativeApi";
2020
import { resolvePathLinkTarget } from "../terminal-links";
2121
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
22-
import { isElectron } from "../env";
2322
import { useTheme } from "../hooks/useTheme";
2423
import { buildPatchCacheKey } from "../lib/diffRendering";
2524
import { resolveDiffThemeName } from "../lib/diffRendering";
2625
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
2726
import { useStore } from "../store";
2827
import { useAppSettings } from "../appSettings";
2928
import { formatShortTimestamp } from "../timestampFormat";
29+
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
3030
import { ToggleGroup, Toggle } from "./ui/toggle-group";
3131

3232
type DiffRenderMode = "stacked" | "split";
@@ -152,7 +152,7 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
152152
}
153153

154154
interface DiffPanelProps {
155-
mode?: "inline" | "sheet" | "sidebar";
155+
mode?: DiffPanelMode;
156156
}
157157

158158
export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
@@ -398,7 +398,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
398398
selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
399399
}, [selectedTurn?.turnId, selectedTurnId]);
400400

401-
const shouldUseDragRegion = isElectron && mode !== "sheet";
402401
const headerRow = (
403402
<>
404403
<div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
@@ -512,28 +511,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
512511
</ToggleGroup>
513512
</>
514513
);
515-
const headerRowClassName = cn(
516-
"flex items-center justify-between gap-2 px-4",
517-
shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
518-
);
519514

520515
return (
521-
<div
522-
className={cn(
523-
"flex h-full min-w-0 flex-col bg-background",
524-
mode === "inline"
525-
? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
526-
: "w-full",
527-
)}
528-
>
529-
{shouldUseDragRegion ? (
530-
<div className={headerRowClassName}>{headerRow}</div>
531-
) : (
532-
<div className="border-b border-border">
533-
<div className={headerRowClassName}>{headerRow}</div>
534-
</div>
535-
)}
536-
516+
<DiffPanelShell mode={mode} header={headerRow}>
537517
{!activeThread ? (
538518
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
539519
Select a thread to inspect turn diffs.
@@ -558,15 +538,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
558538
</div>
559539
)}
560540
{!renderablePatch ? (
561-
<div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
562-
<p>
563-
{isLoadingCheckpointDiff
564-
? "Loading checkpoint diff..."
565-
: hasNoNetChanges
541+
isLoadingCheckpointDiff ? (
542+
<DiffPanelLoadingState label="Loading checkpoint diff..." />
543+
) : (
544+
<div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
545+
<p>
546+
{hasNoNetChanges
566547
? "No net changes in this selection."
567548
: "No patch available for this selection."}
568-
</p>
569-
</div>
549+
</p>
550+
</div>
551+
)
570552
) : renderablePatch.kind === "files" ? (
571553
<Virtualizer
572554
className="diff-render-surface h-full min-h-0 overflow-auto px-2 pb-2"
@@ -622,6 +604,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
622604
</div>
623605
</>
624606
)}
625-
</div>
607+
</DiffPanelShell>
626608
);
627609
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ReactNode } from "react";
2+
3+
import { isElectron } from "~/env";
4+
import { cn } from "~/lib/utils";
5+
6+
import { Skeleton } from "./ui/skeleton";
7+
8+
export type DiffPanelMode = "inline" | "sheet" | "sidebar";
9+
10+
function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
11+
const shouldUseDragRegion = isElectron && mode !== "sheet";
12+
return cn(
13+
"flex items-center justify-between gap-2 px-4",
14+
shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
15+
);
16+
}
17+
18+
export function DiffPanelShell(props: {
19+
mode: DiffPanelMode;
20+
header: ReactNode;
21+
children: ReactNode;
22+
}) {
23+
const shouldUseDragRegion = isElectron && props.mode !== "sheet";
24+
25+
return (
26+
<div
27+
className={cn(
28+
"flex h-full min-w-0 flex-col bg-background",
29+
props.mode === "inline"
30+
? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
31+
: "w-full",
32+
)}
33+
>
34+
{shouldUseDragRegion ? (
35+
<div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
36+
) : (
37+
<div className="border-b border-border">
38+
<div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
39+
</div>
40+
)}
41+
{props.children}
42+
</div>
43+
);
44+
}
45+
46+
export function DiffPanelHeaderSkeleton() {
47+
return (
48+
<>
49+
<div className="relative min-w-0 flex-1">
50+
<Skeleton className="absolute left-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />
51+
<Skeleton className="absolute right-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />
52+
<div className="flex gap-1 overflow-hidden px-8 py-0.5">
53+
<Skeleton className="h-6 w-16 shrink-0 rounded-md" />
54+
<Skeleton className="h-6 w-24 shrink-0 rounded-md" />
55+
<Skeleton className="h-6 w-24 shrink-0 rounded-md max-sm:hidden" />
56+
</div>
57+
</div>
58+
<div className="flex shrink-0 gap-1">
59+
<Skeleton className="size-7 rounded-md" />
60+
<Skeleton className="size-7 rounded-md" />
61+
</div>
62+
</>
63+
);
64+
}
65+
66+
export function DiffPanelLoadingState(props: { label: string }) {
67+
return (
68+
<div className="flex min-h-0 flex-1 flex-col p-2">
69+
<div
70+
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/60 bg-card/25"
71+
role="status"
72+
aria-live="polite"
73+
aria-label={props.label}
74+
>
75+
<div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">
76+
<Skeleton className="h-4 w-32 rounded-full" />
77+
<Skeleton className="ml-auto h-4 w-20 rounded-full" />
78+
</div>
79+
<div className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-4">
80+
<div className="space-y-2">
81+
<Skeleton className="h-3 w-full rounded-full" />
82+
<Skeleton className="h-3 w-full rounded-full" />
83+
<Skeleton className="h-3 w-10/12 rounded-full" />
84+
<Skeleton className="h-3 w-11/12 rounded-full" />
85+
<Skeleton className="h-3 w-9/12 rounded-full" />
86+
</div>
87+
<span className="sr-only">{props.label}</span>
88+
</div>
89+
</div>
90+
</div>
91+
);
92+
}

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.

0 commit comments

Comments
 (0)