diff --git a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx index b15899172..4315059b3 100644 --- a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx +++ b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx @@ -15,6 +15,7 @@ import { type ProjectRepairReport, type RepairStepResult, } from "../../../shared/types/recovery"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useAppStore } from "../../state/appStore"; /** @@ -149,7 +150,7 @@ export function ProjectRecoveryScreen() { const [report, setReport] = useState(null); const [revealed, setRevealed] = useState(0); const [repairError, setRepairError] = useState(null); - const [copied, setCopied] = useState(false); + const { copy, copied } = useCopyToClipboard(); const reopenStartedRef = useRef(false); // Diagnose on mount / when the failed root changes. On failure fall back to @@ -249,16 +250,6 @@ export function ProjectRecoveryScreen() { .filter((line): line is string => Boolean(line && line.trim())) .join("\n"); - const copyTechnical = () => { - void navigator.clipboard?.writeText(technicalText).then( - () => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - }, - () => {}, - ); - }; - const visibleSteps = report ? report.steps.slice(0, phase === "repairing" ? revealed : undefined) : []; const isSuccess = phase === "success"; @@ -372,7 +363,7 @@ export function ProjectRecoveryScreen() { type="button" onClick={(e) => { e.preventDefault(); - copyTechnical(); + void copy(technicalText); }} className="inline-flex items-center gap-1 text-[11px] text-fg/45 transition-colors hover:text-fg/75" > diff --git a/apps/desktop/src/renderer/components/automations/AdeActionEditor.tsx b/apps/desktop/src/renderer/components/automations/AdeActionEditor.tsx index 7a24920f1..a88f5fc38 100644 --- a/apps/desktop/src/renderer/components/automations/AdeActionEditor.tsx +++ b/apps/desktop/src/renderer/components/automations/AdeActionEditor.tsx @@ -8,6 +8,7 @@ import { X, } from "@phosphor-icons/react"; import { useClickOutside } from "../../hooks/useClickOutside"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../ui/cn"; import { textareaCls } from "./designTokens"; import { INPUT_CLS, INPUT_STYLE } from "./shared"; @@ -537,16 +538,9 @@ function ParamField({ } function PlaceholderRow() { - const [copied, setCopied] = useState(null); - const copy = async (placeholder: string) => { - try { - await navigator.clipboard.writeText(placeholder); - setCopied(placeholder); - window.setTimeout(() => setCopied((current) => (current === placeholder ? null : current)), 1200); - } catch { - // Clipboard may be blocked (e.g., insecure context) — silently no-op. - } - }; + // Keyed: only the chip that was clicked shows the confirmation. Clipboard may + // be blocked (e.g., insecure context) — the hook then leaves the chip idle. + const { copy, isCopied } = useCopyToClipboard({ timeout: 1200 }); return (
@@ -559,14 +553,14 @@ function PlaceholderRow() { type="button" className={cn( "rounded border px-1.5 py-0.5 text-[10px] transition-colors", - copied === placeholder.value + isCopied(placeholder.value) ? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200" : "border-white/[0.08] bg-white/[0.03] text-muted-fg/70 hover:border-accent/40 hover:text-fg", )} - onClick={() => void copy(placeholder.value)} + onClick={() => void copy(placeholder.value, placeholder.value)} title={placeholder.value} > - {copied === placeholder.value ? "✓ copied" : placeholder.label} + {isCopied(placeholder.value) ? "✓ copied" : placeholder.label} ))} diff --git a/apps/desktop/src/renderer/components/automations/settings/IngressStatusStrip.tsx b/apps/desktop/src/renderer/components/automations/settings/IngressStatusStrip.tsx index eeba09ffa..a7c5897a4 100644 --- a/apps/desktop/src/renderer/components/automations/settings/IngressStatusStrip.tsx +++ b/apps/desktop/src/renderer/components/automations/settings/IngressStatusStrip.tsx @@ -6,6 +6,7 @@ import { Button } from "../../ui/Button"; import { cn } from "../../ui/cn"; import { formatDate } from "../../../lib/format"; import { linearIngressApi } from "../linearIngressApi"; +import { useAsyncAction } from "../../../hooks/useAsyncAction"; function Dot({ tone }: { tone: "ok" | "warn" | "off" }) { return ( @@ -32,7 +33,6 @@ function githubSummary(status: AutomationIngressStatus | null): { tone: "ok" | " export function IngressStatusStrip({ ingressStatus }: { ingressStatus: AutomationIngressStatus | null }) { const [dismissed, setDismissed] = useState(false); const [linear, setLinear] = useState(null); - const [busy, setBusy] = useState(false); const api = linearIngressApi(); const refreshLinear = useCallback(async () => { @@ -48,24 +48,23 @@ export function IngressStatusStrip({ ingressStatus }: { ingressStatus: Automatio void refreshLinear(); }, [refreshLinear]); + const { run: setupLinear, pending: busy } = useAsyncAction({ + action: async () => { + if (!api?.setup) return; + try { + await api.setup(); + } finally { + // The service records lastError; the refresh surfaces it either way. + await refreshLinear().catch(() => {}); + } + }, + }); + if (dismissed) return null; const gh = githubSummary(ingressStatus); const linearAvailable = Boolean(api?.getStatus) && linear != null && linear.state !== "disabled"; - const setupLinear = async () => { - if (!api?.setup) return; - setBusy(true); - try { - await api.setup(); - } catch { - // The service records lastError; the refresh below surfaces it. - } finally { - await refreshLinear().catch(() => {}); - setBusy(false); - } - }; - return (
@@ -99,7 +98,7 @@ export function IngressStatusStrip({ ingressStatus }: { ingressStatus: Automatio // a linear.* rule is enabled — no manual connect step. Via ADE app ) : ( - )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index de70e68f2..b817a4fdc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1215,8 +1215,37 @@ describe("AgentChatMessageList transcript rendering", () => { fireEvent.click(screen.getByRole("button", { name: "Copy message" })); + // The invariant is that the hidden prompt never reaches the clipboard. The + // shared copy hook no-ops on empty text rather than writing "", so the + // clipboard is left untouched instead of being wiped. Asserting "not called + // at all" is the exact new contract and is not vacuous: the sibling test + // below proves the same button does reach `writeText` for a visible message. + // The early return happens before any await, so no settling wait is needed. + expect(writeText).not.toHaveBeenCalled(); + }); + + it("copies the visible message text when it is not hidden", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "user_message", + text: "A perfectly ordinary message.", + }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Copy message" })); + await waitFor(() => { - expect(writeText).toHaveBeenCalledWith(""); + expect(writeText).toHaveBeenCalledWith("A perfectly ordinary message."); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 0aefeb355..549ab5d5e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -56,6 +56,7 @@ import type { } from "../../../shared/types"; import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { formatTime } from "../../lib/format"; import { navigateToAppTarget, openUrlInAdeBrowser } from "../../lib/openExternal"; import { isPathEqualOrDescendant, isWindowsAbsolutePath, normalizePath } from "../../lib/pathUtils"; @@ -972,44 +973,7 @@ function MessageCopyButton({ label?: string; title?: string; }) { - const [copied, setCopied] = useState(false); - const mountedRef = useRef(true); - const resetTimerRef = useRef(null); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (resetTimerRef.current !== null) { - clearTimeout(resetTimerRef.current); - } - }; - }, []); - - const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; - void navigator.clipboard.writeText(value) - .then(() => { - if (!mountedRef.current) return; - if (resetTimerRef.current !== null) { - clearTimeout(resetTimerRef.current); - } - setCopied(true); - resetTimerRef.current = window.setTimeout(() => { - resetTimerRef.current = null; - if (!mountedRef.current) return; - setCopied(false); - }, 1_500); - }) - .catch(() => { - if (!mountedRef.current) return; - if (resetTimerRef.current !== null) { - clearTimeout(resetTimerRef.current); - resetTimerRef.current = null; - } - setCopied(false); - }); - }, [value]); + const { copy, copied } = useCopyToClipboard(); return (
diff --git a/apps/desktop/src/renderer/components/prs/detail/PrChecksTab.tsx b/apps/desktop/src/renderer/components/prs/detail/PrChecksTab.tsx index 6f7d02fc1..55c46850f 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrChecksTab.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrChecksTab.tsx @@ -29,6 +29,7 @@ import type { } from "../../../../shared/types"; import { COLORS, MONO_FONT, RADII, SANS_FONT, SPACING, floatingPane } from "../../lanes/laneDesignTokens"; import { formatDurationMs } from "../../../lib/format"; +import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { PrCommandPalettes, type PaletteCheck } from "../shared/PrCommandPalettes"; import { PrCheckLogDrawer, @@ -509,7 +510,7 @@ export function PrChecksTab({ const [excerpt, setExcerpt] = React.useState(null); const [logLoading, setLogLoading] = React.useState(false); const [logError, setLogError] = React.useState(null); - const [copied, setCopied] = React.useState(false); + const { copy, copied, reset: resetCopied } = useCopyToClipboard({ timeout: 1600 }); const [selectedAttempt, setSelectedAttempt] = React.useState(null); const [paletteOpen, setPaletteOpen] = React.useState(false); const [focusedFailureIdx, setFocusedFailureIdx] = React.useState(0); @@ -587,8 +588,8 @@ export function PrChecksTab({ // ---- log drawer ------------------------------------------------------- const openDrawerFor = React.useCallback((node: PrWorkflowGraphNode) => { setDrawer({ node, jobId: resolveLogJobId(node) }); - setCopied(false); - }, []); + resetCopied(); + }, [resetCopied]); React.useEffect(() => { if (!drawer) { @@ -734,15 +735,8 @@ export function PrChecksTab({ const handleCopy = React.useCallback(() => { if (!excerpt) return; const markdown = buildLogExcerptMarkdown({ excerpt, elapsedLabel: drawerElapsed, pr }); - const writeText = navigator.clipboard?.writeText; - if (!writeText) return; - void writeText.call(navigator.clipboard, markdown) - .then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); - }) - .catch(() => {}); - }, [excerpt, drawerElapsed, pr]); + void copy(markdown); + }, [copy, excerpt, drawerElapsed, pr]); const rerunFailedVisible = Boolean(onRerunChecks) && buckets.failed > 0; const drawerRerun = React.useMemo(() => { diff --git a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx index a214948c9..546df692e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx @@ -8,6 +8,7 @@ import { import { CheckCircle } from "@phosphor-icons/react"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, primaryButton } from "../lanes/laneDesignTokens"; import { extractError } from "../../lib/format"; +import { useAsyncAction } from "../../hooks/useAsyncAction"; import type { RemoteRuntimeParsedPairingInput } from "../../../shared/types"; const fieldStyle: CSSProperties = { @@ -62,7 +63,6 @@ export function PairMachineForm({ const [parseError, setParseError] = useState(null); const [parsing, setParsing] = useState(false); const [pin, setPin] = useState(""); - const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const deviceName = defaultDeviceName.trim() || "This Mac"; @@ -100,32 +100,33 @@ export function PairMachineForm({ }, [trimmedInput]); const pinValid = /^\d{6}$/.test(pin.trim()); - const canSubmit = useMemo( - () => Boolean(parsed) && pinValid && !busy && !submitting, - [parsed, pinValid, busy, submitting], - ); - async function handleSubmit(event: FormEvent) { - event.preventDefault(); - if (!canSubmit || !trimmedInput) return; - setSubmitting(true); - setError(null); - try { + const { run: submitPairing, pending: submitting } = useAsyncAction({ + action: async () => { + setError(null); const { targetId } = await window.ade.remoteRuntime.pairWithMachine({ input: trimmedInput, pin: pin.trim(), deviceName, }); await onPaired(targetId); - } catch (err) { - setError(friendlyPairError(err)); - } finally { - setSubmitting(false); - } + }, + onError: (err) => setError(friendlyPairError(err)), + }); + + const canSubmit = useMemo( + () => Boolean(parsed) && pinValid && !busy && !submitting, + [parsed, pinValid, busy, submitting], + ); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!canSubmit || !trimmedInput) return; + submitPairing(); } return ( -
void handleSubmit(event)} style={{ display: "grid", gap: 12 }}> +
You haven't connected to this Mac before. Enter the pairing code shown in ADE on that Mac.
diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx index ec1fa9100..95cc43a79 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteErrorCard.tsx @@ -1,6 +1,7 @@ import { useState, type CSSProperties } from "react"; import { ArrowClockwise, CaretDown, CaretRight, Warning } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../lanes/laneDesignTokens"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import type { MachineErrorCard } from "./remoteMachineModel"; const detailBoxStyle: CSSProperties = { @@ -32,18 +33,17 @@ type RemoteErrorCardProps = { */ export function RemoteErrorCard({ card, onRetry, retrying }: RemoteErrorCardProps) { const [detailOpen, setDetailOpen] = useState(false); - const [copied, setCopied] = useState(false); - - const copyDetail = async () => { - if (!card.detail) return; - try { - await window.ade?.app?.writeClipboardText?.(card.detail); - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard may be unavailable; the detail stays visible to copy manually. - } - }; + // Writes through the main process rather than `navigator.clipboard`, which + // the remote pane cannot rely on. Clipboard may be unavailable; on failure + // the detail stays visible to copy manually. + const { copy, copied } = useCopyToClipboard({ + write: async (text) => { + const writeText = window.ade?.app?.writeClipboardText; + if (!writeText) return false; + await writeText(text); + return true; + }, + }); return (
{card.detail}