Skip to content

Commit 1ef0c39

Browse files
committed
Extract reusable copy and tooltip action UI components
- Add new CopyActionButton and TooltipIconButton primitives with shared clipboard/tooltip behavior - Replace bespoke copy, zoom, and profile icon buttons with the new reusable actions - Introduce CompactDropdownTrigger and migrate compact dropdown trigger call sites - Add Button pressEffect control and update toolbar/menu copy logic to use the shared component
1 parent 044eef5 commit 1ef0c39

15 files changed

Lines changed: 494 additions & 245 deletions

src/components/chat-message.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe("MessageBubble", () => {
9494

9595
const footer = screen.getByTestId("message-usage-footer");
9696
expect(footer).toHaveClass("min-h-8");
97-
expect(footer.querySelector(".absolute")).toBeNull();
97+
expect(screen.getByTestId("message-nerd-stats").querySelector(".absolute")).toBeNull();
9898
expect(footer.querySelector(".items-center")).not.toBeNull();
9999
expect(screen.getByTestId("message-nerd-stats")).toHaveTextContent("TTFT 1.23 sec");
100100
expect(screen.queryByText(/Generation time/)).not.toBeInTheDocument();
@@ -239,6 +239,12 @@ describe("MessageBubble", () => {
239239
expect(screen.getByText("source-citations.ts")).toBeInTheDocument();
240240
});
241241

242+
test("renders the completed assistant copy action with its initial accessible label", () => {
243+
render(<MessageBubble message={makeAssistantMessage()} activeMessageStream={null} />);
244+
245+
expect(screen.getByTestId("message-copy-button")).toHaveAccessibleName("Copy reply");
246+
});
247+
242248
test("opens a configured local editor directly from the code source", async () => {
243249
const source = repositorySource();
244250
writeLocalEditorConfig(source.repositoryId, {

src/components/chat-message.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { memo, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
22
import type { AllowedTags, Components } from "streamdown";
33
import type { Doc } from "../../convex/_generated/dataModel";
4-
import { Message, MessageContent, MessageActions, MessageAction } from "@/components/ai-elements/message";
4+
import { Message, MessageContent, MessageActions } from "@/components/ai-elements/message";
55
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
66
import { Shimmer } from "@/components/ai-elements/shimmer";
77
import { LocalEditorSetupDialog } from "@/components/local-editor-setup-dialog";
88
import { ToolCallTrace } from "@/components/tool-call-trace";
99
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
1010
import { Badge } from "@/components/ui/badge";
1111
import { Button } from "@/components/ui/button";
12+
import { CopyActionButton } from "@/components/ui/copy-action-button";
1213
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
1314
import { Markdown } from "@/components/markdown";
1415
import { useClipboard } from "@/hooks/use-clipboard";
@@ -845,32 +846,18 @@ function computeReasoningDurationSeconds(
845846
return undefined;
846847
}
847848

848-
/**
849-
* Copy button for assistant messages. Shows a checkmark briefly after
850-
* clicking to confirm the copy succeeded.
851-
*
852-
* Delegates to {@link useClipboard} so the timer cleanup (auto-reset
853-
* after 1.5s), unmount guard, and `navigator.clipboard` availability
854-
* check all match the rest of the app's copy affordances rather than
855-
* being re-derived here. The hook swallows clipboard failures and
856-
* leaves `copied` false on rejection — the affordance stays idle when
857-
* the browser blocks the write (insecure context, permissions policy)
858-
* instead of falsely confirming a successful copy.
859-
*/
860849
function CopyMessageAction({ content }: { content: string }) {
861-
const { copied, copy } = useClipboard({ resetAfterMs: 1500 });
862-
const handleCopy = useCallback(() => {
863-
void copy(content);
864-
}, [copy, content]);
865850
return (
866-
<MessageAction
867-
tooltip={copied ? "Copied" : "Copy reply"}
851+
<CopyActionButton
852+
text={content}
853+
idleLabel="Copy reply"
854+
copiedLabel="Copied"
855+
idleAriaLabel="Copy reply"
868856
size="sm"
869-
onClick={handleCopy}
857+
copyIcon={<CopyIcon size={16} />}
858+
copiedIcon={<CheckIcon size={16} weight="bold" />}
870859
data-testid="message-copy-button"
871-
>
872-
{copied ? <CheckIcon size={16} weight="bold" /> : <CopyIcon size={16} />}
873-
</MessageAction>
860+
/>
874861
);
875862
}
876863

src/components/compact-model-settings-menu.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
resolveReasoningPickerState,
55
type ReasoningPickerState,
66
} from "@/components/ai-elements/reasoning-effort-options";
7-
import { Button } from "@/components/ui/button";
7+
import { CompactDropdownTrigger } from "@/components/ui/compact-dropdown-trigger";
88
import {
99
DropdownMenu,
1010
DropdownMenuContent,
@@ -59,18 +59,15 @@ export function CompactModelSettingsMenu({
5959
return (
6060
<DropdownMenu>
6161
<DropdownMenuTrigger asChild>
62-
<Button
63-
type="button"
64-
variant="ghost"
65-
size="sm"
62+
<CompactDropdownTrigger
63+
icon={<SlidersHorizontalIcon size={14} weight="bold" />}
64+
label={triggerLabel}
65+
caret={null}
6666
disabled={modelPicker.disabled}
6767
aria-label="Model settings"
6868
data-testid="compact-model-settings-trigger"
69-
className="h-8 max-w-full shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground active:scale-100 focus-visible:bg-muted focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted data-[state=open]:text-foreground"
70-
>
71-
<SlidersHorizontalIcon size={14} weight="bold" />
72-
<span className="truncate">{triggerLabel}</span>
73-
</Button>
69+
className="max-w-full hover:text-foreground focus-visible:bg-muted focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted data-[state=open]:text-foreground"
70+
/>
7471
</DropdownMenuTrigger>
7572
<DropdownMenuContent align="start" className="w-56 p-1" data-testid="compact-model-settings-menu">
7673
<DropdownMenuLabel>Model</DropdownMenuLabel>

src/components/library-ask-panel.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
22
import {
33
BookOpenIcon,
4-
CaretDownIcon,
54
FileHtmlIcon,
65
FilePlusIcon,
76
GitDiffIcon,
@@ -39,6 +38,7 @@ import {
3938
import { LibraryAskThreadTabs } from "@/components/library-ask-thread-tabs";
4039
import { type PromptInputModelPickerValue } from "@/components/ai-elements/prompt-input-model-picker";
4140
import { Button } from "@/components/ui/button";
41+
import { CompactDropdownTrigger } from "@/components/ui/compact-dropdown-trigger";
4242
import {
4343
DropdownMenu,
4444
DropdownMenuContent,
@@ -1058,23 +1058,13 @@ function LibraryAskComposerTools({ tools }: { tools: LibraryAskComposerToolsStat
10581058
>
10591059
<DropdownMenu>
10601060
<DropdownMenuTrigger asChild>
1061-
<button
1062-
type="button"
1063-
className={[
1064-
"inline-flex h-8 w-auto min-w-0 max-w-32 shrink-0 items-center justify-start gap-1.5 rounded-none border-none bg-transparent px-2 py-0 text-xs font-medium text-muted-foreground shadow-none transition-colors",
1065-
"hover:bg-accent hover:text-foreground",
1066-
"focus-visible:bg-transparent focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
1067-
"aria-expanded:bg-accent aria-expanded:text-foreground",
1068-
"disabled:pointer-events-none disabled:opacity-50",
1069-
"[&_svg]:shrink-0",
1070-
].join(" ")}
1061+
<CompactDropdownTrigger
1062+
icon={<SparkleIcon size={13} weight="bold" />}
1063+
label="Draft"
1064+
className="max-w-32"
10711065
disabled={tools.draftMenuDisabledReason !== undefined}
10721066
title={tools.draftMenuDisabledReason}
1073-
>
1074-
<SparkleIcon size={13} weight="bold" />
1075-
<span className="truncate leading-none">Draft</span>
1076-
<CaretDownIcon size={11} weight="bold" />
1077-
</button>
1067+
/>
10781068
</DropdownMenuTrigger>
10791069
<DropdownMenuContent align="start" className="w-56">
10801070
<DropdownMenuItem onSelect={tools.openCreateDraft}>

src/components/library-editor.tsx

Lines changed: 23 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { useCallback, useEffect, useRef, useState } from "react";
1+
import { useCallback, useEffect, useState } from "react";
22
import { useAction, useQuery } from "convex/react";
33
import { CheckIcon, CopySimpleIcon, MinusIcon, PlusIcon, SlidersHorizontalIcon, XIcon } from "@phosphor-icons/react";
44
import { toast } from "sonner";
55
import { api } from "../../convex/_generated/api";
66
import { Markdown, type MermaidRepairRequest } from "@/components/markdown";
77
import { Button } from "@/components/ui/button";
8+
import { CopyActionButton } from "@/components/ui/copy-action-button";
89
import { FloatingExpandableToolbar } from "@/components/ui/floating-expandable-toolbar";
910
import { ScrollArea } from "@/components/ui/scroll-area";
1011
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
1112
import { Skeleton } from "@/components/ui/skeleton";
12-
import { useAsyncCallback } from "@/hooks/use-async-callback";
1313
import { useLocalStorageEnum } from "@/hooks/use-persisted-state";
1414
import { toUserErrorMessage } from "@/lib/errors";
1515
import type { ArtifactId } from "@/lib/types";
@@ -78,34 +78,6 @@ export function LibraryEditor({ artifactId, className }: { artifactId: ArtifactI
7878
setToolbarExpanded(false);
7979
}, [artifactId]);
8080

81-
const [copied, setCopied] = useState(false);
82-
const copiedResetTimer = useRef<number | null>(null);
83-
84-
const clearCopiedResetTimer = useCallback(() => {
85-
if (copiedResetTimer.current === null) return;
86-
window.clearTimeout(copiedResetTimer.current);
87-
copiedResetTimer.current = null;
88-
}, []);
89-
90-
useEffect(() => clearCopiedResetTimer, [clearCopiedResetTimer]);
91-
92-
const [, runCopy] = useAsyncCallback(async () => {
93-
if (!artifact) return;
94-
const copySource = selectedVersion !== null && selectedVersion !== artifact.version ? historicalVersion : artifact;
95-
if (!copySource) return;
96-
try {
97-
await navigator.clipboard.writeText(copySource.contentMarkdown);
98-
clearCopiedResetTimer();
99-
setCopied(true);
100-
copiedResetTimer.current = window.setTimeout(() => {
101-
copiedResetTimer.current = null;
102-
setCopied(false);
103-
}, 1600);
104-
} catch {
105-
// Browsers without clipboard API support — leave the affordance idle.
106-
}
107-
});
108-
10981
const [fontSize, setFontSize] = useLocalStorageEnum("systify.library.fontSize", FONT_SIZE_STEPS, DEFAULT_FONT_SIZE);
11082
const handleRepairMermaid = useCallback(
11183
async ({ chart, error }: MermaidRepairRequest) => {
@@ -155,6 +127,7 @@ export function LibraryEditor({ artifactId, className }: { artifactId: ArtifactI
155127
const versionIsLoading = displayedArtifact === undefined;
156128
const versionIsMissing = displayedArtifact === null;
157129
const copyIsDisabled = versionIsLoading || versionIsMissing;
130+
const copyMarkdown = () => displayedArtifact?.contentMarkdown;
158131

159132
return (
160133
<div className={cn("relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", className)}>
@@ -169,8 +142,7 @@ export function LibraryEditor({ artifactId, className }: { artifactId: ArtifactI
169142
fontSize={fontSize}
170143
onFontSizeChange={setFontSize}
171144
copyIsDisabled={copyIsDisabled}
172-
copied={copied}
173-
onCopy={() => void runCopy()}
145+
copyText={copyMarkdown}
174146
/>
175147

176148
<ScrollArea className="min-h-0 flex-1">
@@ -231,8 +203,7 @@ function ReaderToolbar({
231203
fontSize,
232204
onFontSizeChange,
233205
copyIsDisabled,
234-
copied,
235-
onCopy,
206+
copyText,
236207
}: {
237208
expanded: boolean;
238209
onExpandedChange: (next: boolean | ((previous: boolean) => boolean)) => void;
@@ -250,8 +221,7 @@ function ReaderToolbar({
250221
fontSize: FontSize;
251222
onFontSizeChange: (next: FontSize) => void;
252223
copyIsDisabled: boolean;
253-
copied: boolean;
254-
onCopy: () => void;
224+
copyText: () => string | null | undefined;
255225
}) {
256226
return (
257227
<FloatingExpandableToolbar
@@ -262,6 +232,7 @@ function ReaderToolbar({
262232
collapseLabel="Collapse reader tools"
263233
expandIcon={<SlidersHorizontalIcon size={13} weight="bold" />}
264234
collapseIcon={<XIcon size={12} weight="bold" />}
235+
className="absolute top-3 right-4 z-10 max-w-[calc(100%-2rem)]"
265236
data-testid="reader-toolbar"
266237
>
267238
<ArtifactVersionSelect
@@ -272,19 +243,21 @@ function ReaderToolbar({
272243
/>
273244
{showFontSizeControl ? <FontSizeControl value={fontSize} onChange={onFontSizeChange} /> : null}
274245
<ReaderToolbarSeparator />
275-
<Button
276-
type="button"
277-
variant="ghost"
246+
<CopyActionButton
247+
text={copyText}
248+
idleLabel="Copy"
249+
copiedLabel="Copied"
250+
idleAriaLabel="Copy markdown"
251+
showLabel
252+
tooltip={false}
253+
copyIcon={<CopySimpleIcon size={13} weight="bold" />}
254+
copiedIcon={<CheckIcon size={13} weight="bold" />}
255+
resetAfterMs={1600}
278256
size="sm"
279-
className="h-6 w-14 shrink-0 gap-1 px-1.5 text-[11px] active:scale-100"
280-
onClick={onCopy}
257+
className="h-6 w-14 shrink-0 gap-1 px-1.5 text-[11px]"
281258
disabled={copyIsDisabled}
282-
aria-label="Copy markdown"
283259
tabIndex={expanded ? undefined : -1}
284-
>
285-
{copied ? <CheckIcon size={13} weight="bold" /> : <CopySimpleIcon size={13} weight="bold" />}
286-
{copied ? "Copied" : "Copy"}
287-
</Button>
260+
/>
288261
<ReaderToolbarSeparator className="mr-1" />
289262
</FloatingExpandableToolbar>
290263
);
@@ -380,7 +353,8 @@ function FontSizeControl({ value, onChange }: { value: FontSize; onChange: (next
380353
type="button"
381354
variant="ghost"
382355
size="sm"
383-
className="h-6 w-6 px-0 active:scale-100"
356+
pressEffect="none"
357+
className="h-6 w-6 px-0"
384358
disabled={atMin}
385359
onClick={() => stepTo(-1)}
386360
aria-label="Decrease text size"
@@ -391,7 +365,8 @@ function FontSizeControl({ value, onChange }: { value: FontSize; onChange: (next
391365
type="button"
392366
variant="ghost"
393367
size="sm"
394-
className="h-6 w-6 px-0 active:scale-100"
368+
pressEffect="none"
369+
className="h-6 w-6 px-0"
395370
disabled={atMax}
396371
onClick={() => stepTo(1)}
397372
aria-label="Increase text size"

src/components/mermaid-diagram.tsx

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { createPortal } from "react-dom";
1717
import { Button } from "@/components/ui/button";
1818
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
19+
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
1920
import { cn } from "@/lib/utils";
2021

2122
export interface MermaidRepairRequest {
@@ -305,9 +306,9 @@ function SvgViewer({
305306
</div>
306307
<div className="flex shrink-0 items-center gap-2">
307308
<ViewerToolbar actionsRef={fullscreenActionsRef} />
308-
<ToolbarButton label="Close diagram" onClick={() => setIsFullscreen(false)}>
309+
<TooltipIconButton label="Close diagram" onClick={() => setIsFullscreen(false)} className="size-8 p-0">
309310
<X className="size-4" />
310-
</ToolbarButton>
311+
</TooltipIconButton>
311312
</div>
312313
</div>
313314
<ZoomableViewport actionsRef={fullscreenActionsRef} className="min-h-0 flex-1" interactive wheelZoomEnabled>
@@ -327,9 +328,13 @@ function SvgViewer({
327328
<>
328329
<div className="border-b border-border bg-background/70 px-3 py-2">
329330
<div className="flex items-center justify-end">
330-
<ToolbarButton label="View diagram fullscreen" onClick={() => setIsFullscreen(true)}>
331+
<TooltipIconButton
332+
label="View diagram fullscreen"
333+
onClick={() => setIsFullscreen(true)}
334+
className="size-8 p-0"
335+
>
331336
<Maximize2 className="size-4" />
332-
</ToolbarButton>
337+
</TooltipIconButton>
333338
</div>
334339
</div>
335340
<ZoomableViewport actionsRef={inlineActionsRef} className="min-h-48" interactive={false} wheelZoomEnabled={false}>
@@ -343,34 +348,19 @@ function SvgViewer({
343348
function ViewerToolbar({ actionsRef }: { actionsRef: RefObject<ZoomActions | null> }) {
344349
return (
345350
<>
346-
<ToolbarButton label="Zoom in" onClick={() => actionsRef.current?.zoomIn()}>
351+
<TooltipIconButton label="Zoom in" onClick={() => actionsRef.current?.zoomIn()} className="size-8 p-0">
347352
<ZoomIn className="size-4" />
348-
</ToolbarButton>
349-
<ToolbarButton label="Zoom out" onClick={() => actionsRef.current?.zoomOut()}>
353+
</TooltipIconButton>
354+
<TooltipIconButton label="Zoom out" onClick={() => actionsRef.current?.zoomOut()} className="size-8 p-0">
350355
<ZoomOut className="size-4" />
351-
</ToolbarButton>
352-
<ToolbarButton label="Reset diagram view" onClick={() => actionsRef.current?.reset()}>
356+
</TooltipIconButton>
357+
<TooltipIconButton label="Reset diagram view" onClick={() => actionsRef.current?.reset()} className="size-8 p-0">
353358
<RotateCcw className="size-4" />
354-
</ToolbarButton>
359+
</TooltipIconButton>
355360
</>
356361
);
357362
}
358363

359-
function ToolbarButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
360-
return (
361-
<TooltipProvider delayDuration={150}>
362-
<Tooltip>
363-
<TooltipTrigger asChild>
364-
<Button aria-label={label} className="size-8 p-0" onClick={onClick} size="icon" type="button" variant="ghost">
365-
{children}
366-
</Button>
367-
</TooltipTrigger>
368-
<TooltipContent>{label}</TooltipContent>
369-
</Tooltip>
370-
</TooltipProvider>
371-
);
372-
}
373-
374364
function ZoomableViewport({
375365
children,
376366
actionsRef,

0 commit comments

Comments
 (0)