From 2a08d6863124a96fcfa170db5cab7547aaffdfa8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 17:13:15 -0700 Subject: [PATCH] fix: clear transient ui feedback timers (#3385) --- src/renderer/src/components/QuickOpen.tsx | 12 +++++++++-- .../components/editor/CodeBlockCopyButton.tsx | 11 ++++++++-- .../editor/RichMarkdownCodeBlock.tsx | 11 ++++++++-- .../right-sidebar/checks-panel-content.tsx | 20 ++++++++++++++++--- .../components/settings/McpConfigSection.tsx | 9 ++++++++- .../src/components/settings/MobilePane.tsx | 9 ++++++++- .../components/settings/RepositoryPane.tsx | 11 ++++++++-- .../src/components/stats/ShareUsageButton.tsx | 11 ++++++++-- 8 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ba0bf4f3060..7180862bc61 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { AlertTriangle, Check, Copy } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' @@ -66,6 +66,15 @@ function InstallRgGuidance({ guidance?: string | null }): React.JSX.Element { const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!copied) { + return + } + const timeout = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timeout) + }, [copied]) + const handleCopy = useCallback(() => { if (!command) { return @@ -78,7 +87,6 @@ function InstallRgGuidance({ .writeClipboardText(command) .then(() => { setCopied(true) - setTimeout(() => setCopied(false), 1500) }) .catch(() => { /* best-effort */ diff --git a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx index c65e1ddafcd..ffda79aab05 100644 --- a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx +++ b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { Copy, Check } from 'lucide-react' type CodeBlockCopyButtonProps = React.HTMLAttributes & { @@ -11,6 +11,14 @@ export default function CodeBlockCopyButton({ }: CodeBlockCopyButtonProps): React.JSX.Element { const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) { + return + } + const timeout = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timeout) + }, [copied]) + const handleCopy = useCallback(() => { // Extract the text content from the nested element rendered by // react-markdown inside
. We walk the React children tree to grab the
@@ -29,7 +37,6 @@ export default function CodeBlockCopyButton({
       .writeClipboardText(text)
       .then(() => {
         setCopied(true)
-        setTimeout(() => setCopied(false), 1500)
       })
       .catch(() => {
         // Silently swallow clipboard write failures (e.g. permission denied).
diff --git a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
index 1063e4a00e8..914fddd3d06 100644
--- a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
 import type { NodeViewProps } from '@tiptap/react'
 import { Copy, Check } from 'lucide-react'
@@ -51,6 +51,14 @@ export function RichMarkdownCodeBlock({
 
   const isMermaid = language === 'mermaid'
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const onChange = useCallback(
     (e: React.ChangeEvent) => {
       updateAttributes({ language: e.target.value })
@@ -66,7 +74,6 @@ export function RichMarkdownCodeBlock({
         .writeClipboardText(text)
         .then(() => {
           setCopied(true)
-          setTimeout(() => setCopied(false), 1500)
         })
         .catch(() => {
           // Silently swallow clipboard write failures (e.g. permission denied).
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index a1586cf5700..24bee80a4f1 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -1,6 +1,6 @@
 /* eslint-disable max-lines -- Why: co-locating all checks-panel sub-components (checks list,
 conflict sections, threaded PR comments) keeps the shared icon/color maps in one place. */
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import {
   CircleCheck,
   CircleX,
@@ -357,12 +357,19 @@ export function ChecksList({
 function CopyButton({ text }: { text: string }): React.JSX.Element {
   const [copied, setCopied] = useState(false)
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const handleCopy = useCallback(
     (e: React.MouseEvent) => {
       e.stopPropagation()
       void window.api.ui.writeClipboardText(text).then(() => {
         setCopied(true)
-        setTimeout(() => setCopied(false), 1500)
       })
     },
     [text]
@@ -390,12 +397,19 @@ function ResolveButton({
 }): React.JSX.Element {
   const [loading, setLoading] = useState(false)
 
+  useEffect(() => {
+    if (!loading) {
+      return
+    }
+    const timeout = window.setTimeout(() => setLoading(false), 300)
+    return () => window.clearTimeout(timeout)
+  }, [loading])
+
   const handleClick = useCallback(
     (e: React.MouseEvent) => {
       e.stopPropagation()
       setLoading(true)
       onResolve(threadId, !isResolved)
-      setTimeout(() => setLoading(false), 300)
     },
     [threadId, isResolved, onResolve]
   )
diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx
index 6506e332dff..da082c6e9a3 100644
--- a/src/renderer/src/components/settings/McpConfigSection.tsx
+++ b/src/renderer/src/components/settings/McpConfigSection.tsx
@@ -215,6 +215,14 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
     void loadConfigs()
   }, [loadConfigs])
 
+  useEffect(() => {
+    if (!createConfirm) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCreateConfirm(false), 3000)
+    return () => window.clearTimeout(timeout)
+  }, [createConfirm])
+
   const handleOpen = (config: LoadedMcpConfigInspection): void => {
     setActiveWorktree(targetWorktreeId)
     const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId)
@@ -234,7 +242,6 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
   const handleCreateStarter = async (): Promise => {
     if (!createConfirm) {
       setCreateConfirm(true)
-      window.setTimeout(() => setCreateConfirm(false), 3000)
       return
     }
 
diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx
index 6fe60926ebd..db297eccf74 100644
--- a/src/renderer/src/components/settings/MobilePane.tsx
+++ b/src/renderer/src/components/settings/MobilePane.tsx
@@ -53,6 +53,14 @@ export function MobilePane(): React.JSX.Element {
   const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
   const [codeCopied, setCodeCopied] = useState(false)
 
+  useEffect(() => {
+    if (!codeCopied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCodeCopied(false), 2000)
+    return () => window.clearTimeout(timeout)
+  }, [codeCopied])
+
   const loadDevices = useCallback(async () => {
     try {
       const result = await window.api.mobile.listDevices()
@@ -141,7 +149,6 @@ export function MobilePane(): React.JSX.Element {
       // IPC clipboard which the rest of the app uses everywhere.
       await window.api.ui.writeClipboardText(pairingUrl)
       setCodeCopied(true)
-      setTimeout(() => setCodeCopied(false), 2000)
     } catch {
       toast.error('Failed to copy pairing code')
     }
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
index 8bdb528f5d5..2bf4a777cc0 100644
--- a/src/renderer/src/components/settings/RepositoryPane.tsx
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
 import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
 import { Button } from '../ui/button'
@@ -59,6 +59,14 @@ export function RepositoryPane({
   // request to hide every child row that does not repeat the project name.
   const forceFullPaneForRepoMatch = matchesRepositoryIdentitySearch(searchQuery, repo)
 
+  useEffect(() => {
+    if (!copiedTemplate) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopiedTemplate(false), 1500)
+    return () => window.clearTimeout(timeout)
+  }, [copiedTemplate])
+
   const handleRemoveProject = (repoId: string) => {
     if (confirmingRemove === repoId) {
       removeProject(repoId)
@@ -84,7 +92,6 @@ export function RepositoryPane({
   archive: |
     echo "Cleaning up before archive"`)
     setCopiedTemplate(true)
-    window.setTimeout(() => setCopiedTemplate(false), 1500)
   }
 
   const allEntries = getRepositoryPaneSearchEntries(repo)
diff --git a/src/renderer/src/components/stats/ShareUsageButton.tsx b/src/renderer/src/components/stats/ShareUsageButton.tsx
index 40d4093aa48..7e85055f39f 100644
--- a/src/renderer/src/components/stats/ShareUsageButton.tsx
+++ b/src/renderer/src/components/stats/ShareUsageButton.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
 import { toPng } from 'html-to-image'
 import { Check, Copy, Share2 } from 'lucide-react'
 import { Button } from '../ui/button'
@@ -21,6 +21,14 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
   const [copied, setCopied] = useState(false)
   const [capturing, setCapturing] = useState(false)
 
+  useEffect(() => {
+    if (!copied) {
+      return
+    }
+    const timeout = window.setTimeout(() => setCopied(false), 2000)
+    return () => window.clearTimeout(timeout)
+  }, [copied])
+
   const captureToClipboard = useCallback(async () => {
     if (!cardRef.current || capturing) {
       return
@@ -42,7 +50,6 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
     const ok = await captureToClipboard()
     if (ok) {
       setCopied(true)
-      setTimeout(() => setCopied(false), 2000)
     }
   }, [captureToClipboard])