From c5fc5ff10dcd10dbed64619d85c0e4cf48652227 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:52:58 -0400 Subject: [PATCH 1/2] Shared copy/async hooks + global reduced-motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two shared renderer hooks adapted from the MIT-licensed interior.dev set: - useCopyToClipboard: single owner of the copy/flash-Copied interaction. Holds a monotonic run ticket (a slow write resolving after a newer one cannot clobber it), a mounted guard, and one always-cleared timer. Serves both the unkeyed button and keyed-list shapes, with an optional write transport for panes that must go through the main process. Copying empty text no-ops instead of clearing the clipboard, and a failed write returns false rather than throwing, so callers showing a success message gate on the boolean. - useAsyncAction: run/pending with a synchronous re-entrancy guard so a double-click cannot double-submit, plus an unmount guard. Migrates 14 hand-rolled copy implementations and 3 async-action call sites onto them, replacing 14 bespoke timer/mount-guard pairs. Fixes a real defect this surfaced: SecretsSection reported "Copied ." even when the clipboard write failed, on the one surface where that lie costs most. Adds at the renderer root so every motion element honours the OS reduced-motion setting without per-site opt-in — the renderer had 25 AnimatePresence sites and 12 layoutId sites with no JS reduced-motion handling. usePrefersReducedMotion remains for controls that change what renders rather than how it moves. Co-Authored-By: Claude Opus 5 --- .../components/app/ProjectRecoveryScreen.tsx | 15 +- .../automations/AdeActionEditor.tsx | 20 +- .../settings/IngressStatusStrip.tsx | 29 ++- .../chat/AgentChatMessageList.test.tsx | 31 ++- .../components/chat/AgentChatMessageList.tsx | 42 +--- .../components/chat/AgentCliAuthCard.tsx | 15 +- .../renderer/components/chat/ChatPrPane.tsx | 18 +- .../components/chat/ChatProposedPlanCard.tsx | 15 +- .../components/chat/CodeHighlighter.tsx | 66 +------ .../components/cto/CtoOnboardingCard.tsx | 44 +++-- .../components/onboarding/AiRuntimesBand.tsx | 10 +- .../components/prs/detail/PrChecksTab.tsx | 18 +- .../remoteTargets/PairMachineForm.tsx | 35 ++-- .../remoteTargets/RemoteErrorCard.tsx | 26 +-- .../components/settings/OAuthConnectModal.tsx | 11 +- .../components/settings/ProvidersSection.tsx | 14 +- .../settings/SecretsSection.test.tsx | 20 ++ .../components/settings/SecretsSection.tsx | 63 +++--- .../settings/SyncDevicesSection.tsx | 77 +++----- .../renderer/hooks/useAsyncAction.test.tsx | 148 ++++++++++++++ .../src/renderer/hooks/useAsyncAction.ts | 90 +++++++++ .../hooks/useCopyToClipboard.test.tsx | 185 ++++++++++++++++++ .../src/renderer/hooks/useCopyToClipboard.ts | 184 +++++++++++++++++ apps/desktop/src/renderer/main.tsx | 18 +- docs/ARCHITECTURE.md | 9 + 25 files changed, 857 insertions(+), 346 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/useAsyncAction.test.tsx create mode 100644 apps/desktop/src/renderer/hooks/useAsyncAction.ts create mode 100644 apps/desktop/src/renderer/hooks/useCopyToClipboard.test.tsx create mode 100644 apps/desktop/src/renderer/hooks/useCopyToClipboard.ts 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}