Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { chatServiceTrpc } from "@superset/chat/client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { electronTrpcClient } from "renderer/lib/trpc-client";

Expand Down Expand Up @@ -145,15 +146,12 @@ export function useAnthropicOAuth({
}
}, [clearAutoSubmitTimeout, openExternalUrl, startAnthropicOAuthMutation]);

const copyOAuthUrl = useCallback(async () => {
const { copyToClipboard } = useCopyToClipboard();
const copyOAuthUrl = useCallback(() => {
if (!oauthUrl) return;
try {
await navigator.clipboard.writeText(oauthUrl);
setOauthError(null);
} catch (error) {
setOauthError(getErrorMessage(error, "Failed to copy URL"));
}
}, [oauthUrl]);
copyToClipboard(oauthUrl);
setOauthError(null);
}, [oauthUrl, copyToClipboard]);

const submitAnthropicOAuthCode = useCallback(
async (rawCode: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { chatServiceTrpc } from "@superset/chat/client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { electronTrpcClient } from "renderer/lib/trpc-client";

Expand Down Expand Up @@ -99,15 +100,12 @@ export function useOpenAIOAuth({
}
}, [startOpenAIOAuthMutation]);

const copyOAuthUrl = useCallback(async () => {
const { copyToClipboard } = useCopyToClipboard();
const copyOAuthUrl = useCallback(() => {
if (!oauthUrl) return;
try {
await navigator.clipboard.writeText(oauthUrl);
setOauthError(null);
} catch (error) {
setOauthError(getErrorMessage(error, "Failed to copy URL"));
}
}, [oauthUrl]);
copyToClipboard(oauthUrl);
setOauthError(null);
}, [oauthUrl, copyToClipboard]);
Comment on lines +104 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t clear oauthError before copy success is known.

setOauthError(null) runs regardless of copy outcome. This can suppress a useful error state when copying fails.

Proposed fix
-const copyOAuthUrl = useCallback(() => {
+const copyOAuthUrl = useCallback(async () => {
 	if (!oauthUrl) return;
-	copyToClipboard(oauthUrl);
-	setOauthError(null);
+	try {
+		await copyToClipboard(oauthUrl);
+		setOauthError(null);
+	} catch (error) {
+		setOauthError(getErrorMessage(error, "Failed to copy OAuth URL"));
+	}
 }, [oauthUrl, copyToClipboard]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts`
around lines 104 - 108, In useOpenAIOAuth, the copyOAuthUrl callback clears
oauthError unconditionally which hides errors if copying fails; modify
copyOAuthUrl to attempt copyToClipboard(oauthUrl) and only call
setOauthError(null) when that copy succeeds (handle promise/return value from
copyToClipboard), otherwise preserve or set an appropriate error via
setOauthError; update the callback to rely on the copyToClipboard result and
keep references to copyOAuthUrl, copyToClipboard, setOauthError, and oauthUrl so
the error state is only cleared on successful copy.


const syncOpenAIAuthUi = useCallback(
async (action: "complete" | "disconnect") => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "react";
import { useState } from "react";
import { LuCopy } from "react-icons/lu";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";

function getModifierKeyLabel() {
const isMac = navigator.platform.toLowerCase().includes("mac");
Expand All @@ -28,55 +29,10 @@ export function SelectionContextMenu<T extends HTMLElement>({
children,
selectAllContainerRef,
}: SelectionContextMenuProps<T>) {
const { copyToClipboard } = useCopyToClipboard();
const [selectionText, setSelectionText] = useState("");
const [linkHref, setLinkHref] = useState<string | null>(null);

const copyTextToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall through to legacy copy method.
}

// Legacy fallback: `execCommand("copy")` copies from the currently-selected input/textarea.
// Snapshot the current selection ranges so we can restore the user's selection after copying.
const selection = document.getSelection();
const savedRanges =
selection?.rangeCount && selection.rangeCount > 0
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange(),
)
: [];

const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);

textarea.select();
textarea.setSelectionRange(0, textarea.value.length);

try {
document.execCommand("copy");
} catch {
// Ignore; clipboard access may be restricted.
} finally {
textarea.remove();
}

if (selection) {
selection.removeAllRanges();
for (const range of savedRanges) {
selection.addRange(range);
}
}
};

const handleOpenChange = (open: boolean) => {
if (!open) {
setLinkHref(null);
Expand All @@ -103,12 +59,12 @@ export function SelectionContextMenu<T extends HTMLElement>({
const text = selection?.toString() || selectionText;
if (!text) return;

await copyTextToClipboard(text);
copyToClipboard(text);
};

const handleCopyLinkAddress = async () => {
if (!linkHref) return;
await copyTextToClipboard(linkHref);
copyToClipboard(linkHref);
};

const handleSelectAll = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,56 @@ function ListDropdown({ editor }: { editor: Editor }) {

export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) {
const prevent = (e: React.MouseEvent) => e.preventDefault();
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const linkInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (showLinkInput) {
linkInputRef.current?.focus();
}
}, [showLinkInput]);

const applyLink = () => {
const url = linkUrl.trim();
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
setShowLinkInput(false);
setLinkUrl("");
};

const cancelLink = () => {
setShowLinkInput(false);
setLinkUrl("");
editor.chain().focus().run();
};

if (showLinkInput) {
return (
<div className="flex items-center gap-1 bg-popover border rounded-lg shadow-md px-1.5 py-0.5">
<HiOutlineLink className="size-3.5 text-muted-foreground shrink-0" />
<input
ref={linkInputRef}
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
} else if (e.key === "Escape") {
e.preventDefault();
cancelLink();
}
}}
onBlur={cancelLink}
placeholder="Enter URL..."
className="bg-transparent text-sm outline-none w-48 text-foreground placeholder:text-muted-foreground"
/>
</div>
);
}

return (
<div className="flex items-center gap-0.5 bg-popover border rounded-lg shadow-md px-1 py-0.5">
Expand Down Expand Up @@ -339,10 +389,8 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
} else {
const url = window.prompt("Enter URL:");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
setShowLinkInput(true);
setLinkUrl("");
}
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { useState } from "react";
import { HiCheck, HiChevronDown, HiOutlineClipboard } from "react-icons/hi2";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import {
FILE_VIEW_CODE_BLOCK_LANGUAGES,
getCodeBlockLanguageLabel,
Expand All @@ -18,7 +19,6 @@ export function EditableCodeBlockView({
updateAttributes,
extension,
}: NodeViewProps) {
const [copied, setCopied] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);

const attrs = node.attrs as { language?: string };
Expand All @@ -30,17 +30,9 @@ export function EditableCodeBlockView({
currentLanguage,
);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(node.textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error(
"[EditableCodeBlockView] Failed to copy code block:",
error,
);
}
const { copyToClipboard, copied } = useCopyToClipboard();
const handleCopy = () => {
copyToClipboard(node.textContent);
};

const handleLanguageChange = (language: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getAppOption,
OpenInExternalDropdownItems,
} from "renderer/components/OpenInExternalDropdown";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useThemeStore } from "renderer/stores";
import { useHotkeyText } from "renderer/stores/hotkeys";
Expand Down Expand Up @@ -55,7 +56,7 @@ export function OpenInButton({
}
},
});
const copyPath = electronTrpc.external.copyPath.useMutation();
const { copyToClipboard } = useCopyToClipboard();

const currentApp = defaultApp ? (getAppOption(defaultApp) ?? null) : null;

Expand All @@ -69,7 +70,7 @@ export function OpenInButton({

const handleCopyPath = () => {
if (!path) return;
copyPath.mutate(path);
copyToClipboard(path);
setIsOpen(false);
};

Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/renderer/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback, useState } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";

/**
* Copy text to clipboard via Electron's native clipboard API (IPC).
*
* Unlike `navigator.clipboard.writeText`, this works regardless of
* document focus — no DOMException when a terminal or webview has focus.
*
* Returns `{ copyToClipboard, copied }` where `copied` is true for
* `timeout` ms after a successful write.
*/
export function useCopyToClipboard(timeout = 2000) {
const { mutateAsync } = electronTrpc.external.copyPath.useMutation();
const [copied, setCopied] = useState(false);

const copyToClipboard = useCallback(
async (text: string) => {
await mutateAsync(text);
setCopied(true);
setTimeout(() => setCopied(false), timeout);
Comment on lines +19 to +21
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Handle mutateAsync failures inside this hook (or make all callers await/catch) to avoid unhandled promise rejections.

(Based on your team's feedback about awaiting/catching rejecting async calls.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/hooks/useCopyToClipboard.ts, line 19:

<comment>Handle `mutateAsync` failures inside this hook (or make all callers await/catch) to avoid unhandled promise rejections.

(Based on your team's feedback about awaiting/catching rejecting async calls.) </comment>

<file context>
@@ -1,13 +1,27 @@
+
+	const copyToClipboard = useCallback(
+		async (text: string) => {
+			await mutateAsync(text);
+			setCopied(true);
+			setTimeout(() => setCopied(false), timeout);
</file context>
Suggested change
await mutateAsync(text);
setCopied(true);
setTimeout(() => setCopied(false), timeout);
try {
await mutateAsync(text);
} catch (error) {
console.warn("[useCopyToClipboard] clipboard write failed", error);
return;
}
setCopied(true);
setTimeout(() => setCopied(false), timeout);
Fix with Cubic

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Consecutive copy actions race because previous reset timers are not cleared, so copied can turn false earlier than the configured timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/hooks/useCopyToClipboard.ts, line 21:

<comment>Consecutive copy actions race because previous reset timers are not cleared, so `copied` can turn false earlier than the configured timeout.</comment>

<file context>
@@ -1,13 +1,27 @@
+		async (text: string) => {
+			await mutateAsync(text);
+			setCopied(true);
+			setTimeout(() => setCopied(false), timeout);
+		},
+		[mutateAsync, timeout],
</file context>
Fix with Cubic

},
[mutateAsync, timeout],
Comment on lines +17 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/desktop/src/renderer/hooks/useCopyToClipboard.ts

Repository: superset-sh/superset

Length of output: 1102


🏁 Script executed:

# Search for usages of useCopyToClipboard hook in the codebase
rg "useCopyToClipboard" --type ts --type tsx -B 2 -A 2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Check if there are any test files for this hook
fd "useCopyToClipboard" --type f

Repository: superset-sh/superset

Length of output: 117


🏁 Script executed:

# Search for usages of useCopyToClipboard hook in the codebase (correcting the type)
rg "useCopyToClipboard" --type ts -B 2 -A 2

Repository: superset-sh/superset

Length of output: 41235


🏁 Script executed:

# Check the trpc mutation to understand what mutateAsync does
rg "copyPath" --type ts -B 3 -A 3

Repository: superset-sh/superset

Length of output: 42055


🏁 Script executed:

# Check how callbacks that return promises are typically handled in this codebase
rg "useCallback.*async" --type ts -A 5 | head -50

Repository: superset-sh/superset

Length of output: 4807


🏁 Script executed:

# Check a few specific call sites to confirm none are awaiting
rg "copyToClipboard\(" apps/desktop/src/renderer -A 1 | head -40

Repository: superset-sh/superset

Length of output: 4450


🏁 Script executed:

# Look for any error boundaries or error handlers around these calls
rg -B 5 "copyToClipboard\(" apps/desktop/src/renderer/hooks/useCopyToClipboard.ts -A 5

Repository: superset-sh/superset

Length of output: 46


Add error handling and timer cleanup to useCopyToClipboard hook.

The hook currently lacks error handling for mutateAsync rejections and doesn't clean up timers on unmount. Since nearly all callers invoke copyToClipboard(text) without await, rejected mutations become unhandled promise rejections. Additionally, multiple rapid calls create overlapping timers that can incorrectly reset state after the intended timeout.

Proposed fix
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { electronTrpc } from "renderer/lib/electron-trpc";
@@
 export function useCopyToClipboard(timeout = 2000) {
 	const { mutateAsync } = electronTrpc.external.copyPath.useMutation();
 	const [copied, setCopied] = useState(false);
+	const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+	useEffect(
+		() => () => {
+			if (timeoutRef.current) {
+				clearTimeout(timeoutRef.current);
+			}
+		},
+		[],
+	);
 
 	const copyToClipboard = useCallback(
 		async (text: string) => {
-			await mutateAsync(text);
-			setCopied(true);
-			setTimeout(() => setCopied(false), timeout);
+			try {
+				await mutateAsync(text);
+				setCopied(true);
+				if (timeoutRef.current) {
+					clearTimeout(timeoutRef.current);
+				}
+				timeoutRef.current = setTimeout(() => setCopied(false), timeout);
+				return true;
+			} catch {
+				return false;
+			}
 		},
 		[mutateAsync, timeout],
 	);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/hooks/useCopyToClipboard.ts` around lines 17 - 23,
The useCopyToClipboard hook's copyToClipboard callback must catch/reject-safe
the mutateAsync call and manage the timeout timer: wrap the await
mutateAsync(text) in try/catch (log or swallow the error) so rejections don't
become unhandled, and track the active timer id in a ref (e.g. timerRef) so you
clearTimeout(timerRef.current) before creating a new timer and assign the id;
also add a useEffect cleanup that clears the timer on unmount. Update references
to copyToClipboard, mutateAsync, setCopied, timeout, and useCallback
accordingly.

);

return { copyToClipboard, copied };
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import type { TaskWithStatus } from "../../../components/TasksView/hooks/useTasksTable";

Expand All @@ -24,22 +25,16 @@ export function TaskActionMenu({ task, onDelete }: TaskActionMenuProps) {
const collections = useCollections();
const [open, setOpen] = useState(false);

const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(task.slug);
setOpen(false);
} catch (error) {
console.error("[TaskActionMenu] Failed to copy task ID:", error);
}
const { copyToClipboard } = useCopyToClipboard();

const handleCopyId = () => {
copyToClipboard(task.slug);
setOpen(false);
};

const handleCopyTitle = async () => {
try {
await navigator.clipboard.writeText(task.title);
setOpen(false);
} catch (error) {
console.error("[TaskActionMenu] Failed to copy task title:", error);
}
const handleCopyTitle = () => {
copyToClipboard(task.title);
setOpen(false);
};

const handleDelete = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { useState } from "react";
import { HiCheck, HiChevronDown, HiOutlineClipboard } from "react-icons/hi2";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import {
COMMON_CODE_BLOCK_LANGUAGES,
getCodeBlockLanguageLabel,
Expand All @@ -18,7 +19,6 @@ export function CodeBlockView({
updateAttributes,
extension,
}: NodeViewProps) {
const [copied, setCopied] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);

const attrs = node.attrs as { language?: string };
Expand All @@ -30,10 +30,9 @@ export function CodeBlockView({
currentLanguage,
);

const handleCopy = async () => {
await navigator.clipboard.writeText(node.textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const { copyToClipboard, copied } = useCopyToClipboard();
const handleCopy = () => {
copyToClipboard(node.textContent);
};

const handleLanguageChange = (language: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,8 @@ describe("Task detail action menu", () => {
expect(source).toContain(
'console.error("[TaskActionMenu] Failed to delete task:", error)',
);
expect(source).toContain("await navigator.clipboard.writeText(task.slug)");
expect(source).toContain("await navigator.clipboard.writeText(task.title)");
expect(source).toContain(
'console.error("[TaskActionMenu] Failed to copy task ID:", error)',
);
expect(source).toContain(
'console.error("[TaskActionMenu] Failed to copy task title:", error)',
);
expect(source).toContain("copyToClipboard(task.slug)");
expect(source).toContain("copyToClipboard(task.title)");
expect(source).not.toContain("<span>Status</span>");
expect(source).not.toContain("<span>Assignee</span>");
expect(source).not.toContain("<span>Priority</span>");
Expand Down
Loading
Loading