From ff04c1f1802d21c0d6a27c18b14d166e5ad48f08 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 23:54:22 -0700 Subject: [PATCH 01/57] fix: guard async copy feedback timers (#3505) --- src/renderer/src/components/QuickOpen.tsx | 16 +++++++++++++++- .../components/editor/CodeBlockCopyButton.tsx | 16 +++++++++++++++- .../editor/RichMarkdownCodeBlock.tsx | 16 +++++++++++++++- .../right-sidebar/checks-panel-content.tsx | 16 +++++++++++++++- .../src/components/settings/MobilePane.tsx | 14 ++++++++++++++ .../components/settings/RepositoryPane.tsx | 16 +++++++++++++++- .../src/components/stats/ShareUsageButton.tsx | 19 ++++++++++++++++--- 7 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 0a2576f41ff..b18681b9e45 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, useRef, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, Check, Copy } from 'lucide-react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' @@ -67,6 +67,9 @@ function InstallRgGuidance({ }): React.JSX.Element { const [copied, setCopied] = useState(false) const copiedResetTimerRef = useRef(null) + // Why: clipboard IPC can resolve after this guidance unmounts; avoid + // starting a reset timer that will outlive the component. + const isMountedRef = useRef(false) const clearCopiedResetTimer = useCallback((): void => { if (copiedResetTimerRef.current !== null) { @@ -84,6 +87,14 @@ function InstallRgGuidance({ [clearCopiedResetTimer] ) + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + clearCopiedResetTimer() + } + }, [clearCopiedResetTimer]) + const handleCopy = useCallback(() => { if (!command) { return @@ -95,6 +106,9 @@ function InstallRgGuidance({ void window.api.ui .writeClipboardText(command) .then(() => { + if (!isMountedRef.current) { + return + } clearCopiedResetTimer() setCopied(true) copiedResetTimerRef.current = window.setTimeout(() => { diff --git a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx index cc7e79422d6..f521b3013a6 100644 --- a/src/renderer/src/components/editor/CodeBlockCopyButton.tsx +++ b/src/renderer/src/components/editor/CodeBlockCopyButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { Copy, Check } from 'lucide-react' type CodeBlockCopyButtonProps = React.HTMLAttributes & { @@ -11,6 +11,9 @@ export default function CodeBlockCopyButton({ }: CodeBlockCopyButtonProps): React.JSX.Element { const [copied, setCopied] = useState(false) const copiedResetTimerRef = useRef(null) + // Why: clipboard IPC can resolve after this button unmounts; avoid starting + // a reset timer that will outlive the component. + const isMountedRef = useRef(false) const clearCopiedResetTimer = useCallback((): void => { if (copiedResetTimerRef.current !== null) { @@ -28,6 +31,14 @@ export default function CodeBlockCopyButton({ [clearCopiedResetTimer] ) + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + clearCopiedResetTimer() + } + }, [clearCopiedResetTimer]) + 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
@@ -45,6 +56,9 @@ export default function CodeBlockCopyButton({
     void window.api.ui
       .writeClipboardText(text)
       .then(() => {
+        if (!isMountedRef.current) {
+          return
+        }
         clearCopiedResetTimer()
         setCopied(true)
         copiedResetTimerRef.current = window.setTimeout(() => {
diff --git a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
index 163d8437a6c..7d3600c959a 100644
--- a/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownCodeBlock.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
 import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
 import type { NodeViewProps } from '@tiptap/react'
 import { Copy, Check } from 'lucide-react'
@@ -45,6 +45,9 @@ export function RichMarkdownCodeBlock({
   const language = (node.attrs.language as string) || ''
   const [copied, setCopied] = useState(false)
   const copiedResetTimerRef = useRef(null)
+  // Why: clipboard IPC can resolve after the node view unmounts; avoid
+  // starting a reset timer that will outlive the component.
+  const isMountedRef = useRef(false)
   const settings = useAppStore((s) => s.settings)
   const isDark =
     settings?.theme === 'dark' ||
@@ -68,6 +71,14 @@ export function RichMarkdownCodeBlock({
     [clearCopiedResetTimer]
   )
 
+  useEffect(() => {
+    isMountedRef.current = true
+    return () => {
+      isMountedRef.current = false
+      clearCopiedResetTimer()
+    }
+  }, [clearCopiedResetTimer])
+
   const onChange = useCallback(
     (e: React.ChangeEvent) => {
       updateAttributes({ language: e.target.value })
@@ -82,6 +93,9 @@ export function RichMarkdownCodeBlock({
       void window.api.ui
         .writeClipboardText(text)
         .then(() => {
+          if (!isMountedRef.current) {
+            return
+          }
           clearCopiedResetTimer()
           setCopied(true)
           copiedResetTimerRef.current = window.setTimeout(() => {
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 f9eade73ff3..ae1ba8a30d2 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, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
 import {
   CircleCheck,
   CircleX,
@@ -357,6 +357,9 @@ export function ChecksList({
 function CopyButton({ text }: { text: string }): React.JSX.Element {
   const [copied, setCopied] = useState(false)
   const copiedResetTimerRef = useRef(null)
+  // Why: clipboard IPC can resolve after this row action unmounts; avoid
+  // starting a reset timer that will outlive the component.
+  const isMountedRef = useRef(false)
 
   const clearCopiedResetTimer = useCallback((): void => {
     if (copiedResetTimerRef.current !== null) {
@@ -374,10 +377,21 @@ function CopyButton({ text }: { text: string }): React.JSX.Element {
     [clearCopiedResetTimer]
   )
 
+  useEffect(() => {
+    isMountedRef.current = true
+    return () => {
+      isMountedRef.current = false
+      clearCopiedResetTimer()
+    }
+  }, [clearCopiedResetTimer])
+
   const handleCopy = useCallback(
     (e: React.MouseEvent) => {
       e.stopPropagation()
       void window.api.ui.writeClipboardText(text).then(() => {
+        if (!isMountedRef.current) {
+          return
+        }
         clearCopiedResetTimer()
         setCopied(true)
         copiedResetTimerRef.current = window.setTimeout(() => {
diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx
index f6e98c685c5..7a7f7cf4f4b 100644
--- a/src/renderer/src/components/settings/MobilePane.tsx
+++ b/src/renderer/src/components/settings/MobilePane.tsx
@@ -53,6 +53,9 @@ export function MobilePane(): React.JSX.Element {
   const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
   const [codeCopied, setCodeCopied] = useState(false)
   const codeCopiedResetTimerRef = useRef(null)
+  // Why: clipboard IPC can resolve after settings navigation; avoid starting
+  // a reset timer that will outlive this pane.
+  const isMountedRef = useRef(false)
 
   const clearCodeCopiedResetTimer = useCallback((): void => {
     if (codeCopiedResetTimerRef.current !== null) {
@@ -70,6 +73,14 @@ export function MobilePane(): React.JSX.Element {
     [clearCodeCopiedResetTimer]
   )
 
+  useEffect(() => {
+    isMountedRef.current = true
+    return () => {
+      isMountedRef.current = false
+      clearCodeCopiedResetTimer()
+    }
+  }, [clearCodeCopiedResetTimer])
+
   const loadDevices = useCallback(async () => {
     try {
       const result = await window.api.mobile.listDevices()
@@ -158,6 +169,9 @@ export function MobilePane(): React.JSX.Element {
       // (no transient activation, non-secure context). Use the main-process
       // IPC clipboard which the rest of the app uses everywhere.
       await window.api.ui.writeClipboardText(pairingUrl)
+      if (!isMountedRef.current) {
+        return
+      }
       clearCodeCopiedResetTimer()
       setCodeCopied(true)
       codeCopiedResetTimerRef.current = window.setTimeout(() => {
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
index d7d15b07f33..4985d896c97 100644
--- a/src/renderer/src/components/settings/RepositoryPane.tsx
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
 import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
 import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
 import { Button } from '../ui/button'
@@ -56,6 +56,9 @@ export function RepositoryPane({
   const [confirmingRemove, setConfirmingRemove] = useState(null)
   const [copiedTemplate, setCopiedTemplate] = useState(false)
   const copiedTemplateResetTimerRef = useRef(null)
+  // Why: clipboard IPC can resolve after settings navigation; avoid starting
+  // a reset timer that will outlive this pane.
+  const isMountedRef = useRef(false)
   // Why: searching a project name is navigation to that project, not a
   // request to hide every child row that does not repeat the project name.
   const forceFullPaneForRepoMatch = matchesRepositoryIdentitySearch(searchQuery, repo)
@@ -76,6 +79,14 @@ export function RepositoryPane({
     [clearCopiedTemplateResetTimer]
   )
 
+  useEffect(() => {
+    isMountedRef.current = true
+    return () => {
+      isMountedRef.current = false
+      clearCopiedTemplateResetTimer()
+    }
+  }, [clearCopiedTemplateResetTimer])
+
   const handleRemoveProject = (repoId: string) => {
     if (confirmingRemove === repoId) {
       removeProject(repoId)
@@ -100,6 +111,9 @@ export function RepositoryPane({
     pnpm worktree:setup
   archive: |
     echo "Cleaning up before archive"`)
+    if (!isMountedRef.current) {
+      return
+    }
     clearCopiedTemplateResetTimer()
     setCopiedTemplate(true)
     copiedTemplateResetTimerRef.current = window.setTimeout(() => {
diff --git a/src/renderer/src/components/stats/ShareUsageButton.tsx b/src/renderer/src/components/stats/ShareUsageButton.tsx
index 38727ce289f..dc890b4208f 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,9 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
   const [copied, setCopied] = useState(false)
   const [capturing, setCapturing] = useState(false)
   const copiedResetTimerRef = useRef(null)
+  // Why: image capture/clipboard IPC can resolve after dialog teardown; avoid
+  // state writes and reset timers after this control unmounts.
+  const isMountedRef = useRef(false)
 
   const clearCopiedResetTimer = useCallback((): void => {
     if (copiedResetTimerRef.current !== null) {
@@ -38,6 +41,14 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
     [clearCopiedResetTimer]
   )
 
+  useEffect(() => {
+    isMountedRef.current = true
+    return () => {
+      isMountedRef.current = false
+      clearCopiedResetTimer()
+    }
+  }, [clearCopiedResetTimer])
+
   const captureToClipboard = useCallback(async () => {
     if (!cardRef.current || capturing) {
       return
@@ -51,13 +62,15 @@ export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Elemen
       await window.api.ui.writeClipboardImage(dataUrl)
       return true
     } finally {
-      setCapturing(false)
+      if (isMountedRef.current) {
+        setCapturing(false)
+      }
     }
   }, [capturing])
 
   const handleCopy = useCallback(async () => {
     const ok = await captureToClipboard()
-    if (ok) {
+    if (ok && isMountedRef.current) {
       clearCopiedResetTimer()
       setCopied(true)
       copiedResetTimerRef.current = window.setTimeout(() => {

From 6760be4edf761b404c0ee64df931e676b0894820 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 29 May 2026 23:59:15 -0700
Subject: [PATCH 02/57] fix: clear remote file browser paste timer (#3507)

---
 .../src/components/sidebar/RemoteFileBrowser.tsx  | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
index 3756a9c8e4b..eab58f6fe16 100644
--- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
+++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
@@ -58,6 +58,9 @@ export function RemoteFileBrowser({
   const inputRef = useRef(null)
   const fileHintTimerRef = useRef | null>(null)
   const debounceTimerRef = useRef | null>(null)
+  // Why: paste resolution intentionally runs next tick; closing the picker
+  // before then should cancel stale preview work.
+  const pasteResolveTimerRef = useRef | null>(null)
   // Cache directory listings by absolute resolved path for the lifetime of
   // the picker so ordinary typing issues at most one remote call per newly
   // committed segment. targetId does not change within a picker instance.
@@ -82,9 +85,15 @@ export function RemoteFileBrowser({
     return () => {
       if (fileHintTimerRef.current) {
         clearTimeout(fileHintTimerRef.current)
+        fileHintTimerRef.current = null
       }
       if (debounceTimerRef.current) {
         clearTimeout(debounceTimerRef.current)
+        debounceTimerRef.current = null
+      }
+      if (pasteResolveTimerRef.current) {
+        clearTimeout(pasteResolveTimerRef.current)
+        pasteResolveTimerRef.current = null
       }
     }
   }, [])
@@ -382,7 +391,11 @@ export function RemoteFileBrowser({
       // Paste resolves immediately; no debounce. React's onChange still fires
       // after the paste is applied to the input value, so we defer to the
       // next tick so `filter` reflects the pasted value.
-      setTimeout(() => {
+      if (pasteResolveTimerRef.current) {
+        clearTimeout(pasteResolveTimerRef.current)
+      }
+      pasteResolveTimerRef.current = setTimeout(() => {
+        pasteResolveTimerRef.current = null
         if (debounceTimerRef.current) {
           clearTimeout(debounceTimerRef.current)
           debounceTimerRef.current = null

From 504b98d873addc5915fe289b2e4e12ac883ce8fa Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 30 May 2026 00:03:26 -0700
Subject: [PATCH 03/57] fix: guard work item link copy feedback (#3509)

---
 src/renderer/src/components/GitHubItemDialog.tsx | 13 +++++++++++++
 src/renderer/src/components/PullRequestPage.tsx  | 13 +++++++++++++
 2 files changed, 26 insertions(+)

diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 8144b3bbc45..c4088335df6 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -5440,6 +5440,16 @@ export default function GitHubItemDialog({
   const files = details?.files ?? []
   const checks = details?.checks ?? []
   const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set())
+  // Why: clipboard IPC can resolve after the dialog unmounts; skip copied-state
+  // feedback instead of starting its reset timer on a stale surface.
+  const linkCopyMountedRef = useRef(false)
+
+  useEffect(() => {
+    linkCopyMountedRef.current = true
+    return () => {
+      linkCopyMountedRef.current = false
+    }
+  }, [])
 
   useEffect(() => {
     setLinkCopied(false)
@@ -5461,6 +5471,9 @@ export default function GitHubItemDialog({
       // Why: Electron's clipboard IPC is reliable even when browser clipboard
       // APIs lose focus/activation inside nested overlay surfaces.
       await window.api.ui.writeClipboardText(workItem.url)
+      if (!linkCopyMountedRef.current) {
+        return
+      }
       setLinkCopied(true)
       toast.success('GitHub link copied')
     } catch {
diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx
index 1ac3c4ce549..195d3b72cd5 100644
--- a/src/renderer/src/components/PullRequestPage.tsx
+++ b/src/renderer/src/components/PullRequestPage.tsx
@@ -5325,6 +5325,16 @@ export default function PullRequestPage({
   const files = details?.files ?? []
   const checks = details?.checks ?? []
   const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set())
+  // Why: clipboard IPC can resolve after the page unmounts; skip copied-state
+  // feedback instead of starting its reset timer on a stale surface.
+  const linkCopyMountedRef = useRef(false)
+
+  useEffect(() => {
+    linkCopyMountedRef.current = true
+    return () => {
+      linkCopyMountedRef.current = false
+    }
+  }, [])
 
   useEffect(() => {
     setLinkCopied(false)
@@ -5346,6 +5356,9 @@ export default function PullRequestPage({
       // Why: Electron's clipboard IPC is reliable even when browser clipboard
       // APIs lose focus/activation inside nested overlay surfaces.
       await window.api.ui.writeClipboardText(workItem.url)
+      if (!linkCopyMountedRef.current) {
+        return
+      }
       setLinkCopied(true)
       toast.success('GitHub link copied')
     } catch {

From 6b8a2b6b2617d323bf3869662fc77b2e691646fd Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 30 May 2026 00:07:22 -0700
Subject: [PATCH 04/57] fix: surface codex account auth errors (#3510)

---
 .../codex-fetcher-auth-errors.test.ts         | 130 ++++++++++++++++++
 src/main/rate-limits/codex-fetcher.ts         |   9 +-
 .../src/components/settings/AccountsPane.tsx  |  93 +++++++++++--
 .../components/settings/accounts-search.ts    |  14 +-
 .../codex-account-auth-warning.test.ts        |  88 ++++++++++++
 .../settings/codex-account-auth-warning.ts    |  42 ++++++
 src/shared/codex-auth-errors.ts               |  37 +++++
 7 files changed, 397 insertions(+), 16 deletions(-)
 create mode 100644 src/main/rate-limits/codex-fetcher-auth-errors.test.ts
 create mode 100644 src/renderer/src/components/settings/codex-account-auth-warning.test.ts
 create mode 100644 src/renderer/src/components/settings/codex-account-auth-warning.ts
 create mode 100644 src/shared/codex-auth-errors.ts

diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts
new file mode 100644
index 00000000000..08674c9d98a
--- /dev/null
+++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts
@@ -0,0 +1,130 @@
+import { EventEmitter } from 'node:events'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
+  childSpawnMock: vi.fn(),
+  resolveCodexCommandMock: vi.fn(),
+  ptySpawnMock: vi.fn()
+}))
+
+vi.mock('node:child_process', () => ({
+  spawn: childSpawnMock
+}))
+
+vi.mock('../codex-cli/command', () => ({
+  resolveCodexCommand: resolveCodexCommandMock
+}))
+
+vi.mock('node-pty', () => ({
+  spawn: ptySpawnMock
+}))
+
+import { fetchCodexRateLimits } from './codex-fetcher'
+
+function makeDisposable() {
+  return { dispose: vi.fn() }
+}
+
+function makeRpcChild() {
+  const child = new EventEmitter() as EventEmitter & {
+    stdout: EventEmitter
+    stderr: EventEmitter
+    stdin: { write: ReturnType }
+    kill: ReturnType
+  }
+  child.stdout = new EventEmitter()
+  child.stderr = new EventEmitter()
+  child.stdin = { write: vi.fn() }
+  child.kill = vi.fn()
+  return child
+}
+
+describe('fetchCodexRateLimits auth errors', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+    vi.clearAllMocks()
+    resolveCodexCommandMock.mockReturnValue('codex')
+  })
+
+  it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => {
+    const rpcChild = makeRpcChild()
+    const authError =
+      'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.'
+
+    childSpawnMock.mockReturnValue(rpcChild)
+    rpcChild.stdin.write.mockImplementation((line: string) => {
+      const msg = JSON.parse(line) as { id?: number; method?: string }
+      if (msg.method === 'initialize') {
+        setTimeout(() => {
+          rpcChild.stdout.emit(
+            'data',
+            Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
+          )
+        }, 0)
+      }
+      if (msg.method === 'account/rateLimits/read') {
+        setTimeout(() => {
+          rpcChild.stdout.emit(
+            'data',
+            Buffer.from(
+              `${JSON.stringify({
+                jsonrpc: '2.0',
+                id: msg.id,
+                error: { code: -32000, message: authError }
+              })}\n`
+            )
+          )
+        }, 0)
+      }
+    })
+
+    const resultPromise = fetchCodexRateLimits()
+    await vi.advanceTimersByTimeAsync(1)
+    await vi.advanceTimersByTimeAsync(1)
+
+    await expect(resultPromise).resolves.toMatchObject({
+      provider: 'codex',
+      session: null,
+      weekly: null,
+      status: 'error',
+      error: authError
+    })
+    expect(ptySpawnMock).not.toHaveBeenCalled()
+  })
+
+  it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => {
+    const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {}
+    const authError =
+      'Error loading configuration: Your authentication session could not be refreshed automatically.'
+
+    childSpawnMock.mockImplementation(() => {
+      throw new Error('rpc unavailable')
+    })
+    ptySpawnMock.mockReturnValue({
+      onData: vi.fn((callback) => {
+        ptyHandlers.onData = callback
+        return makeDisposable()
+      }),
+      onExit: vi.fn((callback) => {
+        ptyHandlers.onExit = callback
+        return makeDisposable()
+      }),
+      write: vi.fn(),
+      kill: vi.fn()
+    })
+
+    const resultPromise = fetchCodexRateLimits()
+    await vi.advanceTimersByTimeAsync(0)
+
+    ptyHandlers.onData?.(`${authError}\n`)
+    ptyHandlers.onExit?.()
+
+    await expect(resultPromise).resolves.toMatchObject({
+      provider: 'codex',
+      session: null,
+      weekly: null,
+      status: 'error',
+      error: authError
+    })
+  })
+})
diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts
index feec38d98f7..d46da8c40a7 100644
--- a/src/main/rate-limits/codex-fetcher.ts
+++ b/src/main/rate-limits/codex-fetcher.ts
@@ -8,6 +8,7 @@ import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnost
 import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
 import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
 import { parseWslUncPath } from '../../shared/wsl-paths'
+import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors'
 
 const RPC_TIMEOUT_MS = 10_000
 const WSL_RPC_TIMEOUT_MS = 25_000
@@ -395,7 +396,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise s.settingsSearchQuery)
+  const codexRateLimits = useAppStore((s) => s.rateLimits.codex)
+  const codexRateLimitTarget = useAppStore((s) => s.rateLimits.codexTarget)
   const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
   const fetchSettings = useAppStore((s) => s.fetchSettings)
   const recordedOpenCodeSettingEditsRef = useRef>(new Set())
@@ -249,6 +252,7 @@ export function AccountsPane({
     activeAccountId: null,
     activeAccountIdsByRuntime: { host: null, wsl: {} }
   })
+  const [codexAccountsLoaded, setCodexAccountsLoaded] = useState(false)
   const [codexAction, setCodexAction] = useState<
     'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
   >('idle')
@@ -270,6 +274,17 @@ export function AccountsPane({
   )
   const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime)
   const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime)
+  const activeCodexAuthWarning = codexAccountsLoaded
+    ? getCodexAccountAuthWarning({
+        limits: codexRateLimits,
+        target: codexRateLimitTarget,
+        runtime: accountRuntime,
+        activeAccountId: activeCodexAccountId,
+        accountId: activeCodexAccountId
+      })
+    : null
+  const systemCodexNeedsReauthentication =
+    activeCodexAccountId === null && Boolean(activeCodexAuthWarning)
   const accountRuntimeUnavailable =
     accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading
 
@@ -289,6 +304,7 @@ export function AccountsPane({
         const nextCodex = await window.api.codexAccounts.list()
         if (!stale) {
           setCodexAccounts(nextCodex)
+          setCodexAccountsLoaded(true)
         }
       } catch (error) {
         if (!stale) {
@@ -324,6 +340,7 @@ export function AccountsPane({
 
   const syncCodexAccounts = async (next: CodexRateLimitAccountsState): Promise => {
     setCodexAccounts(next)
+    setCodexAccountsLoaded(true)
     await fetchSettings()
   }
 
@@ -700,6 +717,16 @@ export function AccountsPane({
           the status-bar account switcher. Keeping a stable DOM anchor here
           avoids dumping the user at the top of Accounts and making them hunt
           for the actual Codex account controls. */}
+          {activeCodexAuthWarning ? (
+            
+ + + {activeCodexAccountId + ? 'Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.' + : `Codex reported that the ${accountRuntime.label} login needs a fresh sign-in. Sign in again before starting new Codex sessions.`} + +
+ ) : null}
@@ -746,9 +773,11 @@ export function AccountsPane({ } disabled={codexAction !== 'idle' || accountRuntimeUnavailable} className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${ - activeCodexAccountId === null - ? 'border-foreground/20 bg-accent/15' - : 'border-border/70 hover:border-border hover:bg-accent/8' + systemCodexNeedsReauthentication + ? 'border-destructive/50 bg-destructive/5' + : activeCodexAccountId === null + ? 'border-foreground/20 bg-accent/15' + : 'border-border/70 hover:border-border hover:bg-accent/8' } disabled:cursor-default disabled:opacity-100`} >
@@ -762,9 +791,23 @@ export function AccountsPane({ Active ) : null} + {systemCodexNeedsReauthentication ? ( + + Needs sign-in + + ) : null}
- - Use your current {accountRuntime.label} Codex login. + + {systemCodexNeedsReauthentication + ? `Codex reported this ${accountRuntime.label} login is out of date.` + : `Use your current ${accountRuntime.label} Codex login.`}
@@ -776,6 +819,14 @@ export function AccountsPane({ ) : ( visibleCodexAccounts.map((account) => { const isActive = activeCodexAccountId === account.id + const accountAuthWarning = getCodexAccountAuthWarning({ + limits: codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: account.id + }) + const needsReauthentication = Boolean(accountAuthWarning) const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` const isBusy = codexAction !== 'idle' || accountRuntimeUnavailable @@ -784,9 +835,11 @@ export function AccountsPane({
@@ -820,12 +873,28 @@ export function AccountsPane({ Active ) : null} + {needsReauthentication ? ( + + Needs re-auth + + ) : null}
-
- {account.workspaceLabel ? ( +
+ {needsReauthentication ? ( + + Codex reported this sign-in is out of date + + ) : account.workspaceLabel ? ( {account.workspaceLabel} ) : null} - {account.workspaceLabel ? ( + {needsReauthentication || account.workspaceLabel ? ( • ) : null} diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 73016c18f7a..3057f9369e5 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -21,12 +21,22 @@ export const ACCOUNTS_CODEX_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Codex Accounts', description: 'Optional account switching for Codex and live rate limit fetching.', - keywords: ['codex', 'account', 'rate limit', 'status bar', 'quota', 'optional'] + keywords: [ + 'codex', + 'account', + 'rate limit', + 'status bar', + 'quota', + 'optional', + 'reauthenticate', + 'expired', + 'out of date' + ] }, { title: 'Active Codex Account', description: 'Choose which optional saved Codex account powers live quota reads.', - keywords: ['codex', 'account', 'switch', 'active', 'status bar', 'optional'] + keywords: ['codex', 'account', 'switch', 'active', 'status bar', 'optional', 'sign in'] } ] diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts new file mode 100644 index 00000000000..ea8de210bcf --- /dev/null +++ b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { isCodexAuthError } from '../../../../shared/codex-auth-errors' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { + codexRateLimitTargetMatchesAccountRuntime, + getCodexAccountAuthWarning +} from './codex-account-auth-warning' + +function codexLimits(error: string | null, status: ProviderRateLimits['status'] = 'error') { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: 1, + error, + status + } satisfies ProviderRateLimits +} + +describe('codex account auth warning', () => { + it('recognizes Codex refresh-token failures as re-auth errors', () => { + expect( + isCodexAuthError( + 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' + ) + ).toBe(true) + expect( + isCodexAuthError( + 'Error loading configuration: Your authentication session could not be refreshed automatically.' + ) + ).toBe(true) + }) + + it('does not treat generic Codex availability failures as auth errors', () => { + expect(isCodexAuthError('RPC timeout')).toBe(false) + expect( + isCodexAuthError('Codex CLI found but could not run - Node.js may not be in your PATH') + ).toBe(false) + }) + + it('matches the active host account on the current rate-limit target', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits( + 'Your access token could not be refreshed. Please log out and sign in again.' + ), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toContain('access token could not be refreshed') + }) + + it('does not warn for inactive accounts or a different runtime', () => { + const limits = codexLimits( + 'Your access token could not be refreshed. Please log out and sign in again.' + ) + + expect( + getCodexAccountAuthWarning({ + limits, + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-2' + }) + ).toBeNull() + expect( + getCodexAccountAuthWarning({ + limits, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toBeNull() + }) + + it('allows a WSL default account location to receive the active WSL target warning', () => { + expect( + codexRateLimitTargetMatchesAccountRuntime( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + { runtime: 'wsl', wslDistro: null } + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.ts b/src/renderer/src/components/settings/codex-account-auth-warning.ts new file mode 100644 index 00000000000..b9d824576c4 --- /dev/null +++ b/src/renderer/src/components/settings/codex-account-auth-warning.ts @@ -0,0 +1,42 @@ +import type { + ProviderRateLimits, + RateLimitRuntimeTarget +} from '../../../../shared/rate-limit-types' +import { isCodexAuthError } from '../../../../shared/codex-auth-errors' + +type AccountRuntime = { + runtime: 'host' | 'wsl' + wslDistro?: string | null +} + +export function codexRateLimitTargetMatchesAccountRuntime( + target: RateLimitRuntimeTarget, + runtime: AccountRuntime +): boolean { + if (target.runtime !== runtime.runtime) { + return false + } + if (runtime.runtime === 'host') { + return true + } + return !runtime.wslDistro || target.wslDistro === runtime.wslDistro +} + +export function getCodexAccountAuthWarning(args: { + limits: ProviderRateLimits | null + target: RateLimitRuntimeTarget + runtime: AccountRuntime + activeAccountId: string | null + accountId: string | null +}): string | null { + if (args.accountId !== args.activeAccountId) { + return null + } + if (!codexRateLimitTargetMatchesAccountRuntime(args.target, args.runtime)) { + return null + } + if (args.limits?.status !== 'error' || !isCodexAuthError(args.limits.error)) { + return null + } + return args.limits.error?.trim() || 'Codex reported that this sign-in needs re-authentication.' +} diff --git a/src/shared/codex-auth-errors.ts b/src/shared/codex-auth-errors.ts new file mode 100644 index 00000000000..8a31aabd49c --- /dev/null +++ b/src/shared/codex-auth-errors.ts @@ -0,0 +1,37 @@ +const CODEX_AUTH_ERROR_PATTERNS = [ + /access token could not be refreshed/i, + /authentication session could not be refreshed/i, + /refresh token (?:has expired|was already used|was revoked)/i, + /you have since logged out or signed in to another account/i, + /please (?:log out and )?sign in again/i, + /please reauthenticate/i, + /not logged in/i, + /token data is not available/i, + /auth (?:is missing|tokens are missing|does not expose)/i +] +const ANSI_ESCAPE_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[a-zA-Z]`, 'g') + +export function isCodexAuthError(error: string | null | undefined): boolean { + const message = error?.trim() + if (!message) { + return false + } + return CODEX_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message)) +} + +export function extractCodexAuthError(output: string | null | undefined): string | null { + const cleanOutput = output?.replace(ANSI_ESCAPE_RE, '').trim() + if (!cleanOutput) { + return null + } + + const matchingLine = cleanOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .find(isCodexAuthError) + if (matchingLine) { + return matchingLine + } + + return isCodexAuthError(cleanOutput) ? cleanOutput.slice(0, 4_000) : null +} From 8a0fb0bae2fd7f11a1abfddc51300704dee095c5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:07:34 -0700 Subject: [PATCH 05/57] fix: guard source control copy feedback (#3513) --- .../right-sidebar/SourceControl.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index dea699cd2a2..568462d305f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1030,17 +1030,30 @@ function SourceControlInner(): React.JSX.Element { const [pendingDiffCommentsClear, setPendingDiffCommentsClear] = useState(null) const [isClearingDiffComments, setIsClearingDiffComments] = useState(false) + // Why: clipboard IPC can resolve after Source Control unmounts; skip copied + // feedback instead of starting a reset timer on a stale panel. + const diffCommentsCopyMountedRef = useRef(false) useEffect(() => { return () => cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef) }, []) + useEffect(() => { + diffCommentsCopyMountedRef.current = true + return () => { + diffCommentsCopyMountedRef.current = false + } + }, []) + const handleCopyDiffComments = useCallback(async (): Promise => { if (diffCommentsForActive.length === 0) { return } try { await window.api.ui.writeClipboardText(diffCommentsPrompt) + if (!diffCommentsCopyMountedRef.current) { + return + } setDiffCommentsCopied(true) } catch { // Why: swallow — clipboard write can fail when the window isn't focused. @@ -5843,6 +5856,16 @@ function DiffCommentsInlineList({ }, [comments]) const [copiedId, setCopiedId] = useState(null) + // Why: clipboard IPC can resolve after the inline notes list unmounts; skip + // copied feedback instead of starting a reset timer on a stale list. + const copiedIdMountedRef = useRef(false) + + useEffect(() => { + copiedIdMountedRef.current = true + return () => { + copiedIdMountedRef.current = false + } + }, []) // Why: auto-dismiss the per-row "copied" indicator so the button returns to // its default icon after a brief confirmation window. Matches the top-level @@ -5858,6 +5881,9 @@ function DiffCommentsInlineList({ const handleCopyOne = useCallback(async (c: DiffComment): Promise => { try { await window.api.ui.writeClipboardText(formatDiffComment(c)) + if (!copiedIdMountedRef.current) { + return + } setCopiedId(c.id) } catch { // Why: swallow — clipboard write can fail when the window isn't focused. From 3f12acf4eaa6547e1559af7083fb9b4158cf7123 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:10:24 -0700 Subject: [PATCH 06/57] fix: clear copy feedback mounted guards from refs (#3514) --- src/renderer/src/components/GitHubItemDialog.tsx | 10 ++++------ src/renderer/src/components/PullRequestPage.tsx | 9 +++------ src/renderer/src/components/QuickOpen.tsx | 11 ++--------- .../src/components/editor/CodeBlockCopyButton.tsx | 11 ++--------- .../src/components/editor/RichMarkdownCodeBlock.tsx | 11 ++--------- .../components/right-sidebar/checks-panel-content.tsx | 11 ++--------- src/renderer/src/components/settings/MobilePane.tsx | 9 +-------- .../src/components/settings/RepositoryPane.tsx | 11 ++--------- .../src/components/stats/ShareUsageButton.tsx | 11 ++--------- 9 files changed, 20 insertions(+), 74 deletions(-) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c4088335df6..1b350a15ae7 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -5443,12 +5443,8 @@ export default function GitHubItemDialog({ // Why: clipboard IPC can resolve after the dialog unmounts; skip copied-state // feedback instead of starting its reset timer on a stale surface. const linkCopyMountedRef = useRef(false) - - useEffect(() => { - linkCopyMountedRef.current = true - return () => { - linkCopyMountedRef.current = false - } + const setLinkCopyButtonRef = useCallback((node: HTMLButtonElement | null) => { + linkCopyMountedRef.current = node !== null }, []) useEffect(() => { @@ -5593,6 +5589,7 @@ export default function GitHubItemDialog({ +
+ ) +} + function InlineUsageSkeleton(): React.JSX.Element { return (
@@ -995,6 +1045,17 @@ function CodexSwitcherMenu({ activeAccountId: null }) const [isSwitching, setIsSwitching] = useState(false) + const [reauthenticatingAccountId, setReauthenticatingAccountId] = useState(null) + const accountsExpandedRef = useRef(accountsExpanded) + // Why: Radix item selection is separate from the nested button click, so + // propagation stops alone do not prevent the row switch action. + const suppressNextAccountSelectRef = useRef(false) + const suppressNextAccountSelect = useCallback(() => { + suppressNextAccountSelectRef.current = true + window.setTimeout(() => { + suppressNextAccountSelectRef.current = false + }, 0) + }, []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) @@ -1021,6 +1082,10 @@ function CodexSwitcherMenu({ setAccounts(next) }, []) + useEffect(() => { + accountsExpandedRef.current = accountsExpanded + }, [accountsExpanded]) + useEffect(() => { // Why: the status bar keeps its own lightweight account snapshot for the // dropdown. Settings account actions mutate the main-process store outside @@ -1035,7 +1100,7 @@ function CodexSwitcherMenu({ accountId: string | null, target: CodexStatusRuntimeTarget ): Promise => { - if (isSwitching) { + if (isSwitching || reauthenticatingAccountId !== null) { return } const previousActiveAccountId = getCodexStatusActiveId(accountState, target) @@ -1068,6 +1133,26 @@ function CodexSwitcherMenu({ } } + const handleSignInAccount = async (accountId: string): Promise => { + if (isSwitching || reauthenticatingAccountId !== null) { + return + } + setReauthenticatingAccountId(accountId) + try { + const next = await window.api.codexAccounts.reauthenticate({ accountId }) + recordFeatureInteraction('codex-account-switching') + setAccounts(next) + await fetchSettings() + if (accountsExpandedRef.current) { + await fetchInactiveCodexAccountUsage() + } + } catch (error) { + console.error('Failed to re-authenticate Codex account from status bar:', error) + } finally { + setReauthenticatingAccountId(null) + } + } + const handleSelectRuntime = async (group: CodexStatusSwitchGroup): Promise => { const currentKey = getCodexStatusRuntimeKey( normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) @@ -1161,6 +1246,12 @@ function CodexSwitcherMenu({ const inactiveUsage = target.id ? inactiveCodexAccounts.find((a) => a.accountId === target.id) : null + const showSignInAction = + !target.active && + target.id !== null && + isUnavailableInactiveUsage(inactiveUsage?.claude) + const isSigningIn = reauthenticatingAccountId === target.id + const isBusy = isSwitching || reauthenticatingAccountId !== null return (
@@ -1188,6 +1283,19 @@ function CodexSwitcherMenu({
{inactiveUsage?.isFetching && !inactiveUsage.claude ? ( + ) : showSignInAction ? ( + { + suppressNextAccountSelect() + if (target.id !== null) { + void handleSignInAccount(target.id) + } + }} + /> ) : inactiveUsage?.claude ? ( Date: Sat, 30 May 2026 00:15:47 -0700 Subject: [PATCH 09/57] fix: clear source copy mounted guards from refs (#3516) --- .../right-sidebar/SourceControl.tsx | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 568462d305f..35e29ae62b8 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1033,16 +1033,13 @@ function SourceControlInner(): React.JSX.Element { // Why: clipboard IPC can resolve after Source Control unmounts; skip copied // feedback instead of starting a reset timer on a stale panel. const diffCommentsCopyMountedRef = useRef(false) - - useEffect(() => { - return () => cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef) + const setSourceControlRootRef = useCallback((node: HTMLDivElement | null) => { + sourceControlRef.current = node + diffCommentsCopyMountedRef.current = node !== null }, []) useEffect(() => { - diffCommentsCopyMountedRef.current = true - return () => { - diffCommentsCopyMountedRef.current = false - } + return () => cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef) }, []) const handleCopyDiffComments = useCallback(async (): Promise => { @@ -3939,7 +3936,7 @@ function SourceControlInner(): React.JSX.Element { return ( <> -
+
{(['all', 'uncommitted'] as const).map((value) => ( + ) + } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/telemetry', () => ({ + track: mocks.track +})) + +vi.mock('sonner', () => ({ + toast: { + success: mocks.toastSuccess, + error: vi.fn(), + info: vi.fn() + } +})) + +vi.mock('./AddRepoSetupStep', () => ({ + SetupStep: ({ repoName }: { repoName: string }) =>
setup:{repoName}
, + getProjectAddedPrimaryBranchName: () => 'main' +})) + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/projects/child', + displayName: 'child', + badgeColor: '#999999', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +async function clickAddProject(): Promise { + const button = mocks.buttons.find((entry) => entry.label.includes('Add Project')) + if (!button?.onClick) { + throw new Error('Add Project button not found') + } + await button.onClick() +} + +describe('AddProjectFromFolderDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buttons = [] + mocks.state.activeModal = 'confirm-add-project-from-folder' + mocks.state.modalData = { folderPath: '/projects/child' } + mocks.state.worktreesByRepo = {} + mocks.state.detectedWorktreesByRepo = {} + mocks.state.hideDefaultBranchWorkspace = false + mocks.state.repos = [] + vi.stubGlobal('window', { + api: { + repos: { + addRemote: mocks.addRemote + } + } + }) + }) + + it('adds a local Git folder and opens the reused setup step data path', async () => { + const repo = makeRepo() + mocks.state.addRepoPath.mockResolvedValue(repo) + const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog') + + renderToStaticMarkup() + await clickAddProject() + + expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child') + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id) + expect(mocks.state.openModal).not.toHaveBeenCalledWith( + 'confirm-non-git-folder', + expect.anything() + ) + }) + + it('leaves local non-Git folders on the existing Open as Folder confirmation path', async () => { + mocks.state.addRepoPath.mockImplementation(async (folderPath: string) => { + mocks.state.openModal('confirm-non-git-folder', { folderPath }) + return null + }) + const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog') + + renderToStaticMarkup() + await clickAddProject() + + expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child') + expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { + folderPath: '/projects/child' + }) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + + it('adds an SSH Git folder through the remote repo import path', async () => { + const repo = makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }) + mocks.state.modalData = { + folderPath: '/srv/projects/child', + connectionId: 'ssh-target-1' + } + mocks.addRemote.mockResolvedValue({ repo }) + const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog') + + renderToStaticMarkup() + await clickAddProject() + + expect(mocks.addRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-target-1', + remotePath: '/srv/projects/child' + }) + expect(mocks.state.repos).toEqual([repo]) + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Remote project added', { + description: repo.displayName + }) + }) + + it('sends SSH non-Git folders to the Open as Folder confirmation with the connection id', async () => { + mocks.state.modalData = { + folderPath: '/srv/projects/docs', + connectionId: 'ssh-target-1' + } + mocks.addRemote.mockResolvedValue({ + error: 'Not a valid git repository: /srv/projects/docs' + }) + const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog') + + renderToStaticMarkup() + await clickAddProject() + + expect(mocks.addRemote).toHaveBeenCalledWith({ + connectionId: 'ssh-target-1', + remotePath: '/srv/projects/docs' + }) + expect(mocks.state.closeModal).toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { + folderPath: '/srv/projects/docs', + connectionId: 'ssh-target-1' + }) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx new file mode 100644 index 00000000000..37894afa457 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { FolderPlus, Loader2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { track } from '@/lib/telemetry' +import type { Repo } from '../../../../shared/types' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getProjectAddedPrimaryBranchName, SetupStep } from './AddRepoSetupStep' +import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../shared/worktree-ownership' + +const NON_GIT_REPO_ERROR = 'Not a valid git repository' + +const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialog() { + const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) + const closeModal = useAppStore((s) => s.closeModal) + const openModal = useAppStore((s) => s.openModal) + const addRepoPath = useAppStore((s) => s.addRepoPath) + const updateRepo = useAppStore((s) => s.updateRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + + const [addedRepo, setAddedRepo] = useState(null) + const [isAdding, setIsAdding] = useState(false) + const [error, setError] = useState(null) + + const isOpen = activeModal === 'confirm-add-project-from-folder' + const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : '' + const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : '' + const repoId = addedRepo?.id ?? '' + + const worktrees = useMemo(() => { + return worktreesByRepo[repoId] ?? [] + }, [repoId, worktreesByRepo]) + const detectedResult = repoId ? detectedWorktreesByRepo[repoId] : undefined + const hiddenWorktreeCount = + detectedResult?.authoritative === true + ? detectedResult.worktrees.filter( + (worktree) => !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' + ).length + : 0 + const otherWorktreesVisible = addedRepo + ? effectiveExternalWorktreeVisibility( + addedRepo, + isLegacyRepoForExternalWorktreeVisibility(addedRepo) + ) === 'show' + : false + const sortedWorktrees = useMemo(() => { + return [...worktrees].sort((a, b) => { + if (a.lastActivityAt !== b.lastActivityAt) { + return b.lastActivityAt - a.lastActivityAt + } + return a.displayName.localeCompare(b.displayName) + }) + }, [worktrees]) + const primaryWorktree = useMemo( + () => sortedWorktrees.find((worktree) => worktree.isMainWorktree) ?? null, + [sortedWorktrees] + ) + const primaryBranchName = getProjectAddedPrimaryBranchName(primaryWorktree) + + useEffect(() => { + if (!isOpen) { + setAddedRepo(null) + setIsAdding(false) + setError(null) + } + }, [isOpen]) + + const openNonGitConfirmation = useCallback(() => { + closeModal() + openModal('confirm-non-git-folder', { + folderPath, + ...(connectionId ? { connectionId } : {}) + }) + }, [closeModal, connectionId, folderPath, openModal]) + + const handleConfirm = useCallback(async () => { + if (!folderPath || isAdding) { + return + } + setIsAdding(true) + setError(null) + try { + let repo: Repo | null + if (connectionId) { + const result = await window.api.repos.addRemote({ + connectionId, + remotePath: folderPath + }) + if ('error' in result) { + throw new Error(result.error) + } + repo = result.repo + const state = useAppStore.getState() + const existingIdx = state.repos.findIndex((r) => r.id === repo?.id) + if (existingIdx !== -1) { + state.clearOrcaHookTrustForRepo(repo.id) + const updated = [...state.repos] + updated[existingIdx] = repo + useAppStore.setState({ repos: updated }) + } else { + useAppStore.setState({ repos: [...state.repos, repo] }) + } + toast.success('Remote project added', { description: repo.displayName }) + } else { + repo = await addRepoPath(folderPath) + } + + if (!repo) { + return + } + if (!isGitRepoKind(repo)) { + openNonGitConfirmation() + return + } + setAddedRepo(repo) + await fetchWorktrees(repo.id) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes(NON_GIT_REPO_ERROR)) { + openNonGitConfirmation() + return + } + setError(message) + } finally { + setIsAdding(false) + } + }, [addRepoPath, connectionId, fetchWorktrees, folderPath, isAdding, openNonGitConfirmation]) + + const handleStartPrimaryWorktree = useCallback(() => { + if (!primaryWorktree) { + return + } + track('add_repo_setup_step_action', { action: 'open_primary' }) + closeModal() + if (useAppStore.getState().hideDefaultBranchWorkspace) { + setHideDefaultBranchWorkspace(false) + } + activateAndRevealWorktree(primaryWorktree.id) + }, [closeModal, primaryWorktree, setHideDefaultBranchWorkspace]) + + const handleUseExistingWorktrees = useCallback(async () => { + if (!repoId) { + return + } + track('add_repo_setup_step_action', { action: 'open_existing' }) + if (!otherWorktreesVisible) { + const updated = await updateRepo(repoId, { externalWorktreeVisibility: 'show' }) + if (updated && addedRepo) { + setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' }) + } + } + closeModal() + await fetchWorktrees(repoId) + finalizeImportedRepoAfterSkip(useAppStore.getState(), repoId) + }, [addedRepo, closeModal, fetchWorktrees, otherWorktreesVisible, repoId, updateRepo]) + + const handleCreateWorktree = useCallback( + (name?: string) => { + if (!repoId) { + return + } + track('add_repo_setup_step_action', { action: 'create_worktree' }) + closeModal() + setTimeout(() => { + openModal('new-workspace-composer', { + initialRepoId: repoId, + ...(name ? { prefilledName: name } : {}), + telemetrySource: 'sidebar' + }) + }, 150) + }, + [closeModal, openModal, repoId] + ) + + const handleConfigureRepo = useCallback(() => { + if (!repoId) { + return + } + track('add_repo_setup_step_action', { action: 'configure' }) + closeModal() + openSettingsTarget({ pane: 'repo', repoId }) + openSettingsPage() + }, [closeModal, openSettingsPage, openSettingsTarget, repoId]) + + const handleSkip = useCallback(async () => { + if (!repoId) { + closeModal() + return + } + track('add_repo_setup_step_action', { action: 'skip' }) + closeModal() + await fetchWorktrees(repoId) + finalizeImportedRepoAfterSkip(useAppStore.getState(), repoId) + }, [closeModal, fetchWorktrees, repoId]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + if (addedRepo) { + void handleSkip() + return + } + closeModal() + } + }, + [addedRepo, closeModal, handleSkip] + ) + + return ( + + + {addedRepo ? ( + void handleUseExistingWorktrees()} + onCreateWorktree={handleCreateWorktree} + onConfigureRepo={handleConfigureRepo} + /> + ) : ( + <> + + Add Project + Add this folder as a separate Orca project. + + + {folderPath && ( +
+
{folderPath}
+
+ )} + + {error &&

{error}

} + + + + + + + )} +
+
+ ) +}) + +export default AddProjectFromFolderDialog diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 0e8da6e6837..f712e820b7f 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -11,6 +11,7 @@ import WorktreeMetaDialog from './WorktreeMetaDialog' import NonGitFolderDialog from './NonGitFolderDialog' import RemoveFolderDialog from './RemoveFolderDialog' import AddRepoDialog from './AddRepoDialog' +import AddProjectFromFolderDialog from './AddProjectFromFolderDialog' import ProjectAddedDialog from './ProjectAddedDialog' import WorktreeVisibilityDialog from './WorktreeVisibilityDialog' import OrcaYamlTrustDialog from './OrcaYamlTrustDialog' @@ -98,6 +99,7 @@ function Sidebar({ + diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7b4ac2d0c8b..93a2f804ae2 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -481,6 +481,7 @@ export type UISlice = { | 'create-worktree' | 'edit-meta' | 'delete-worktree' + | 'confirm-add-project-from-folder' | 'confirm-non-git-folder' | 'confirm-remove-folder' | 'add-repo' From 17b7a20816a201ac9361faafd9397c6d234952e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:17:06 -0700 Subject: [PATCH 11/57] fix: dispose contextual copy listeners (#3517) --- .../src/components/editor/setup-contextual-copy.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/editor/setup-contextual-copy.ts b/src/renderer/src/components/editor/setup-contextual-copy.ts index ea565a9ab2d..71eadd719ca 100644 --- a/src/renderer/src/components/editor/setup-contextual-copy.ts +++ b/src/renderer/src/components/editor/setup-contextual-copy.ts @@ -255,7 +255,7 @@ export function setupContextualCopy({ return true } - editorInstance.onDidChangeCursorSelection((event) => { + const selectionListener = editorInstance.onDidChangeCursorSelection((event) => { if (event.source !== 'restoreState') { schedulePrimarySelectionBufferUpdate() } @@ -264,13 +264,13 @@ export function setupContextualCopy({ } updateCopyHint() }) - editorInstance.onDidScrollChange(() => { + const scrollListener = editorInstance.onDidScrollChange(() => { updateCopyHint() }) - editorInstance.onDidFocusEditorText(() => { + const focusListener = editorInstance.onDidFocusEditorText(() => { startCopyHintPolling() }) - editorInstance.onDidBlurEditorText(() => { + const blurListener = editorInstance.onDidBlurEditorText(() => { stopCopyHintPolling() copyHintNode.style.display = 'none' copyHintWidgetPosition = null @@ -294,6 +294,12 @@ export function setupContextualCopy({ editorDomNode.addEventListener('mouseup', updateCopyHint, true) editorDomNode.addEventListener('keyup', updateCopyHint, true) editorInstance.onDidDispose(() => { + // Why: Monaco owns these emitters, but disposing explicitly keeps this + // feature's lifecycle symmetrical with the DOM listener cleanup below. + selectionListener.dispose() + scrollListener.dispose() + focusListener.dispose() + blurListener.dispose() if (primarySelectionTimer !== null) { window.clearTimeout(primarySelectionTimer) primarySelectionTimer = null From 73d8d95ddc9b071f3d8181047942de79af976618 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:22:03 -0700 Subject: [PATCH 12/57] fix: clear editor copy mounted guards from refs (#3519) --- .../components/editor/CombinedDiffViewer.tsx | 16 ++++++++-------- .../src/components/editor/EditorPanel.tsx | 12 +++++------- .../components/editor/EditorPanelShell.tsx | 4 ++-- .../src/components/editor/MarkdownPreview.tsx | 19 +++++++++++-------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index f1c788f032a..62a55ad8270 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -199,6 +199,10 @@ export default function CombinedDiffViewer({ const sectionsRef = useRef([]) const generationRef = useRef(0) const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) + const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { + scrollContainerRef.current = node + notesCopyMountedRef.current = node !== null + }, []) const loadSchedulerRef = useRef( createCombinedDiffLoadScheduler({ loadSection: (index) => loadSectionRef.current(index) @@ -865,13 +869,6 @@ export default function CombinedDiffViewer({ } }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) - useEffect(() => { - notesCopyMountedRef.current = true - return () => { - notesCopyMountedRef.current = false - } - }, []) - useEffect(() => { if (diffCommentCount === 0 && !isClearingNotes) { setClearNotesDialogOpen(false) @@ -1143,7 +1140,10 @@ export default function CombinedDiffViewer({ onCollapsedChange={setFileTreeCollapsed} onNavigate={handleTreeNavigate} /> -
+
{skippedConflictNotice}
{virtualizer.getVirtualItems().map((virtualItem) => { diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index db0611a8dce..4a6f69077fc 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -53,6 +53,10 @@ function EditorPanelInner({ // Why: clipboard IPC can resolve after the editor panel unmounts; skip path // toast feedback instead of starting a reset timer on a stale panel. const pathCopyMountedRef = useRef(false) + const setPanelRef = useCallback((node: HTMLDivElement | null) => { + panelRef.current = node + pathCopyMountedRef.current = node !== null + }, []) const [showMarkdownTableOfContents, setShowMarkdownTableOfContents] = useState(false) const [sideBySide, setSideBySide] = useState(settings?.diffDefaultView === 'side-by-side') const [prevDiffView, setPrevDiffView] = useState(settings?.diffDefaultView) @@ -92,12 +96,6 @@ function EditorPanelInner({ useEffect(() => acquireExportPdfListener(), []) useClosedEditorTabCleanup(openFiles) useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview }) - useEffect(() => { - pathCopyMountedRef.current = true - return () => { - pathCopyMountedRef.current = false - } - }, []) useEffect(() => { if (!copiedPathToast) { @@ -294,7 +292,7 @@ function EditorPanelInner({ return ( type EditorPanelShellProps = { - panelRef: RefObject + panelRef: Ref activeFile: OpenFile activeViewStateId: string | null | undefined model: EditorPanelRenderModel diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index f43e0891538..218d6e4942c 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -665,13 +665,16 @@ export default function MarkdownPreview({ } }, []) - useEffect(() => { - reviewNotesCopyMountedRef.current = true - return () => { - reviewNotesCopyMountedRef.current = false - clearReviewNotesCopiedResetTimer() - } - }, [clearReviewNotesCopiedResetTimer]) + const setRootRef = useCallback( + (node: HTMLDivElement | null) => { + rootRef.current = node + reviewNotesCopyMountedRef.current = node !== null + if (node === null) { + clearReviewNotesCopiedResetTimer() + } + }, + [clearReviewNotesCopiedResetTimer] + ) const scrollToAnchor = useCallback((rawAnchor: string): boolean => { const container = rootRef.current @@ -1505,7 +1508,7 @@ export default function MarkdownPreview({ return (
Date: Sat, 30 May 2026 00:22:53 -0700 Subject: [PATCH 13/57] fix: dispose monaco editor state listeners (#3520) --- .../src/components/editor/MonacoEditor.tsx | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index f9006b66de1..6412c04b51f 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -377,7 +377,7 @@ export default function MonacoEditor({ if (pos) { setEditorCursorLine(filePath, pos.lineNumber) } - editorInstance.onDidChangeCursorPosition((e) => { + const cursorPositionSub = editorInstance.onDidChangeCursorPosition((e) => { setEditorCursorLine(filePath, e.position.lineNumber) setWithLRU(cursorPositionCache, viewStateKey, { lineNumber: e.position.lineNumber, @@ -385,25 +385,11 @@ export default function MonacoEditor({ }) }) - editorInstance.onDidDispose(() => { - cleanupSaveShortcut() - autoHeightSub?.dispose() - if (autoHeightFrame !== null) { - window.cancelAnimationFrame(autoHeightFrame) - autoHeightFrame = null - } - conflictDecorationsRef.current?.clear() - conflictDecorationsRef.current = null - editorRef.current = null - setMountedEditor(null) - setCommentPopover(null) - }) - // Why: Writing to the Map at 60fps (every scroll frame) is unnecessary since // we only need the final position when the user stops scrolling or switches // tabs. A trailing throttle of ~150ms captures the resting position while // avoiding excessive writes. - editorInstance.onDidScrollChange((e) => { + const scrollStateSub = editorInstance.onDidScrollChange((e) => { if (scrollThrottleTimerRef.current !== null) { clearTimeout(scrollThrottleTimerRef.current) } @@ -415,7 +401,7 @@ export default function MonacoEditor({ // Intercept right-click on line number gutter to show Radix context menu // (same approach as VSCode: custom menu instead of Monaco's built-in one) - editorInstance.onMouseDown((e) => { + const gutterMouseDownSub = editorInstance.onMouseDown((e) => { if ( e.event.rightButton && e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS @@ -430,6 +416,25 @@ export default function MonacoEditor({ } }) + editorInstance.onDidDispose(() => { + // Why: keep editor-owned UI subscriptions symmetrical with the + // shortcut/decorator cleanup when Monaco tears this instance down. + cursorPositionSub.dispose() + scrollStateSub.dispose() + gutterMouseDownSub.dispose() + cleanupSaveShortcut() + autoHeightSub?.dispose() + if (autoHeightFrame !== null) { + window.cancelAnimationFrame(autoHeightFrame) + autoHeightFrame = null + } + conflictDecorationsRef.current?.clear() + conflictDecorationsRef.current = null + editorRef.current = null + setMountedEditor(null) + setCommentPopover(null) + }) + // If there's a pending reveal at mount time, execute it now const reveal = useAppStore.getState().pendingEditorReveal // Why: search-result navigation sets the reveal before openFile switches From e42f80f7df7e46b0a570be06628f975c9056b04b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:27:56 -0700 Subject: [PATCH 14/57] fix: dispose editable editor listeners (#3522) --- .../src/components/editor/DiffSectionItem.tsx | 9 +++++++-- src/renderer/src/components/editor/DiffViewer.tsx | 10 +++++++--- src/renderer/src/components/editor/IpynbViewer.tsx | 11 ++++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 363233681c4..fe9d47aedfe 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -381,8 +381,7 @@ export function DiffSectionItem({ const cleanupSaveShortcut = installEditorSaveShortcut(modified.getContainerDomNode(), () => handleSectionSaveRef.current(index) ) - modified.onDidDispose(() => cleanupSaveShortcut()) - modified.onDidChangeModelContent(() => { + const modelContentSub = modified.onDidChangeModelContent(() => { const current = modified.getValue() setSections((prev) => { let changed = false @@ -406,6 +405,12 @@ export function DiffSectionItem({ return changed ? next : prev }) }) + modified.onDidDispose(() => { + // Why: editable diff sections own both the save shortcut and model-change + // subscription for this Monaco editor instance. + cleanupSaveShortcut() + modelContentSub.dispose() + }) } useEffect(() => { diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 83aa4c31c2e..71185cf6912 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -337,12 +337,16 @@ export default function DiffViewer({ } ) - modifiedEditor.onDidDispose(() => cleanupSaveShortcut()) - // Track changes - modifiedEditor.onDidChangeModelContent(() => { + const modelContentSub = modifiedEditor.onDidChangeModelContent(() => { onContentChangeRef.current?.(modifiedEditor.getValue()) }) + modifiedEditor.onDidDispose(() => { + // Why: editable diff views own both the save shortcut and + // model-change subscription for this Monaco editor instance. + cleanupSaveShortcut() + modelContentSub.dispose() + }) modifiedEditor.focus() } else { diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index 4582f681acb..7459f911074 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -297,11 +297,16 @@ function CodeCell({ void onSaveRequestRef.current() } ) - editorInstance.onDidDispose(() => cleanupSaveShortcut()) - editorInstance.addCommand(monacoInstance.KeyCode.Escape, () => { + const blurSub = editorInstance.onDidBlurEditorWidget(() => { onDeactivateRef.current() }) - editorInstance.onDidBlurEditorWidget(() => { + editorInstance.onDidDispose(() => { + // Why: the inline source editor owns both the save shortcut and blur + // subscription for this Monaco editor instance. + cleanupSaveShortcut() + blurSub.dispose() + }) + editorInstance.addCommand(monacoInstance.KeyCode.Escape, () => { onDeactivateRef.current() }) }, []) From 978a1dcc6ce96752bb8bf97755380894b9258138 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:34:48 -0700 Subject: [PATCH 15/57] Show section activity in worktree sidebar (#3419) * Show section activity in worktree sidebar * Tighten section activity badge placement * Unify section count status badge * Separate section total and running counts * Hide empty running section count --- .../src/components/sidebar/WorktreeList.tsx | 146 ++++++++++++- .../sidebar/worktree-section-activity.test.ts | 192 ++++++++++++++++++ .../sidebar/worktree-section-activity.ts | 108 ++++++++++ 3 files changed, 440 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 70eacfed913..7b97535b7ae 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -166,6 +166,12 @@ import { } from '../../../../shared/worktree-ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { + buildWorktreeSectionActivitySummaries, + EMPTY_WORKTREE_SECTION_ACTIVITY, + type WorktreeSectionActivityState, + type WorktreeSectionActivitySummary +} from './worktree-section-activity' type ProjectGroupNameDialogState = | { type: 'create-from-repo'; repo: Repo } @@ -388,6 +394,8 @@ type VirtualizedWorktreeViewportProps = { dropIndex: number }) => void showInlineAgentCards: boolean + showSectionStatus: boolean + sectionActivityByGroupKey: ReadonlyMap // Why: broad grouping changes still remount the viewport, while add/delete // stays mounted for row-key anchoring and layout animation. These refs bridge // both paths so the virtualizer never falls back to scrollTop 0. @@ -397,6 +405,63 @@ type VirtualizedWorktreeViewportProps = { type WorktreeItemRow = Extract +function formatSectionActivityLabel(count: number, label: string): string { + return `${count} ${label}${count === 1 ? '' : 's'}` +} + +function SectionMetricsBadge({ + count, + summary, + showStatus +}: { + count: number + summary: WorktreeSectionActivitySummary + showStatus: boolean +}): React.JSX.Element { + const runningCount = showStatus ? summary.runningCount : 0 + const hasRunning = runningCount > 0 + const totalLabel = formatSectionActivityLabel(count, 'workspace') + const runningLabel = hasRunning + ? formatSectionActivityLabel(runningCount, 'running workspace') + : 'no running workspaces' + const badgeLabel = showStatus ? `${totalLabel}; ${runningLabel}` : totalLabel + const totalTooltipLabel = showStatus && !hasRunning ? badgeLabel : totalLabel + + return ( + + + + + {count} + + + + {totalTooltipLabel} + + + {showStatus && hasRunning ? ( + + + + + + + {runningLabel} + + + ) : null} + + ) +} + type WorktreeRowDragState = { draggingWorktreeId: string | null sourceGroupKey: string | null @@ -639,6 +704,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp shouldShowWorkspaceBoardDropIndicator, onReorderWorktrees, showInlineAgentCards, + showSectionStatus, + sectionActivityByGroupKey, scrollOffsetRef, scrollAnchorRef }: VirtualizedWorktreeViewportProps) { @@ -2317,6 +2384,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }) : null const projectGroupDepth = row.projectGroupDepth ?? 0 + const isCollapsed = collapsedGroups.has(row.key) + const sectionActivity = + sectionActivityByGroupKey.get(row.key) ?? EMPTY_WORKTREE_SECTION_ACTIVITY return (
-
-
+
+
{row.label}
-
- {row.count} -
+
@@ -2433,7 +2505,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
@@ -3030,6 +3102,15 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null ) const settings = useAppStore((s) => s.settings) + const sectionActivityTabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const sectionActivityBrowserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) + const sectionActivityPtyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const sectionActivityRuntimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + const sectionActivityAgentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const sectionActivityMigrationUnsupportedByPtyId = useAppStore( + (s) => s.migrationUnsupportedByPtyId + ) + const sectionActivityRetainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sortEpoch = useAppStore((s) => s.sortEpoch) @@ -3326,6 +3407,57 @@ const WorktreeList = React.memo(function WorktreeList({ const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) const reorderReposAction = useAppStore((s) => s.reorderRepos) const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy) + const showSectionStatus = cardProps.includes('status') + const sectionActivityState: WorktreeSectionActivityState = useMemo(() => { + const current = useAppStore.getState() + return { + tabsByWorktree: sectionActivityTabsByWorktree, + browserTabsByWorktree: sectionActivityBrowserTabsByWorktree, + ptyIdsByTabId: sectionActivityPtyIdsByTabId, + runtimePaneTitlesByTabId: sectionActivityRuntimePaneTitlesByTabId, + agentStatusEpoch: sectionActivityAgentStatusEpoch, + // Why: agentStatusByPaneKey can tick for same-state tool details. The + // section counts only need structural status transitions, tracked by + // agentStatusEpoch, so read the current map without subscribing to it. + agentStatusByPaneKey: current.agentStatusByPaneKey, + migrationUnsupportedByPtyId: sectionActivityMigrationUnsupportedByPtyId, + retainedAgentsByPaneKey: sectionActivityRetainedAgentsByPaneKey + } + }, [ + sectionActivityAgentStatusEpoch, + sectionActivityBrowserTabsByWorktree, + sectionActivityMigrationUnsupportedByPtyId, + sectionActivityPtyIdsByTabId, + sectionActivityRetainedAgentsByPaneKey, + sectionActivityRuntimePaneTitlesByTabId, + sectionActivityTabsByWorktree + ]) + const sectionActivityByGroupKey = useMemo( + () => + showSectionStatus + ? buildWorktreeSectionActivitySummaries({ + groupBy, + worktrees, + repoMap, + prCache, + workspaceStatuses, + projectGroups, + settings, + state: sectionActivityState + }) + : new Map(), + [ + groupBy, + prCache, + projectGroups, + repoMap, + sectionActivityState, + settings, + showSectionStatus, + workspaceStatuses, + worktrees + ] + ) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -3870,6 +4002,8 @@ const WorktreeList = React.memo(function WorktreeList({ shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} onReorderWorktrees={reorderWorktrees} showInlineAgentCards={cardProps.includes('inline-agents')} + showSectionStatus={showSectionStatus} + sectionActivityByGroupKey={sectionActivityByGroupKey} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts new file mode 100644 index 00000000000..3d8dccfcb28 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import { + getProjectGroupHeaderKey, + PINNED_GROUP_KEY, + type WorktreeGroupBy +} from './worktree-list-groups' +import { + buildWorktreeSectionActivitySummaries, + type WorktreeSectionActivityState +} from './worktree-section-activity' + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/tmp/repo-1', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + ...overrides + } +} + +function makeProjectGroup(overrides: Partial): ProjectGroup { + return { + id: 'group-1', + name: 'Group', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/repo-1-feature', + branch: 'refs/heads/feature', + head: 'abc123', + isBare: false, + isMainWorktree: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + comment: '', + displayName: 'feature', + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeTerminalTab(overrides: Partial = {}): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'zsh', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function makeState( + overrides: Partial = {} +): WorktreeSectionActivityState { + return { + tabsByWorktree: {}, + browserTabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + agentStatusEpoch: 0, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + ...overrides + } +} + +function buildSummaries({ + groupBy = 'repo', + worktrees, + repos, + projectGroups = [], + state +}: { + groupBy?: WorktreeGroupBy + worktrees: readonly Worktree[] + repos: readonly Repo[] + projectGroups?: readonly ProjectGroup[] + state: WorktreeSectionActivityState +}) { + return buildWorktreeSectionActivitySummaries({ + groupBy, + worktrees, + repoMap: new Map(repos.map((repo) => [repo.id, repo])), + prCache: null, + workspaceStatuses: [], + projectGroups, + state + }) +} + +describe('buildWorktreeSectionActivitySummaries', () => { + it('counts running worktrees across repo project-group ancestors', () => { + const parent = makeProjectGroup({ id: 'parent', name: 'Parent' }) + const child = makeProjectGroup({ + id: 'child', + name: 'Child', + parentGroupId: parent.id + }) + const repo = makeRepo({ projectGroupId: child.id }) + const worktree = makeWorktree({ repoId: repo.id }) + const state = makeState({ + tabsByWorktree: { + [worktree.id]: [ + makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id, title: 'Codex working' }) + ] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + } + }) + + const summaries = buildSummaries({ + worktrees: [worktree], + repos: [repo], + projectGroups: [parent, child], + state + }) + + expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({ + runningCount: 1 + }) + expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({ + runningCount: 1 + }) + expect(summaries.get(`repo:${repo.id}`)).toEqual({ + runningCount: 1 + }) + }) + + it('keeps pinned workspace activity on the pinned header only', () => { + const repo = makeRepo({ projectGroupId: 'group-1' }) + const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) + const now = Date.now() + const entry: AgentStatusEntry = { + state: 'working', + prompt: '', + updatedAt: now, + stateStartedAt: now, + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } + const state = makeState({ + tabsByWorktree: { + [worktree.id]: [makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id })] + }, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [entry.paneKey]: entry + } + }) + + const summaries = buildSummaries({ + worktrees: [worktree], + repos: [repo], + projectGroups: [makeProjectGroup({ id: 'group-1' })], + state + }) + + expect(summaries.get(PINNED_GROUP_KEY)).toEqual({ + runningCount: 1 + }) + expect(summaries.get(`repo:${repo.id}`)).toBeUndefined() + expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts new file mode 100644 index 00000000000..8d5990aaed3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-section-activity.ts @@ -0,0 +1,108 @@ +import type { AppState } from '@/store/types' +import { resolveWorktreeStatus } from '@/lib/worktree-status' +import type { + ProjectGroup, + Repo, + Worktree, + WorkspaceStatusDefinition +} from '../../../../shared/types' +import { + getGroupKeysForWorktree, + type WorktreeGroupBy, + PINNED_GROUP_KEY +} from './worktree-list-groups' +import { + selectLivePtyIdsForWorktree, + selectRuntimePaneTitlesForWorktree +} from './worktree-card-status-inputs' +import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' + +export type WorktreeSectionActivityState = Pick< + AppState, + | 'tabsByWorktree' + | 'browserTabsByWorktree' + | 'ptyIdsByTabId' + | 'runtimePaneTitlesByTabId' + | 'agentStatusEpoch' + | 'agentStatusByPaneKey' + | 'migrationUnsupportedByPtyId' + | 'retainedAgentsByPaneKey' +> + +export type WorktreeSectionActivitySummary = { + runningCount: number +} + +export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = { + runningCount: 0 +} + +export function buildWorktreeSectionActivitySummaries({ + groupBy, + worktrees, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + state +}: { + groupBy: WorktreeGroupBy + worktrees: readonly Worktree[] + repoMap: Map + prCache: Record | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings?: AppState['settings'] + projectGroups: readonly ProjectGroup[] + state: WorktreeSectionActivityState +}): Map { + const summaries = new Map() + + for (const worktree of worktrees) { + const groupKeys = worktree.isPinned + ? [PINNED_GROUP_KEY] + : getGroupKeysForWorktree( + groupBy, + worktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups + ) + if (groupKeys.length === 0) { + continue + } + + const status = getSectionWorktreeStatus(state, worktree.id) + for (const groupKey of groupKeys) { + const summary = summaries.get(groupKey) ?? { ...EMPTY_WORKTREE_SECTION_ACTIVITY } + if (status === 'working') { + summary.runningCount++ + } + summaries.set(groupKey, summary) + } + } + + return summaries +} + +function getSectionWorktreeStatus( + state: WorktreeSectionActivityState, + worktreeId: string +): ReturnType { + const agentSummary = selectWorktreeAgentActivitySummary(state, worktreeId) + + // Why: collapsed headers must mirror the card dot semantics exactly; otherwise + // a hidden section can advertise different activity than its visible cards. + return resolveWorktreeStatus({ + tabs: state.tabsByWorktree[worktreeId] ?? [], + browserTabs: state.browserTabsByWorktree[worktreeId] ?? [], + ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), + runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), + hasPermission: agentSummary.hasPermission, + hasLiveWorking: agentSummary.hasLiveWorking, + hasLiveDone: agentSummary.hasLiveDone, + hasRetainedDone: agentSummary.hasRetainedDone + }) +} From 26b2d2b23ea1af41867f68c5060eca9151e86f7e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:34:54 -0700 Subject: [PATCH 16/57] fix: clear browser address timers from ref (#3527) --- .../browser-pane/BrowserAddressBar.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx index 8e1e1654b76..8d14b8d6bd1 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -71,17 +71,26 @@ export default function BrowserAddressBar({ const blurCloseTimerRef = useRef(null) const closingResetTimerRef = useRef(null) - useEffect(() => { - return () => { - if (blurCloseTimerRef.current !== null) { - window.clearTimeout(blurCloseTimerRef.current) - } - if (closingResetTimerRef.current !== null) { - window.clearTimeout(closingResetTimerRef.current) - } + const clearAddressBarTimers = useCallback((): void => { + if (blurCloseTimerRef.current !== null) { + window.clearTimeout(blurCloseTimerRef.current) + blurCloseTimerRef.current = null + } + if (closingResetTimerRef.current !== null) { + window.clearTimeout(closingResetTimerRef.current) + closingResetTimerRef.current = null } }, []) + const setAddressBarFormRef = useCallback( + (node: HTMLFormElement | null) => { + if (node === null) { + clearAddressBarTimers() + } + }, + [clearAddressBarTimers] + ) + const searchEngine: SearchEngine = (browserDefaultSearchEngine as SearchEngine | null) ?? DEFAULT_SEARCH_ENGINE @@ -279,6 +288,7 @@ export default function BrowserAddressBar({ >
{ event.preventDefault() From 5dad1721cdb75efa2f537adee15c3168b408ecfd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:35:32 -0700 Subject: [PATCH 17/57] fix: guard installed skill discovery updates (#3528) --- .../src/hooks/useInstalledAgentSkills.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 1b5bbf8294a..01b4e7f3b20 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { DiscoveredSkill, SkillDiscoveryResult, SkillSourceKind } from '../../../shared/skills' const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' @@ -118,24 +118,46 @@ export function useInstalledAgentSkill( const [result, setResult] = useState(cachedDiscovery) const [loading, setLoading] = useState(enabled && !cachedDiscovery) const [error, setError] = useState(null) + // Why: skill scans can outlive transient settings/onboarding panels; keep + // the module cache update but skip React state writes after unmount. + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const refresh = useCallback( async (force = true): Promise => { if (!enabled) { - setLoading(false) + if (mountedRef.current) { + setLoading(false) + } return } - setLoading(true) + if (mountedRef.current) { + setLoading(true) + } try { const next = await discoverInstalledAgentSkills(force) + if (!mountedRef.current) { + return + } setResult(next) setError(null) } catch (refreshError) { + if (!mountedRef.current) { + return + } setError( refreshError instanceof Error ? refreshError.message : 'Could not scan installed skills.' ) } finally { - setLoading(false) + if (mountedRef.current) { + setLoading(false) + } } }, [enabled] From fba8297e4074bb349b9cd533c10a9464889d5237 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 30 May 2026 03:35:56 -0400 Subject: [PATCH 18/57] Fix remote paste and workspace activity (#3521) Co-authored-by: Orca --- docs/remote-web-paste-activity-fixes.md | 108 ++++++ .../runtime/rpc/methods/clipboard.test.ts | 286 +++++++++++++++- src/main/runtime/rpc/methods/clipboard.ts | 175 +++++++++- .../sidebar/visible-worktrees.test.ts | 18 +- .../src/lib/worktree-activity-state.test.ts | 28 ++ .../src/lib/worktree-activity-state.ts | 4 +- src/renderer/src/lib/worktree-status.test.ts | 30 +- src/renderer/src/lib/worktree-status.ts | 7 - src/renderer/src/web/web-preload-api.test.ts | 322 ++++++++++++++++-- src/renderer/src/web/web-preload-api.ts | 76 ++++- 10 files changed, 987 insertions(+), 67 deletions(-) create mode 100644 docs/remote-web-paste-activity-fixes.md diff --git a/docs/remote-web-paste-activity-fixes.md b/docs/remote-web-paste-activity-fixes.md new file mode 100644 index 00000000000..dca8ee5bed6 --- /dev/null +++ b/docs/remote-web-paste-activity-fixes.md @@ -0,0 +1,108 @@ +# Remote Web Paste And Activity Fixes + +## Problem + +- Paired browser image paste reads a clipboard image in `src/renderer/src/web/web-preload-api.ts:120`, converts it to PNG base64, and sends the whole string through `clipboard.saveImageAsTempFile` at `src/renderer/src/web/web-preload-api.ts:1613`. `src/main/runtime/rpc/ws-transport.ts` sets `maxPayload` to 1 MiB, and the browser RPC client encrypts JSON requests into base64 text frames. A screenshot well below the existing 24 MiB clipboard schema limit can still exceed the encrypted WebSocket frame cap and close the socket as `Remote Orca runtime connection interrupted`. +- Paired browser workspace activity is inflated. `src/renderer/src/lib/worktree-status.ts` and `src/renderer/src/lib/worktree-activity-state.ts` currently treat any mirrored web terminal surface ID as active, even when `src/renderer/src/runtime/web-session-tabs-sync.ts` only has a pending host terminal with no ready PTY handle. + +## Root Cause + +- The clipboard RPC accepts up to 24 MiB of base64 in `src/main/runtime/rpc/methods/clipboard.ts`, but the web transport limit applies before the server can validate RPC params. Because the web client encrypts JSON and base64-encodes the encrypted bytes, the safe plaintext chunk is materially smaller than 1 MiB. The existing 512 KiB file-upload chunk size in `src/renderer/src/runtime/runtime-file-client.ts` is safe after JSON plus encryption expansion; a chunk close to 1 MiB is not. +- The file-upload RPCs cannot be reused directly. They write worktree-relative files and then commit a rename; clipboard paste must call `saveClipboardImageBufferAsTempFile`, return a local or SSH temp path, and preserve the terminal repo's `connectionId`. +- Web session-tab sync intentionally mirrors pending host terminal surfaces so the tab model stays in parity, but it writes `ptyIdsByTabId[mirroredTabId]` only from `status: "ready"` surfaces. The status/filter helpers then bypass that liveness map with `isWebTerminalSurfaceTabId`, so pending mirrors look active. + +## Non-Goals + +- Do not raise the global WebSocket `maxPayload`; it protects all runtime traffic, including pre-auth and mobile sockets. +- Do not change sidebar grouping, labels, counts, pairing flow, terminal UI, or clipboard permission UI. +- Do not change local Electron clipboard behavior or the existing one-shot RPC contract. + +## Design + +1. Add bounded chunked clipboard-image RPCs. + - Keep `clipboard.saveImageAsTempFile` for small/old clients. + - Add `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and `clipboard.abortImageUpload`. + - `start` takes `expectedBase64Length` and `connectionId`, rejects values over the existing 24 MiB base64 limit, records the target connection, and returns an unguessable upload ID. + - `append` takes `uploadId`, `offset`, and `contentBase64`; reject unknown IDs, out-of-order offsets, chunks above 512 KiB, invalid base64 characters, and cumulative length beyond `expectedBase64Length`. + - `commit` verifies the received length equals `expectedBase64Length`, validates the full base64 payload with the same rules as the one-shot RPC, calls `saveClipboardImageBufferAsTempFile` with the recorded `connectionId`, and deletes the upload state in `finally`. + - `abort` deletes the upload state and is idempotent. + - Bound server memory with a small max concurrent upload count and a TTL cleanup for abandoned uploads. Upload state is process-local; reconnects restart the paste instead of resuming. + +2. Switch paired browser image paste to the chunked path. + - After `navigator.clipboard.read()` and PNG conversion, return `null` for no image as today. + - Preflight `contentBase64.length` against the 24 MiB limit before starting an upload. + - Send 512 KiB base64 slices. This is below the 1 MiB encrypted frame cap after JSON and E2EE base64 expansion. + - Abort best-effort on append or commit failure. If `start` returns `method_not_found`, fall back to `clipboard.saveImageAsTempFile` only when the payload is below a conservative single-frame threshold; never send a large fallback frame. + +3. Tighten workspace activity liveness. + - Treat terminal workspaces as active only when `tabHasLivePty(ptyIdsByTabId, tab.id)` is true. + - Remove the blanket `isWebTerminalSurfaceTabId` active shortcut from `getWorktreeStatus` and `hasActiveWorkspaceActivity`. + - Keep browser tabs active without terminals. + - Keep fresh/retained explicit agent rows able to promote status to `permission`, `working`, or `done`; this is separate from terminal liveness. + +## Data Flow + +- Paste image: + - Browser paste command -> `navigator.clipboard.read()` -> PNG base64 in web preload memory. + - `clipboard.startImageUpload({ expectedBase64Length, connectionId })` -> upload ID. + - Repeated `clipboard.appendImageUploadChunk({ uploadId, offset, contentBase64 })`. + - `clipboard.commitImageUpload({ uploadId })` -> runtime saves temp image locally or on the SSH target -> terminal receives the temp path. + +- Workspace activity: + - Host `session.tabs.listAll` or subscription snapshot includes terminal surfaces. + - Web sync mirrors tabs but writes live PTY handles only for ready surfaces. + - Sidebar status/filter reads `ptyIdsByTabId` and browser tabs. + - Pending mirrored terminals with no PTY remain visible but do not count as active. + +## Edge Cases + +- Clipboard has no image or browser lacks `navigator.clipboard.read`: return `null`, no upload session. +- Clipboard read/permission/conversion fails: existing terminal paste error path reports the failure; the runtime socket should stay open. +- Non-PNG clipboard images may grow during PNG conversion; validate the post-conversion base64 length. +- Image exceeds 24 MiB base64: reject before upload and enforce again on the server. +- Chunk boundaries must preserve base64 validity; the 512 KiB chunk size is divisible by 4, and the final full payload validation catches padding errors. +- Append retry or concurrent append with the same upload ID: offset validation rejects duplicate, skipped, or out-of-order data. +- Multiple paired browser clients or windows paste at once: upload IDs isolate sessions and the concurrent-upload cap bounds memory. +- Append or commit fails: browser aborts best-effort; server TTL cleans abandoned state. +- SSH connection missing or drops during commit: commit fails, upload state is still deleted, and the paste path reports the error. +- Runtime restarts during upload: pending RPC fails; no resume is attempted. +- Pending host terminal surface later becomes ready: the next snapshot writes PTY handles and the workspace becomes active. +- Host closes a terminal or browser tab: the next snapshot removes the mirrored tab and stale PTY/browser handles before activity is recomputed. + +## Test Plan + +- Unit: `src/main/runtime/rpc/methods/clipboard.test.ts` for start/append/commit/abort, offset validation, invalid base64, size limit, TTL cleanup, commit cleanup on save failure, SSH `connectionId` forwarding, and concurrent upload isolation. +- Unit: `src/renderer/src/web/web-preload-api.test.ts` for chunk sequencing, 512 KiB max chunks, `method_not_found` small-payload fallback, no large fallback frame, and abort on append/commit failure. +- Unit: `src/renderer/src/lib/worktree-status.test.ts` and `src/renderer/src/lib/worktree-activity-state.test.ts` for pending mirrored terminal inactivity, ready mirrored terminal activity, browser-only activity, and explicit agent-row promotion. +- Focused run: `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/clipboard.test.ts src/renderer/src/web/web-preload-api.test.ts src/renderer/src/lib/worktree-status.test.ts src/renderer/src/lib/worktree-activity-state.test.ts`. +- Type/lint: `pnpm typecheck`, `pnpm lint`. +- Paired-client validation: restart the host app, pair a real browser client, paste a screenshot large enough that the old one-shot RPC exceeded the 1 MiB encrypted frame cap into a local terminal and an SSH-backed terminal, confirm the runtime socket stays connected, then compare browser and host sidebar activity. + +## UI Quality Bar + +No layout changes. The paired browser sidebar should show active dots only for workspaces with live terminal PTYs, browser tabs, or explicit agent rows. Image paste should insert the generated temp image path into the terminal without connection-error toasts or DevTools runtime disconnect errors. + +## Review Screenshots + +1. Paired browser sidebar after hydration with inactive Done/Todo workspaces visible but not marked active. +2. Paired browser terminal after image paste, showing the generated temp image path inserted. +3. Host sidebar for the same session, showing matching active workspace state. + +## Rollout + +1. Add chunked clipboard RPC implementation and tests. +2. Switch web preload image paste to chunked upload and tests. +3. Tighten activity/status liveness helpers and tests. +4. Run focused tests, typecheck, and lint. +5. Validate in a paired Electron/browser session, including SSH paste, and capture the review screenshots. + +## Lightweight Eng Review + +- Scope: narrow to web clipboard transport and activity liveness. No global WebSocket limit, sidebar redesign, or local Electron clipboard changes. +- Architecture/data flow: web preload owns browser clipboard read and chunk sequencing; main runtime clipboard RPC owns upload session state and final temp-file save; sidebar helpers stay pure and consume the existing live-PTY map. +- Failure modes: oversized images must fail before a large frame is sent; abandoned uploads expire; failed append/commit paths clean up; pending host terminal mirrors do not inflate activity; SSH commit failures do not leak upload state. +- Tests: cover RPC lifecycle and bounds, web preload sequencing/fallback/abort, status/filter liveness, and real paired-client paste/sidebar parity. +- Performance/blast radius: chunking is not free. A max-size image is dozens of serialized runtime RPC calls and duplicates base64 in browser and main memory. The impact is limited to image paste by TTL, size, and concurrency caps. Activity changes affect sidebar filters, jump palette activity, and status dots. +- UI quality: no new chrome. Judge only sidebar parity, paste result, and absence of runtime disconnect/toast regressions. +- Screenshots: browser sidebar, browser terminal after paste, and matching host sidebar. +- Residual risk: clipboard permission and image conversion behavior are browser-dependent, so paired browser validation is required in addition to unit tests. diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index 872bb063dc1..d8b729d317c 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -11,23 +11,38 @@ vi.mock('../../../window/clipboard-image-temp-file', () => ({ saveClipboardImageBufferAsTempFile })) -import { CLIPBOARD_METHODS } from './clipboard' +import { + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, + CLIPBOARD_METHODS, + resetClipboardImageUploadsForTest +} from './clipboard' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } +function makeDispatcher(): RpcDispatcher { + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) +} + describe('clipboard RPC methods', () => { beforeEach(() => { saveClipboardImageBufferAsTempFile.mockReset() + resetClipboardImageUploadsForTest() + }) + + afterEach(() => { + vi.useRealTimers() + resetClipboardImageUploadsForTest() }) it('saves browser-provided clipboard image bytes on the runtime host', async () => { saveClipboardImageBufferAsTempFile.mockResolvedValue( 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png' ) - const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) + const dispatcher = makeDispatcher() const response = await dispatcher.dispatch( makeRequest('clipboard.saveImageAsTempFile', { @@ -46,8 +61,7 @@ describe('clipboard RPC methods', () => { }) it('rejects non-base64 clipboard image payloads', async () => { - const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) + const dispatcher = makeDispatcher() const response = await dispatcher.dispatch( makeRequest('clipboard.saveImageAsTempFile', { @@ -58,4 +72,264 @@ describe('clipboard RPC methods', () => { expect(response.ok).toBe(false) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) + + it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => { + saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: 'ssh-1' + }) + ) + expect(start.ok).toBe(true) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + const firstChunk = contentBase64.slice(0, 4) + const secondChunk = contentBase64.slice(4) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: firstChunk + }) + ) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: firstChunk.length, + contentBase64: secondChunk + }) + ) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: contentBase64.length } }) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { + connectionId: 'ssh-1' + }) + }) + + it('rejects out-of-order chunk offsets', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + const response = await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 4, + contentBase64: 'AAAA' + }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('rejects invalid base64 chunks and oversized chunks', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'not base64!' + }) + ) + ).resolves.toMatchObject({ ok: false }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4) + }) + ) + ).resolves.toMatchObject({ ok: false }) + }) + + it('rejects uploads beyond the existing total clipboard image limit', async () => { + const dispatcher = makeDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 24 * 1024 * 1024 + 1, + connectionId: null + }) + ) + + expect(response.ok).toBe(false) + }) + + it('rejects commit until all expected bytes arrive', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AAAA' + }) + ) + + const response = await dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('validates the complete base64 payload before saving', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 8, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64: 'AA==' + }) + ) + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 4, + contentBase64: 'AAAA' + }) + ) + + const response = await dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + + expect(response.ok).toBe(false) + expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() + }) + + it('deletes upload state after abort and treats repeated aborts as success', async () => { + const dispatcher = makeDispatcher() + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + }) + + it('deletes upload state when saving fails during commit', async () => { + saveClipboardImageBufferAsTempFile.mockRejectedValue(new Error('ssh write failed')) + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + const start = await dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: 'ssh-1' + }) + ) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + await dispatcher.dispatch( + makeRequest('clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64 + }) + ) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ) + ).resolves.toMatchObject({ ok: false }) + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1) + }) + + it('bounds concurrent uploads and releases slots through TTL cleanup', async () => { + vi.useFakeTimers() + const dispatcher = makeDispatcher() + for (let index = 0; index < CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT; index++) { + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: true }) + } + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: false }) + + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: true }) + }) }) diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index dc24dc6f920..4c5568f4e25 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,8 +1,75 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' +import { randomUUID } from 'node:crypto' const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024 +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 +export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 +const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +type ClipboardImageUpload = { + expectedBase64Length: number + connectionId?: string | null + chunks: string[] + receivedBase64Length: number + expiresAt: number + ttlTimer: ReturnType +} + +const clipboardImageUploads = new Map() + +function isValidBase64(value: string): boolean { + return value.length % 4 !== 1 && BASE64_PATTERN.test(value) +} + +function pruneExpiredUploads(now = Date.now()): void { + for (const [uploadId, upload] of clipboardImageUploads) { + if (upload.expiresAt <= now) { + deleteUpload(uploadId) + } + } +} + +function scheduleUploadExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => { + clipboardImageUploads.delete(uploadId) + }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer +} + +function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): void { + clearTimeout(upload.ttlTimer) + upload.expiresAt = Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS + upload.ttlTimer = scheduleUploadExpiry(uploadId) +} + +function deleteUpload(uploadId: string): void { + const upload = clipboardImageUploads.get(uploadId) + if (upload) { + clearTimeout(upload.ttlTimer) + } + clipboardImageUploads.delete(uploadId) +} + +function getUpload(uploadId: string): ClipboardImageUpload { + pruneExpiredUploads() + const upload = clipboardImageUploads.get(uploadId) + if (!upload) { + throw new Error('Clipboard image upload was not found') + } + return upload +} + +function assertValidBase64Content(value: string): void { + if (!isValidBase64(value)) { + throw new Error('Clipboard image content must be base64') + } +} const SaveImageAsTempFile = z.object({ contentBase64: z @@ -11,13 +78,40 @@ const SaveImageAsTempFile = z.object({ .refine((value) => value.length <= MAX_CLIPBOARD_IMAGE_BASE64_CHARS, { message: 'Clipboard image is too large' }) - .refine( - (value) => value.length % 4 !== 1 && /^[A-Za-z0-9+/]*={0,2}$/.test(value), - 'Clipboard image content must be base64' - ), + .refine(isValidBase64, 'Clipboard image content must be base64'), connectionId: z.string().min(1).nullable().optional() }) +const StartImageUpload = z.object({ + expectedBase64Length: z + .number() + .int() + .nonnegative() + .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, 'Clipboard image is too large'), + connectionId: z.string().min(1).nullable().optional() +}) + +const AppendImageUploadChunk = z.object({ + uploadId: z.string().min(1), + offset: z.number().int().nonnegative(), + contentBase64: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing image content' }) + .refine( + (value) => value.length <= CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + 'Clipboard image chunk is too large' + ) + .refine(isValidBase64, 'Clipboard image content must be base64') +}) + +const CommitImageUpload = z.object({ + uploadId: z.string().min(1) +}) + +const AbortImageUpload = z.object({ + uploadId: z.string().min(1) +}) + export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', @@ -26,5 +120,78 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), { connectionId: params.connectionId }) + }), + defineMethod({ + name: 'clipboard.startImageUpload', + params: StartImageUpload, + handler: (params) => { + pruneExpiredUploads() + if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { + throw new Error('Too many clipboard image uploads are in progress') + } + const uploadId = randomUUID() + clipboardImageUploads.set(uploadId, { + expectedBase64Length: params.expectedBase64Length, + connectionId: params.connectionId, + chunks: [], + receivedBase64Length: 0, + expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, + ttlTimer: scheduleUploadExpiry(uploadId) + }) + return { uploadId } + } + }), + defineMethod({ + name: 'clipboard.appendImageUploadChunk', + params: AppendImageUploadChunk, + handler: (params) => { + const upload = getUpload(params.uploadId) + if (params.offset !== upload.receivedBase64Length) { + throw new Error('Clipboard image chunk offset is out of order') + } + const nextLength = upload.receivedBase64Length + params.contentBase64.length + if (nextLength > upload.expectedBase64Length) { + throw new Error('Clipboard image upload exceeded expected size') + } + upload.chunks.push(params.contentBase64) + upload.receivedBase64Length = nextLength + refreshUploadExpiry(params.uploadId, upload) + return { receivedBase64Length: upload.receivedBase64Length } + } + }), + defineMethod({ + name: 'clipboard.commitImageUpload', + params: CommitImageUpload, + handler: async (params) => { + const upload = getUpload(params.uploadId) + try { + if (upload.receivedBase64Length !== upload.expectedBase64Length) { + throw new Error('Clipboard image upload is incomplete') + } + const contentBase64 = upload.chunks.join('') + assertValidBase64Content(contentBase64) + return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { + connectionId: upload.connectionId + }) + } finally { + // Why: failed SSH or filesystem commits must not leave bounded upload + // memory pinned until TTL cleanup. + deleteUpload(params.uploadId) + } + } + }), + defineMethod({ + name: 'clipboard.abortImageUpload', + params: AbortImageUpload, + handler: (params) => { + deleteUpload(params.uploadId) + return { aborted: true } + } }) ] + +export function resetClipboardImageUploadsForTest(): void { + for (const uploadId of clipboardImageUploads.keys()) { + deleteUpload(uploadId) + } +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 5d5a095fc2c..baec0eff6d3 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -144,7 +144,7 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([]) }) - it('treats paired web host terminal mirrors as active while their stream handle is pending', () => { + it('hides paired web host terminal mirrors while their stream handle is pending', () => { const wt = makeWorktree('wt-web-pending') const result = computeVisibleWorktreeIds( @@ -157,6 +157,22 @@ describe('computeVisibleWorktreeIds', () => { }) ) + expect(result).toEqual([]) + }) + + it('keeps paired web host terminal mirrors visible after their stream handle is ready', () => { + const wt = makeWorktree('wt-web-ready') + + const result = computeVisibleWorktreeIds( + { repo1: [wt] }, + [wt.id], + visibleOptions({ + showSleepingWorkspaces: false, + tabsByWorktree: { [wt.id]: [makeTab('web-terminal-host-tab-1', wt.id, null)] }, + ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-web-ready'] } + }) + ) + expect(result).toEqual([wt.id]) }) diff --git a/src/renderer/src/lib/worktree-activity-state.test.ts b/src/renderer/src/lib/worktree-activity-state.test.ts index 2198cebec8d..8548d88fd56 100644 --- a/src/renderer/src/lib/worktree-activity-state.test.ts +++ b/src/renderer/src/lib/worktree-activity-state.test.ts @@ -35,4 +35,32 @@ describe('worktree activity state', () => { ) ).toBe(false) }) + + it('treats pending paired web host terminal mirrors as inactive without a live pty', () => { + expect( + hasActiveWorkspaceActivity('wt-1', { 'wt-1': [makeTab('web-terminal-host-tab-1')] }, {}, {}) + ).toBe(false) + }) + + it('treats ready paired web host terminal mirrors as active with a live pty', () => { + expect( + hasActiveWorkspaceActivity( + 'wt-1', + { 'wt-1': [makeTab('web-terminal-host-tab-1')] }, + { 'web-terminal-host-tab-1': ['pty-1'] }, + {} + ) + ).toBe(true) + }) + + it('keeps browser-only workspaces active when mirrored terminals are pending', () => { + expect( + hasActiveWorkspaceActivity( + 'wt-1', + { 'wt-1': [makeTab('web-terminal-host-tab-1')] }, + {}, + { 'wt-1': [{ id: 'browser-1' }] } + ) + ).toBe(true) + }) }) diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index 193a90eae1e..fbd39c32e5b 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -1,5 +1,4 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { TerminalTab } from '../../../shared/types' type TerminalLikeTab = Pick @@ -18,9 +17,8 @@ export function hasActiveWorkspaceActivity( const tabs = tabsByWorktree?.[worktreeId] ?? [] const hasLiveTerminal = ptyIdsByTabId != null && tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) - const hasHostMirroredTerminal = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) const hasBrowser = (browserTabsByWorktree?.[worktreeId] ?? []).length > 0 - return hasLiveTerminal || hasHostMirroredTerminal || hasBrowser + return hasLiveTerminal || hasBrowser } export function isInactiveWorkspace( diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index a5bf0e0c56d..9e164ba8326 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -108,7 +108,7 @@ describe('resolveWorktreeStatus', () => { expect(status).toBe('done') }) - it('treats paired web host terminal mirrors as active while their stream handle is pending', () => { + it('treats pending paired web host terminal mirrors as inactive without a live pty', () => { const status = resolveWorktreeStatus({ tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }], browserTabs: [], @@ -119,6 +119,34 @@ describe('resolveWorktreeStatus', () => { hasRetainedDone: false }) + expect(status).toBe('inactive') + }) + + it('treats ready paired web host terminal mirrors as active once they have a live pty', () => { + const status = resolveWorktreeStatus({ + tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }], + browserTabs: [], + ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-1'] }, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + hasRetainedDone: false + }) + + expect(status).toBe('active') + }) + + it('keeps browser-only paired workspaces active without terminal liveness', () => { + const status = resolveWorktreeStatus({ + tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }], + browserTabs: [{ id: 'browser-1' }], + ptyIdsByTabId: {}, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + hasRetainedDone: false + }) + expect(status).toBe('active') }) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index b5da94e5146..0c05cd2033d 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,6 +1,5 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { TerminalTab } from '../../../shared/types' export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' @@ -51,12 +50,6 @@ export function getWorktreeStatus( // that rule instead of showing a misleading inactive dot. return 'active' } - if (tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))) { - // Why: paired web mirrors can know a host terminal tab exists before the - // runtime has issued a stream handle. Match desktop sidebar parity without - // treating ordinary slept wake-hint tabs as active. - return 'active' - } return 'inactive' } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 6ab81963d80..38209a9a98d 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -94,6 +94,36 @@ function writeStoredRuntimeEnvironment(storage: Storage): void { ) } +function installClipboardImageBase64(contentBase64: string): void { + vi.stubGlobal( + 'FileReader', + class { + result: string | ArrayBuffer | null = null + error: DOMException | null = null + onload: (() => void) | null = null + onerror: (() => void) | null = null + + readAsDataURL(blob: Blob): void { + this.result = `data:${blob.type};base64,${contentBase64}` + this.onload?.() + } + } + ) + vi.stubGlobal('navigator', { + userAgent: 'Linux', + hardwareConcurrency: 8, + clipboard: { + readText: vi.fn().mockResolvedValue(''), + read: vi.fn().mockResolvedValue([ + { + types: ['image/png'], + getType: vi.fn().mockResolvedValue(new Blob(['ignored'], { type: 'image/png' })) + } + ]) + } + }) +} + describe('web keybindings preload API', () => { beforeEach(() => { vi.resetModules() @@ -181,12 +211,28 @@ describe('web UI preload API', () => { vi.doUnmock('./web-runtime-client') }) - it('saves browser clipboard images through the paired host runtime', async () => { + it('saves browser clipboard images through bounded upload chunks', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(method: string, params?: unknown): Promise> { runtimeCalls.push({ method, params }) + if (method === 'clipboard.startImageUpload') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { uploadId: 'upload-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'clipboard.appendImageUploadChunk') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { receivedBase64Length: runtimeCalls.length }, + _meta: { runtimeId: 'runtime-1' } + }) + } return Promise.resolve({ id: `call-${runtimeCalls.length}`, ok: true, @@ -198,51 +244,92 @@ describe('web UI preload API', () => { close(): void {} } })) - vi.stubGlobal( - 'FileReader', - class { - result: string | ArrayBuffer | null = null - error: DOMException | null = null - onload: (() => void) | null = null - onerror: (() => void) | null = null - - readAsDataURL(blob: Blob): void { - void blob - .arrayBuffer() - .then((buffer) => { - this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}` - this.onload?.() - }) - .catch((error: DOMException) => { - this.error = error - this.onerror?.() - }) - } - } - ) const globals = installBrowserGlobals('Linux') - vi.stubGlobal('navigator', { - userAgent: 'Linux', - hardwareConcurrency: 8, - clipboard: { - readText: vi.fn().mockResolvedValue(''), - read: vi.fn().mockResolvedValue([ - { - types: ['image/png'], - getType: vi.fn().mockResolvedValue(new Blob(['png-bytes'], { type: 'image/png' })) - } - ]) - } - }) writeStoredRuntimeEnvironment(globals.storage) + const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } = + await import('./web-preload-api') + const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA` + installClipboardImageBase64(contentBase64) + installWebPreloadApi() + + await expect( + globals.window.api.ui.saveClipboardImageAsTempFile({ connectionId: 'ssh-1' }) + ).resolves.toBe('C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png') + expect(runtimeCalls).toEqual([ + { + method: 'clipboard.startImageUpload', + params: { + expectedBase64Length: contentBase64.length, + connectionId: 'ssh-1' + } + }, + { + method: 'clipboard.appendImageUploadChunk', + params: { + uploadId: 'upload-1', + offset: 0, + contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS) + } + }, + { + method: 'clipboard.appendImageUploadChunk', + params: { + uploadId: 'upload-1', + offset: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + contentBase64: 'AAAA' + } + }, + { + method: 'clipboard.commitImageUpload', + params: { uploadId: 'upload-1' } + } + ]) + }) + + it('falls back to one-shot clipboard save for small payloads when the host lacks upload RPCs', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'clipboard.startImageUpload') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: '/tmp/orca-paste-image.png', + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + installClipboardImageBase64(Buffer.from('png-bytes').toString('base64')) const { installWebPreloadApi } = await import('./web-preload-api') installWebPreloadApi() await expect( globals.window.api.ui.saveClipboardImageAsTempFile({ connectionId: null }) - ).resolves.toBe('C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png') + ).resolves.toBe('/tmp/orca-paste-image.png') expect(runtimeCalls).toEqual([ + { + method: 'clipboard.startImageUpload', + params: { + expectedBase64Length: Buffer.from('png-bytes').toString('base64').length, + connectionId: null + } + }, { method: 'clipboard.saveImageAsTempFile', params: { @@ -253,6 +340,167 @@ describe('web UI preload API', () => { ]) }) + it('does not send large one-shot fallback frames when upload RPCs are missing', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } = + await import('./web-preload-api') + installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4)) + installWebPreloadApi() + + await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow( + 'Unknown method' + ) + expect(runtimeCalls).toHaveLength(1) + expect(runtimeCalls[0]?.method).toBe('clipboard.startImageUpload') + }) + + it('aborts best-effort when append fails', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'clipboard.startImageUpload') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { uploadId: 'upload-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'clipboard.appendImageUploadChunk') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: false, + error: { code: 'runtime_error', message: 'bad chunk' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { aborted: true }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + installClipboardImageBase64('AAAA') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow('bad chunk') + expect(runtimeCalls.map((call) => call.method)).toEqual([ + 'clipboard.startImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.abortImageUpload' + ]) + }) + + it('aborts best-effort when commit fails', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'clipboard.startImageUpload') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { uploadId: 'upload-1' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'clipboard.commitImageUpload') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: false, + error: { code: 'runtime_error', message: 'save failed' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { aborted: true }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + installClipboardImageBase64('AAAA') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow( + 'save failed' + ) + expect(runtimeCalls.map((call) => call.method)).toEqual([ + 'clipboard.startImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.abortImageUpload' + ]) + }) + + it('rejects oversized converted clipboard images before starting an upload', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: null, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + installClipboardImageBase64('A'.repeat(24 * 1024 * 1024 + 4)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow( + 'Clipboard image is too large' + ) + expect(runtimeCalls).toEqual([]) + }) + it('migrates missing right sidebar visibility from the effective web legacy default', async () => { const { api } = await installApi('Linux') diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 608f7fbe940..c65f1c424af 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -70,6 +70,9 @@ const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' // Why: browser-paired clients need desktop parity for large dev sessions; the // runtime's no-limit default remains capped for lower-level RPC callers. const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 +const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024 +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 +export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() @@ -1618,14 +1621,7 @@ function createWebUiApi(): NonNullable['ui']> { if (!contentBase64) { return null } - return callRuntimeResult( - 'clipboard.saveImageAsTempFile', - { - contentBase64, - connectionId: args?.connectionId ?? null - }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) + return saveClipboardImageAsTempFileInRuntime(contentBase64, args) }, writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(), writeSelectionClipboardText: () => @@ -2058,6 +2054,70 @@ async function callRuntimeResult( return response.result as TResult } +async function saveClipboardImageAsTempFileInRuntime( + contentBase64: string, + args?: { connectionId?: string | null } +): Promise { + if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { + throw new Error('Clipboard image is too large') + } + const connectionId = args?.connectionId ?? null + const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( + 'clipboard.startImageUpload', + { expectedBase64Length: contentBase64.length, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + if (!startResponse.ok) { + if ( + startResponse.error.code === 'method_not_found' && + contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + ) { + return callRuntimeResult( + 'clipboard.saveImageAsTempFile', + { contentBase64, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + throw new Error(startResponse.error.message) + } + + const { uploadId } = startResponse.result + try { + for ( + let offset = 0; + offset < contentBase64.length; + offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) { + await callRuntimeResult( + 'clipboard.appendImageUploadChunk', + { + uploadId, + offset, + contentBase64: contentBase64.slice( + offset, + offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) + }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + return await callRuntimeResult( + 'clipboard.commitImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } catch (error) { + // Why: once chunked paste has created server-side state, failed append or + // commit must not wait for TTL cleanup before releasing the bounded slot. + await callRuntimeResult( + 'clipboard.abortImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ).catch(() => {}) + throw error + } +} + async function getRemoteRuntimeStatus(): Promise { return callRuntimeResult('status.get', undefined, 15_000) } From 861e519113811f967391560945286a2087f5a888 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 30 May 2026 03:39:27 -0400 Subject: [PATCH 19/57] fix: improve reset countdown copy (#3529) Co-authored-by: Orca --- .../src/components/status-bar/tooltip.test.ts | 13 ++++++++++++- src/renderer/src/components/status-bar/tooltip.tsx | 13 +++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 02c0a68fbeb..d28c4fae2fd 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -import { getWindowSections } from './tooltip' +import { formatResetCountdown, getWindowSections } from './tooltip' + +describe('formatResetCountdown', () => { + it('uses natural copy when the reset time has arrived', () => { + expect(formatResetCountdown(0)).toBe('Resets now') + expect(formatResetCountdown(-1)).toBe('Resets now') + }) + + it('keeps the "in" preposition for future reset times', () => { + expect(formatResetCountdown(12 * 60 * 60_000 + 41 * 60_000)).toBe('Resets in 12h 41m') + }) +}) describe('getWindowSections', () => { it('returns buckets as sections when present', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index acf6bdc4b0a..1d0bc68471c 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -36,6 +36,11 @@ function formatDuration(ms: number): string { return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` } +export function formatResetCountdown(ms: number): string { + const duration = formatDuration(ms) + return duration === 'now' ? 'Resets now' : `Resets in ${duration}` +} + // --------------------------------------------------------------------------- // Shared icon component // --------------------------------------------------------------------------- @@ -129,7 +134,7 @@ function TooltipWindowSection({ return null } const leftPct = Math.max(0, Math.round(100 - w.usedPercent)) - const resetIn = w.resetsAt ? formatDuration(w.resetsAt - Date.now()) : null + const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null return (
@@ -142,7 +147,7 @@ function TooltipWindowSection({
{leftPct}% left - {resetIn && Resets in {resetIn}} + {resetLabel && {resetLabel}}
) @@ -289,7 +294,7 @@ export function ProviderPanel({ return null } const leftPct = Math.max(0, Math.round(100 - w.usedPercent)) - const resetIn = w.resetsAt ? formatDuration(w.resetsAt - Date.now()) : null + const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null return (
@@ -302,7 +307,7 @@ export function ProviderPanel({
{leftPct}% left - {resetIn && Resets in {resetIn}} + {resetLabel && {resetLabel}}
) From 7286a6553bc1b7db40dede7b2a252213405d7d9b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:21 -0700 Subject: [PATCH 20/57] Add sidebar folder drop for projects (#3207) --- .../window/attach-main-window-services.ts | 27 ++-- src/preload/api-types.ts | 11 +- src/preload/index.ts | 85 ++++-------- .../src/components/sidebar/AddRepoDialog.tsx | 101 +++++++++----- src/renderer/src/components/sidebar/index.tsx | 23 +++ .../sidebar/sidebar-project-drop.test.ts | 69 +++++++++ .../sidebar/sidebar-project-drop.ts | 61 ++++++++ .../sidebar/useSidebarProjectDrop.ts | 131 ++++++++++++++++++ src/shared/native-file-drop.test.ts | 57 ++++++++ src/shared/native-file-drop.ts | 87 ++++++++++++ 10 files changed, 533 insertions(+), 119 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sidebar-project-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-project-drop.ts create mode 100644 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts create mode 100644 src/shared/native-file-drop.test.ts create mode 100644 src/shared/native-file-drop.ts diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4f8f981d852..8bc36c3343b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -33,6 +33,7 @@ import type { RuntimeMarkdownSaveTabResult } from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' @@ -373,25 +374,15 @@ function registerRuntimeWindowLifecycle( function registerFileDropRelay(mainWindow: BrowserWindow): void { ipcMain.removeAllListeners('terminal:file-dropped-from-preload') - ipcMain.on( - 'terminal:file-dropped-from-preload', - ( - _event, - args: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => { - if (mainWindow.isDestroyed()) { - return - } - - // Why: relay exactly one IPC event per drop gesture so the renderer - // receives the full batch of paths without timer-based reconstruction. - mainWindow.webContents.send('terminal:file-drop', args) + ipcMain.on('terminal:file-dropped-from-preload', (_event, args: NativeFileDropPayload) => { + if (mainWindow.isDestroyed()) { + return } - ) + + // Why: relay exactly one IPC event per drop gesture so the renderer + // receives the full batch of paths without timer-based reconstruction. + mainWindow.webContents.send('terminal:file-drop', args) + }) } export function registerUpdaterHandlers(_store: Store): void { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fce1dc1d607..89a1fa75910 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -7,6 +7,7 @@ import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { BaseRefDefaultResult, @@ -1951,15 +1952,7 @@ export type PreloadApi = { writeClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise - onFileDrop: ( - callback: ( - data: - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - ) => void - ) => () => void + onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void syncTrafficLights: (zoomFactor: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 26e016048c7..580efb1a12f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,27 +146,20 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + NATIVE_FILE_DROP_TARGET, + ORCA_INTERNAL_FILE_DRAG_TYPE, + hasNativeFileDragTypes, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' -type NativeDropResolution = - | { target: 'editor' } - | { target: 'terminal'; tabId?: string } - | { target: 'composer' } - | { target: 'file-explorer'; destinationDir: string } - // Why: returned when the explorer marker was found but no destinationDir - // could be resolved. The caller must suppress the drop entirely instead of - // falling back to 'editor' — fail-closed behavior per design §7.1. - | { target: 'rejected' } - -type NativeFileDropPayload = - | { paths: string[]; target: 'editor' } - | { paths: string[]; target: 'terminal'; tabId?: string } - | { paths: string[]; target: 'composer' } - | { paths: string[]; target: 'file-explorer'; destinationDir: string } - type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] @@ -298,43 +291,17 @@ function disposeCachedNotificationSound(): void { * "inside this folder". */ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const path = event.composedPath() - let foundExplorer = false - let destinationDir: string | undefined - - for (const entry of path) { - if (!(entry instanceof HTMLElement)) { - continue - } - - const target = entry.dataset.nativeFileDropTarget - if (target === 'terminal') { - return { target, tabId: entry.dataset.terminalTabId } - } - if (target === 'editor' || target === 'composer') { - return { target } - } - if (target === 'file-explorer') { - foundExplorer = true - } - - // Pick the nearest (innermost) destination directory marker - if (destinationDir === undefined && entry.dataset.nativeFileDropDir) { - destinationDir = entry.dataset.nativeFileDropDir + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId + }) } } - - if (foundExplorer) { - // Why: routing must fail closed for explorer drops. If preload sees the - // explorer target marker but cannot resolve a destinationDir, it rejects - // the gesture and emits no fallback editor drop event. - if (!destinationDir) { - return { target: 'rejected' } - } - return { target: 'file-explorer', destinationDir } - } - - return null + return resolveNativeFileDropPath(pathEntries) } // --------------------------------------------------------------------------- @@ -347,7 +314,7 @@ document.addEventListener( (e) => { // Let in-app drags (e.g. file explorer drag-to-move) through to React handlers // so they can set their own dropEffect. Only override for native OS file drops. - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { return } e.preventDefault() @@ -362,7 +329,7 @@ document.addEventListener( 'drop', (e) => { // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes('text/x-orca-file-path')) { + if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { return } @@ -398,20 +365,20 @@ document.addEventListener( // The preload layer already has the full FileList. Re-emitting one IPC // message per path and asking the renderer to reconstruct the gesture via // timing would be both fragile and slower under large drops. - if (resolution?.target === 'file-explorer') { + if (resolution?.target === NATIVE_FILE_DROP_TARGET.fileExplorer) { ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: 'file-explorer', + target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir: resolution.destinationDir }) } else { // Why: falls back to 'editor' so drops on surfaces without an explicit - // marker (sidebar, editor body, etc.) preserve the prior open-in-editor - // behavior instead of being silently discarded. + // marker preserve the prior open-in-editor behavior instead of being + // silently discarded. ipcRenderer.send('terminal:file-dropped-from-preload', { paths, - target: resolution?.target ?? 'editor', - ...(resolution?.target === 'terminal' && resolution.tabId + target: resolution?.target ?? NATIVE_FILE_DROP_TARGET.editor, + ...(resolution?.target === NATIVE_FILE_DROP_TARGET.terminal && resolution.tabId ? { tabId: resolution.tabId } : {}) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index a4ca6fecd14..7ec5bdc2988 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -56,6 +56,7 @@ function defaultProjectGroupNameForPath(path: string): string { const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const addRepoPath = useAppStore((s) => s.addRepoPath) const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) @@ -108,6 +109,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) + // Why: a dropped path is modal data, so ordinary state updates must not + // re-run the import while the Add Project dialog advances through steps. + const droppedLocalPathHandledRef = useRef(null) // Why: track whether we've already auto-filled for this entry into the clone step, // so a late settings hydration still gets a chance to set the default. const cloneStepAutoFilledRef = useRef(false) @@ -197,6 +201,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { }, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir]) const isOpen = activeModal === 'add-repo' + const droppedLocalPath = + typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' const projectId = addedRepo?.id ?? '' const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) @@ -261,6 +267,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { // Why: reset state on close so reopening doesn't show stale step/repo. useEffect(() => { if (!isOpen) { + droppedLocalPathHandledRef.current = null resetState() } }, [isOpen, resetState]) @@ -272,6 +279,65 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { step === 'create' || step === 'nested' + const handleAddLocalPath = useCallback( + async (path: string, source: AddRepoExistingWorkspaceSource) => { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + toast.error('Use a server path to add projects from a remote runtime.') + closeModal() + return + } + setIsAdding(true) + try { + const attemptId = createNestedRepoTelemetryAttemptId() + const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan + }) + ) + if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { + setNestedScan(scan) + setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) + setNestedGroupName(defaultProjectGroupNameForPath(path)) + setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('local') + setStep('nested') + return + } + const repo = await addRepoPath(path) + if (repo && isGitRepoKind(repo)) { + setAddedRepo(repo) + setExistingWorkspaceSource(source) + await fetchWorktrees(repo.id) + setStep('setup') + } else if (repo) { + // Why: folder repos skip the Git worktree setup step and activate + // their synthetic root workspace in the folder add flow. + closeModal() + } + } finally { + setIsAdding(false) + } + }, + [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, settings?.activeRuntimeEnvironmentId] + ) + + useEffect(() => { + if (!isOpen || !droppedLocalPath) { + return + } + if (droppedLocalPathHandledRef.current === droppedLocalPath) { + return + } + droppedLocalPathHandledRef.current = droppedLocalPath + void handleAddLocalPath(droppedLocalPath, 'local_folder_picker') + }, [droppedLocalPath, handleAddLocalPath, isOpen]) + const handleBrowse = useCallback(async () => { setIsAdding(true) try { @@ -279,42 +345,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (!path) { return } - const attemptId = createNestedRepoTelemetryAttemptId() - const scan = await scanNestedRepos(path) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'sidebar', - runtimeKind: 'local', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedGroupName(defaultProjectGroupNameForPath(path)) - setNestedConnectionId(null) - setNestedAttemptId(attemptId) - setNestedRuntimeKind('local') - setStep('nested') - return - } - const repo = await addRepoPath(path) - if (repo && isGitRepoKind(repo)) { - setAddedRepo(repo) - setExistingWorkspaceSource('local_folder_picker') - await fetchWorktrees(repo.id) - setStep('setup') - } else if (repo) { - // Why: folder repos skip the Git worktree setup step and activate - // their synthetic root workspace in the folder add flow. - closeModal() - } + await handleAddLocalPath(path, 'local_folder_picker') } finally { setIsAdding(false) } - }, [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos]) + }, [handleAddLocalPath]) const handleImportNestedRepos = useCallback( async (mode: 'group' | 'separate') => { diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index f712e820b7f..d42737a0fd1 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -16,6 +16,9 @@ import ProjectAddedDialog from './ProjectAddedDialog' import WorktreeVisibilityDialog from './WorktreeVisibilityDialog' import OrcaYamlTrustDialog from './OrcaYamlTrustDialog' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import { cn } from '@/lib/utils' +import { FolderPlus, Loader2 } from 'lucide-react' +import { useSidebarProjectDrop } from './useSidebarProjectDrop' const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -37,6 +40,7 @@ function Sidebar({ const setSidebarWidth = useAppStore((s) => s.setSidebarWidth) const repos = useAppStore((s) => s.repos) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) + const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop() const setLiveSidebarWidth = React.useCallback((width: number) => { document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`) @@ -64,7 +68,9 @@ function Sidebar({
{sidebarOpen && ( <> @@ -84,6 +90,23 @@ function Sidebar({ )} + {sidebarOpen && affordance.visible ? ( +
+ {affordance.tone === 'busy' ? ( + + ) : ( + + )} +
{affordance.label}
+
{affordance.description}
+
+ ) : null} + {/* Resize handle */} {sidebarOpen && (
{ + it('accepts exactly one dropped path', () => { + expect(resolveSidebarProjectDropPath(['/Users/alice/repo'])).toEqual({ + status: 'ready', + path: '/Users/alice/repo' + }) + }) + + it('rejects empty and multi-path drops before routing', () => { + expect(resolveSidebarProjectDropPath([])).toEqual({ status: 'empty' }) + expect(resolveSidebarProjectDropPath(['/repo/a', '/repo/b'])).toEqual({ + status: 'multiple', + count: 2 + }) + }) +}) + +describe('isRemoteRuntimeActive', () => { + it('distinguishes local runtime from active server runtime', () => { + expect(isRemoteRuntimeActive(null)).toBe(false) + expect(isRemoteRuntimeActive({ activeRuntimeEnvironmentId: ' ' })).toBe(false) + expect(isRemoteRuntimeActive({ activeRuntimeEnvironmentId: 'server-1' })).toBe(true) + }) +}) + +describe('getSidebarProjectDropAffordance', () => { + it('hides when the sidebar is not in a drop interaction', () => { + expect( + getSidebarProjectDropAffordance({ + isDragOver: false, + isHandlingDrop: false, + remoteRuntimeActive: false + }) + ).toEqual({ visible: false }) + }) + + it('shows ready, busy, and blocked states', () => { + expect( + getSidebarProjectDropAffordance({ + isDragOver: true, + isHandlingDrop: false, + remoteRuntimeActive: false + }) + ).toMatchObject({ visible: true, tone: 'ready' }) + + expect( + getSidebarProjectDropAffordance({ + isDragOver: false, + isHandlingDrop: true, + remoteRuntimeActive: false + }) + ).toMatchObject({ visible: true, tone: 'busy' }) + + expect( + getSidebarProjectDropAffordance({ + isDragOver: true, + isHandlingDrop: false, + remoteRuntimeActive: true + }) + ).toMatchObject({ visible: true, tone: 'blocked' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-project-drop.ts b/src/renderer/src/components/sidebar/sidebar-project-drop.ts new file mode 100644 index 00000000000..d76c05b543d --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-project-drop.ts @@ -0,0 +1,61 @@ +import type { GlobalSettings } from '../../../../shared/types' + +export type SidebarProjectDropPathResolution = + | { status: 'ready'; path: string } + | { status: 'empty' } + | { status: 'multiple'; count: number } + +export type SidebarProjectDropAffordance = + | { visible: false } + | { visible: true; tone: 'ready' | 'blocked' | 'busy'; label: string; description: string } + +export function resolveSidebarProjectDropPath( + paths: readonly string[] +): SidebarProjectDropPathResolution { + const usablePaths = paths.filter((path) => path.length > 0) + if (usablePaths.length === 0) { + return { status: 'empty' } + } + if (usablePaths.length > 1) { + return { status: 'multiple', count: usablePaths.length } + } + return { status: 'ready', path: usablePaths[0] } +} + +export function isRemoteRuntimeActive( + settings: Pick | null | undefined +): boolean { + return Boolean(settings?.activeRuntimeEnvironmentId?.trim()) +} + +export function getSidebarProjectDropAffordance(args: { + isDragOver: boolean + isHandlingDrop: boolean + remoteRuntimeActive: boolean +}): SidebarProjectDropAffordance { + if (!args.isDragOver && !args.isHandlingDrop) { + return { visible: false } + } + if (args.isHandlingDrop) { + return { + visible: true, + tone: 'busy', + label: 'Checking folder', + description: 'Preparing the project add flow' + } + } + if (args.remoteRuntimeActive) { + return { + visible: true, + tone: 'blocked', + label: 'Server runtime active', + description: 'Use Add Project for server paths' + } + } + return { + visible: true, + tone: 'ready', + label: 'Drop folder to add project', + description: 'Local folders and Git repositories' + } +} diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts new file mode 100644 index 00000000000..73c97f59ebb --- /dev/null +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { + NATIVE_FILE_DROP_TARGET, + hasNativeFileDragTypes +} from '../../../../shared/native-file-drop' +import { useAppStore } from '@/store' +import { + getSidebarProjectDropAffordance, + isRemoteRuntimeActive, + resolveSidebarProjectDropPath +} from './sidebar-project-drop' + +type SidebarProjectDropHandlers = { + onDragEnter: (event: React.DragEvent) => void + onDragOver: (event: React.DragEvent) => void + onDragLeave: (event: React.DragEvent) => void +} + +export function useSidebarProjectDrop(): { + nativeDropTarget: typeof NATIVE_FILE_DROP_TARGET.projectSidebar + dropHandlers: SidebarProjectDropHandlers + affordance: ReturnType +} { + const openModal = useAppStore((s) => s.openModal) + const settings = useAppStore((s) => s.settings) + const [isDragOver, setIsDragOver] = useState(false) + const [isHandlingDrop, setIsHandlingDrop] = useState(false) + const dragDepthRef = useRef(0) + const remoteRuntimeActive = isRemoteRuntimeActive(settings) + + const clearDragState = useCallback(() => { + dragDepthRef.current = 0 + setIsDragOver(false) + }, []) + + useEffect(() => { + document.addEventListener('drop', clearDragState, true) + document.addEventListener('dragend', clearDragState, true) + return () => { + document.removeEventListener('drop', clearDragState, true) + document.removeEventListener('dragend', clearDragState, true) + } + }, [clearDragState]) + + const handleProjectDropPaths = useCallback( + async (paths: readonly string[]) => { + const pathResolution = resolveSidebarProjectDropPath(paths) + if (pathResolution.status === 'empty') { + return + } + if (pathResolution.status === 'multiple') { + toast.warning('Drop one folder at a time.') + return + } + if (remoteRuntimeActive) { + toast.error('Local folder drops are unavailable for server runtimes.', { + description: 'Use Add Project to enter a server path.' + }) + return + } + + setIsHandlingDrop(true) + try { + await window.api.fs.authorizeExternalPath({ targetPath: pathResolution.path }) + const stat = await window.api.fs.stat({ filePath: pathResolution.path }) + if (!stat.isDirectory) { + toast.error('Drop a folder to add it as a project.') + return + } + openModal('add-repo', { droppedLocalPath: pathResolution.path }) + } catch (error) { + toast.error('Could not add dropped folder.', { + description: error instanceof Error ? error.message : String(error) + }) + } finally { + setIsHandlingDrop(false) + } + }, + [openModal, remoteRuntimeActive] + ) + + useEffect(() => { + return window.api.ui.onFileDrop((data) => { + if (data.target !== NATIVE_FILE_DROP_TARGET.projectSidebar) { + return + } + void handleProjectDropPaths(data.paths) + }) + }, [handleProjectDropPaths]) + + const dropHandlers = useMemo( + () => ({ + onDragEnter: (event) => { + if (!hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + dragDepthRef.current += 1 + setIsDragOver(true) + }, + onDragOver: (event) => { + if (!hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = remoteRuntimeActive ? 'none' : 'copy' + setIsDragOver(true) + }, + onDragLeave: (event) => { + if (!hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setIsDragOver(false) + } + } + }), + [remoteRuntimeActive] + ) + + return { + nativeDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar, + dropHandlers, + affordance: getSidebarProjectDropAffordance({ + isDragOver, + isHandlingDrop, + remoteRuntimeActive + }) + } +} diff --git a/src/shared/native-file-drop.test.ts b/src/shared/native-file-drop.test.ts new file mode 100644 index 00000000000..53ee4bf0486 --- /dev/null +++ b/src/shared/native-file-drop.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + NATIVE_FILE_DROP_TARGET, + ORCA_INTERNAL_FILE_DRAG_TYPE, + hasNativeFileDragTypes, + resolveNativeFileDropPath +} from './native-file-drop' + +describe('hasNativeFileDragTypes', () => { + it('accepts native OS file drags', () => { + expect(hasNativeFileDragTypes(['Files'])).toBe(true) + }) + + it('rejects internal Orca file moves and URL/text drags', () => { + expect(hasNativeFileDragTypes(['Files', ORCA_INTERNAL_FILE_DRAG_TYPE])).toBe(false) + expect(hasNativeFileDragTypes(['text/uri-list'])).toBe(false) + expect(hasNativeFileDragTypes(['text/plain'])).toBe(false) + }) +}) + +describe('resolveNativeFileDropPath', () => { + it('routes drops on the project sidebar to the add-project surface', () => { + expect( + resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar }]) + ).toEqual({ target: NATIVE_FILE_DROP_TARGET.projectSidebar }) + }) + + it('preserves terminal tab routing for native file drops', () => { + expect( + resolveNativeFileDropPath([ + { + nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.terminal, + terminalTabId: 'tab-1' + } + ]) + ).toEqual({ target: NATIVE_FILE_DROP_TARGET.terminal, tabId: 'tab-1' }) + }) + + it('uses the nearest file-explorer destination and fails closed without one', () => { + expect( + resolveNativeFileDropPath([ + { nativeFileDropDir: '/repo/src' }, + { + nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer, + nativeFileDropDir: '/repo' + } + ]) + ).toEqual({ + target: NATIVE_FILE_DROP_TARGET.fileExplorer, + destinationDir: '/repo/src' + }) + + expect( + resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.fileExplorer }]) + ).toEqual({ target: 'rejected' }) + }) +}) diff --git a/src/shared/native-file-drop.ts b/src/shared/native-file-drop.ts new file mode 100644 index 00000000000..4c5ef2aa9ee --- /dev/null +++ b/src/shared/native-file-drop.ts @@ -0,0 +1,87 @@ +export const ORCA_INTERNAL_FILE_DRAG_TYPE = 'text/x-orca-file-path' + +export const NATIVE_FILE_DROP_TARGET = { + editor: 'editor', + terminal: 'terminal', + composer: 'composer', + fileExplorer: 'file-explorer', + projectSidebar: 'project-sidebar' +} as const + +export type NativeFileDropTarget = + (typeof NATIVE_FILE_DROP_TARGET)[keyof typeof NATIVE_FILE_DROP_TARGET] + +export type NativeDropResolution = + | { target: typeof NATIVE_FILE_DROP_TARGET.editor } + | { target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string } + | { target: typeof NATIVE_FILE_DROP_TARGET.composer } + | { target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer; destinationDir: string } + | { target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar } + | { target: 'rejected' } + +export type NativeFileDropPayload = + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.editor } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.terminal; tabId?: string } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.composer } + | { + paths: string[] + target: typeof NATIVE_FILE_DROP_TARGET.fileExplorer + destinationDir: string + } + | { paths: string[]; target: typeof NATIVE_FILE_DROP_TARGET.projectSidebar } + +export type NativeFileDropPathEntry = { + nativeFileDropTarget?: string + nativeFileDropDir?: string + terminalTabId?: string +} + +export function getDataTransferTypes( + types: Iterable | ArrayLike | null | undefined +): string[] { + return types ? Array.from(types) : [] +} + +export function hasNativeFileDragTypes( + types: Iterable | ArrayLike | null | undefined +): boolean { + const values = getDataTransferTypes(types) + return values.includes('Files') && !values.includes(ORCA_INTERNAL_FILE_DRAG_TYPE) +} + +export function resolveNativeFileDropPath( + path: readonly NativeFileDropPathEntry[] +): NativeDropResolution | null { + let foundExplorer = false + let destinationDir: string | undefined + + for (const entry of path) { + const target = entry.nativeFileDropTarget + if (target === NATIVE_FILE_DROP_TARGET.terminal) { + return { target, tabId: entry.terminalTabId } + } + if (target === NATIVE_FILE_DROP_TARGET.editor || target === NATIVE_FILE_DROP_TARGET.composer) { + return { target } + } + if (target === NATIVE_FILE_DROP_TARGET.projectSidebar) { + return { target } + } + if (target === NATIVE_FILE_DROP_TARGET.fileExplorer) { + foundExplorer = true + } + + // Pick the nearest (innermost) destination directory marker. + if (destinationDir === undefined && entry.nativeFileDropDir) { + destinationDir = entry.nativeFileDropDir + } + } + + if (foundExplorer) { + if (!destinationDir) { + return { target: 'rejected' } + } + return { target: NATIVE_FILE_DROP_TARGET.fileExplorer, destinationDir } + } + + return null +} From 3c5831fd51ca8377e3f45c3617d2fe8c6a563b68 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:51 -0700 Subject: [PATCH 21/57] fix: guard terminal cwd async updates (#3530) --- .../FloatingTerminalPanel.tsx | 20 +++++++++++++++++-- .../OnboardingInlineCommandTerminal.tsx | 10 +++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ddac1b9149d..84ca4f83727 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -356,15 +356,31 @@ export function FloatingTerminalPanel({ }, [bounds.left, bounds.width, open]) useEffect(() => { + let cancelled = false void window.api.app .getFloatingTerminalCwd({ path: floatingTerminalCwd }) - .then(setCwd) + .then((nextCwd) => { + if (!cancelled) { + setCwd(nextCwd) + } + }) + return () => { + cancelled = true + } }, [floatingTerminalCwd]) useEffect(() => { - void window.api.app.getFloatingMarkdownDirectory().then(setMarkdownCwd) + let cancelled = false + void window.api.app.getFloatingMarkdownDirectory().then((nextMarkdownCwd) => { + if (!cancelled) { + setMarkdownCwd(nextMarkdownCwd) + } + }) + return () => { + cancelled = true + } }, []) useEffect(() => { diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index ea35702269f..f07b15516a1 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -62,7 +62,15 @@ export function OnboardingInlineCommandTerminal({ }, [onOpened]) useEffect(() => { - void window.api.app.getFloatingTerminalCwd({ path: '~' }).then(setCwd) + let cancelled = false + void window.api.app.getFloatingTerminalCwd({ path: '~' }).then((nextCwd) => { + if (!cancelled) { + setCwd(nextCwd) + } + }) + return () => { + cancelled = true + } }, []) useEffect(() => { From aa60da9cf1b5c8b7461cd75d6b2cfc697d018ae3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:45:39 -0700 Subject: [PATCH 22/57] fix: guard settings async state updates (#3532) --- .../src/components/settings/GeneralPane.tsx | 10 ++++++- .../src/components/settings/VoicePane.tsx | 26 ++++++++++++++++--- .../src/lib/windows-terminal-capabilities.ts | 10 ++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 2937d23917e..fc363efccfd 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -128,7 +128,15 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea >('loading') useEffect(() => { - window.api.updater.getVersion().then(setAppVersion) + let cancelled = false + void window.api.updater.getVersion().then((version) => { + if (!cancelled) { + setAppVersion(version) + } + }) + return () => { + cancelled = true + } }, []) useEffect(() => { diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index 1d228e5e09d..dd057108c8e 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../shared/types' import { getDefaultVoiceSettings } from '../../../../shared/constants' import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types' @@ -32,13 +32,29 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const shortcutLabel = useShortcutLabel('voice.dictation') const [catalog, setCatalog] = useState([]) const [permissionPending, setPermissionPending] = useState(false) + const mountedRef = useRef(true) useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + useEffect(() => { + let cancelled = false refreshModelStates() - window.api.speech + void window.api.speech .getCatalog() - .then(setCatalog) + .then((nextCatalog) => { + if (!cancelled) { + setCatalog(nextCatalog) + } + }) .catch(() => {}) + return () => { + cancelled = true + } }, [refreshModelStates]) useEffect(() => { @@ -84,7 +100,9 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J } catch { toast.error('Could not request microphone permission. Voice dictation was not enabled.') } finally { - setPermissionPending(false) + if (mountedRef.current) { + setPermissionPending(false) + } } } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 51819a22964..caaa59176ad 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -96,12 +96,20 @@ export function useWindowsTerminalCapabilities( return } + let cancelled = false const cached = getCachedWindowsTerminalCapabilities() setCapabilities(cachedCapabilities ? cached : { ...cached, isLoading: true }) subscribers.add(setCapabilities) - void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount }).then(setCapabilities) + void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount }).then( + (nextCapabilities) => { + if (!cancelled) { + setCapabilities(nextCapabilities) + } + } + ) return () => { + cancelled = true subscribers.delete(setCapabilities) } }, [enabled, forceRefreshOnMount]) From e0d9c4c2b082f02eb82164bf87557dd347fd40f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:49:56 -0700 Subject: [PATCH 23/57] fix: guard ssh action state resets (#3533) --- .../src/components/settings/SshTargetCard.tsx | 24 +++++++++++++++---- .../src/components/sidebar/SshTargetRow.tsx | 16 +++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index d5ac86a6bbf..c1b0c139320 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { CircleStop, Loader2, @@ -85,13 +85,27 @@ export function SshTargetCard({ const terminateInFlight = actionInFlight === 'terminate' || busyAction === 'terminate' const resetInFlight = actionInFlight === 'reset' || busyAction === 'reset' const removeInFlight = busyAction === 'remove' + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const clearActionInFlight = (): void => { + if (mountedRef.current) { + setActionInFlight(null) + } + } const handleConnect = (): void => { if (actionInFlight) { return } setActionInFlight('connect') - Promise.resolve(onConnect(target.id)).finally(() => setActionInFlight(null)) + void Promise.resolve(onConnect(target.id)).finally(clearActionInFlight) } const handleDisconnect = (): void => { @@ -99,7 +113,7 @@ export function SshTargetCard({ return } setActionInFlight('disconnect') - Promise.resolve(onDisconnect(target.id)).finally(() => setActionInFlight(null)) + void Promise.resolve(onDisconnect(target.id)).finally(clearActionInFlight) } const handleTerminateSessions = (): void => { @@ -107,7 +121,7 @@ export function SshTargetCard({ return } setActionInFlight('terminate') - Promise.resolve(onTerminateSessions(target.id)).finally(() => setActionInFlight(null)) + void Promise.resolve(onTerminateSessions(target.id)).finally(clearActionInFlight) } const handleResetRelay = (): void => { @@ -115,7 +129,7 @@ export function SshTargetCard({ return } setActionInFlight('reset') - Promise.resolve(onResetRelay(target.id)).finally(() => setActionInFlight(null)) + void Promise.resolve(onResetRelay(target.id)).finally(clearActionInFlight) } const renderEndRemoteTerminalsButton = (): React.JSX.Element => ( diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 35a67303bc1..30338596f07 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -4,7 +4,7 @@ * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit * while isolating the inline-connect interaction logic. */ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' @@ -22,6 +22,7 @@ export function SshTargetRow({ onConnect }: Props): React.JSX.Element { const [connecting, setConnecting] = useState(false) + const mountedRef = useRef(true) const status = target.state?.status ?? 'disconnected' const isConnected = status === 'connected' const isBusy = @@ -49,9 +50,20 @@ export function SshTargetRow({ return } setConnecting(true) - void onConnect(target.id).finally(() => setConnecting(false)) + void onConnect(target.id).finally(() => { + if (mountedRef.current) { + setConnecting(false) + } + }) } + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + return (
Date: Sat, 30 May 2026 00:50:45 -0700 Subject: [PATCH 24/57] fix: address pr-bug-scan validated finding from #2138 (#2155) Threaded operation tag through SSH executeCommitMessagePlan/cancelGenerateCommitMessage and relay lane key; reverted commit-message emptyResultName from 'details' to default. Co-authored-by: orca-bug-scan-bot --- .../agent-hooks/first-work-branch-rename.ts | 3 +- src/main/ipc/filesystem.test.ts | 41 ++++- src/main/ipc/filesystem.ts | 12 +- src/main/providers/ssh-git-provider.test.ts | 122 ++++++++----- src/main/providers/ssh-git-provider.ts | 64 ++++--- src/main/runtime/orca-runtime-git.ts | 10 +- .../commit-message-text-generation.test.ts | 160 ++++++++++++++++++ .../commit-message-text-generation.ts | 24 +-- src/relay/agent-exec-handler.test.ts | 66 ++++++++ src/relay/agent-exec-handler.ts | 34 ++-- .../worktree-agent-activity-summary.ts | 2 +- 11 files changed, 441 insertions(+), 97 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index ac427172753..20a73d753ec 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -250,7 +250,8 @@ async function resolveGenerationTarget( return { kind: 'remote', cwd: worktreePath, - execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' } } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index a6bfe4c5213..3722855261f 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -31,9 +31,11 @@ const { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted(() => ({ @@ -64,9 +66,11 @@ const { listWorktreesMock: vi.fn(), resolveCommitMessageSettingsMock: vi.fn(), generateCommitMessageFromContextMock: vi.fn(), + generatePullRequestFieldsFromContextMock: vi.fn(), discoverCommitMessageModelsLocalMock: vi.fn(), discoverCommitMessageModelsRemoteMock: vi.fn(), cancelGenerateCommitMessageLocalMock: vi.fn(), + cancelGeneratePullRequestFieldsLocalMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn() })) @@ -139,9 +143,11 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../text-generation/commit-message-text-generation', () => ({ resolveCommitMessageSettings: resolveCommitMessageSettingsMock, generateCommitMessageFromContext: generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContext: generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock, - cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock + cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock })) import { registerFilesystemHandlers } from './filesystem' @@ -219,9 +225,11 @@ describe('registerFilesystemHandlers', () => { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, + cancelGeneratePullRequestFieldsLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock ]) { @@ -1192,17 +1200,44 @@ describe('registerFilesystemHandlers', () => { await target.execute( { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, '/cwd', - 1 + 1, + 'commit-message' ) expect(executeCommitMessagePlan).toHaveBeenCalledWith( { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, '/cwd', - 1 + 1, + 'commit-message' ) expect(prepareForCodexLaunch).not.toHaveBeenCalled() expect(prepareForClaudeLaunch).not.toHaveBeenCalled() }) + it('routes SSH generation cancellations to separate provider operations', async () => { + const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined) + getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage }) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:cancelGenerateCommitMessage')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + await handlers.get('git:cancelGeneratePullRequestFields')!(null, { + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + + expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message') + expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith( + 2, + '/remote/repo', + 'pull-request-fields' + ) + expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled() + expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled() + }) + it('does not call the generator when no staged changes exist', async () => { resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 8d57e5a15bf..5fb43bd5c1f 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -823,8 +823,8 @@ export function registerFilesystemHandlers( return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -865,7 +865,7 @@ export function registerFilesystemHandlers( if (!provider) { return } - await provider.cancelGenerateCommitMessage(args.worktreePath) + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) @@ -967,8 +967,8 @@ export function registerFilesystemHandlers( return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -1017,7 +1017,7 @@ export function registerFilesystemHandlers( if (!provider) { return } - await provider.cancelGenerateCommitMessage(args.worktreePath) + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index f3cf469a1c6..0d0c334342b 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -241,12 +241,80 @@ describe('SshGitProvider', () => { args: ['exec', 'PROMPT'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 60_000, + operation: 'commit-message' }) expect(result).toEqual(execResult) }) - it('serializes non-interactive relay execs for the same cwd', async () => { + it('keeps SSH commit-message and pull-request execution lanes separate', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation((method) => { + if (method === 'agent.cancelExec') { + return Promise.resolve({ canceled: true }) + } + return new Promise((resolve) => { + completeRequests.push(() => + resolve({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false + }) + ) + }) + }) + const plan = { + binary: 'codex', + args: ['exec', 'PROMPT'], + stdinPayload: null, + label: 'Codex' + } + + const commit = provider.executeCommitMessagePlan(plan, '/home/user/repo', 60_000) + const pullRequest = provider.executeCommitMessagePlan( + plan, + '/home/user/repo', + 60_000, + 'pull-request-fields' + ) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenNthCalledWith(1, 'agent.execNonInteractive', { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'commit-message' + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { + binary: 'codex', + args: ['exec', 'PROMPT'], + cwd: '/home/user/repo', + stdin: null, + timeoutMs: 60_000, + operation: 'pull-request-fields' + }) + + await provider.cancelGenerateCommitMessage('/home/user/repo') + await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields') + + expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'commit-message' + }) + expect(mux.request).toHaveBeenNthCalledWith(4, 'agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'pull-request-fields' + }) + + completeRequests.shift()?.() + completeRequests.shift()?.() + await Promise.all([commit, pullRequest]) + }) + + it('serializes non-interactive relay execs for the same cwd and operation', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( () => @@ -263,16 +331,7 @@ describe('SshGitProvider', () => { ) const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) @@ -282,11 +341,11 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 2) expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], + binary: 'pnpm', + args: ['install'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 8000 }) completeRequests.shift()?.() await second @@ -309,16 +368,7 @@ describe('SshGitProvider', () => { ) const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) await provider.cancelNonInteractiveExec('/home/user/repo') @@ -360,16 +410,7 @@ describe('SshGitProvider', () => { 8000, controller.signal ) - const second = provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) + const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) await waitForRequestCount(mux.request, 1) controller.abort() @@ -381,11 +422,11 @@ describe('SshGitProvider', () => { await first await waitForRequestCount(mux.request, 3) expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.execNonInteractive', { - binary: 'codex', - args: ['exec', 'PROMPT'], + binary: 'pnpm', + args: ['install'], cwd: '/home/user/repo', stdin: null, - timeoutMs: 60_000 + timeoutMs: 8000 }) completeRequests.shift()?.() await second @@ -394,7 +435,10 @@ describe('SshGitProvider', () => { it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => { await provider.cancelGenerateCommitMessage('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) + expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { + cwd: '/home/user/repo', + operation: 'commit-message' + }) }) it('getDiff sends git.diff request', async () => { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2cbce98b597..b96361e527e 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -105,15 +105,22 @@ export class SshGitProvider implements IGitProvider { async executeCommitMessagePlan( plan: CommitMessagePlan, cwd: string, - timeoutMs: number + timeoutMs: number, + operation = 'commit-message' ): Promise { - return this.runQueuedNonInteractiveExec(cwd, { - binary: plan.binary, - args: plan.args, + return this.runQueuedNonInteractiveExec( cwd, - stdin: plan.stdinPayload, - timeoutMs - }) + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) } async execNonInteractive( @@ -138,28 +145,38 @@ export class SshGitProvider implements IGitProvider { ) } - async cancelNonInteractiveExec(cwd: string): Promise { - const queue = this.nonInteractiveExecQueues.get(cwd) + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) if (queuedEntry) { queuedEntry.canceled = true return } - await this.cancelActiveNonInteractiveExec(cwd) + await this.cancelActiveNonInteractiveExec(cwd, operation) } - private async cancelActiveNonInteractiveExec(cwd: string): Promise { + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { try { - await this.mux.request('agent.cancelExec', { cwd }) + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) } catch { // Best-effort: callers are already unwinding after cancellation. } } - async cancelGenerateCommitMessage(worktreePath: string): Promise { + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { // Why: best-effort — the relay returns `{canceled: false}` when there is // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath) + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) } private async runQueuedNonInteractiveExec( @@ -171,10 +188,13 @@ export class SshGitProvider implements IGitProvider { stdin: string | null timeoutMs: number env?: Record + operation?: string }, - signal?: AbortSignal + signal?: AbortSignal, + operation?: string ): Promise { - const queue = this.nonInteractiveExecQueues.get(cwd) ?? [] + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] const previous = queue.at(-1)?.done ?? Promise.resolve() let releaseEntry!: () => void const entry: NonInteractiveExecQueueEntry = { @@ -191,13 +211,13 @@ export class SshGitProvider implements IGitProvider { release: () => releaseEntry() } queue.push(entry) - this.nonInteractiveExecQueues.set(cwd, queue) + this.nonInteractiveExecQueues.set(laneKey, queue) const abortEntry = (): void => { if (!entry.started) { entry.canceled = true return } - void this.cancelActiveNonInteractiveExec(cwd) + void this.cancelActiveNonInteractiveExec(cwd, operation) } if (signal?.aborted) { entry.canceled = true @@ -205,8 +225,8 @@ export class SshGitProvider implements IGitProvider { signal?.addEventListener('abort', abortEntry, { once: true }) } - // Why: the SSH relay tracks one non-interactive child per cwd; serializing - // here keeps cache cleanup and commit-message generation from overwriting it. + // Why: the SSH relay tracks children per operation; serialize only matching + // lanes so commit-message and PR-field generation can coexist. await previous.catch(() => {}) try { if (entry.canceled) { @@ -226,13 +246,13 @@ export class SshGitProvider implements IGitProvider { } finally { signal?.removeEventListener('abort', abortEntry) entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(cwd) + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) const entryIndex = currentQueue?.indexOf(entry) ?? -1 if (entryIndex >= 0) { currentQueue?.splice(entryIndex, 1) } if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(cwd) + this.nonInteractiveExecQueues.delete(laneKey) } } } diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index b456cf56029..a4288e56079 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -482,7 +482,8 @@ export class RuntimeGitCommands { return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -515,7 +516,7 @@ export class RuntimeGitCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { - await provider?.cancelGenerateCommitMessage(target.worktree.path) + await provider?.cancelGenerateCommitMessage(target.worktree.path, 'commit-message') return { ok: true } } cancelGenerateCommitMessageLocal(target.worktree.path) @@ -584,7 +585,8 @@ export class RuntimeGitCommands { return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, - execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs), + execute: (plan, cwd, timeoutMs, operation) => + provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } @@ -607,7 +609,7 @@ export class RuntimeGitCommands { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { - await provider?.cancelGenerateCommitMessage(target.worktree.path) + await provider?.cancelGenerateCommitMessage(target.worktree.path, 'pull-request-fields') return { ok: true } } cancelGeneratePullRequestFieldsLocal(target.worktree.path) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index d267ce15375..14704c7847b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -610,6 +610,42 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('reports empty remote commit-message output as an empty message', async () => { + let operation = '' + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => { + operation = requestedOperation + return { + stdout: ' \n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(operation).toBe('commit-message') + expect(result).toEqual({ + success: false, + error: 'agent returned an empty message.' + }) + }) + it('sanitizes remote execution transport failures', async () => { const result = await generateCommitMessageFromContext( { @@ -813,6 +849,130 @@ describe('generateCommitMessageFromContext', () => { expect(children[1]?.kill).not.toHaveBeenCalled() }) + it('keeps local pull-request cancellation from stopping commit-message generation', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + + const commit = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const pullRequest = generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + cancelGeneratePullRequestFieldsLocal('/repo') + + expect(children[0]?.kill).not.toHaveBeenCalled() + expect(children[1]?.kill).toHaveBeenCalledWith('SIGKILL') + + const commitStdout = children[0]?.listeners.get('stdout:data') + commitStdout?.(Buffer.from('Update README\n')) + children[0]?.listeners.get('close')?.(0) + children[1]?.listeners.get('close')?.(null) + + await expect(commit).resolves.toEqual({ + success: true, + message: 'Update README', + agentLabel: 'agent' + }) + await expect(pullRequest).resolves.toEqual({ + success: false, + error: 'Generation canceled.', + canceled: true, + branchChangedByPreparation: false + }) + }) + + it('reports empty remote pull-request field output as empty details', async () => { + let operation = '' + const result = await generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (_plan, _cwd, _timeoutMs, requestedOperation) => { + operation = requestedOperation + return { + stdout: ' \n', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + ) + + expect(operation).toBe('pull-request-fields') + expect(result).toEqual({ + success: false, + error: 'agent returned an empty details.', + branchChangedByPreparation: false + }) + }) + it('reports branch changes when pull request field output cannot be parsed', async () => { const listeners = new Map void>() spawnMock.mockReturnValue({ diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index c56d309b50c..4bb5192ae80 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -84,6 +84,8 @@ export type RemoteCommitMessageExecResult = { spawnError?: string } +export type TextGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' + export type CommitMessageGenerationTarget = | { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv } | { @@ -92,7 +94,8 @@ export type CommitMessageGenerationTarget = execute: ( plan: CommitMessagePlan, cwd: string, - timeoutMs: number + timeoutMs: number, + operation: TextGenerationOperation ) => Promise missingBinaryLocation: string } @@ -428,13 +431,11 @@ function killProcessTree(child: ChildProcess): void { } } -type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' - // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -function localLaneKey(operation: LocalGenerationOperation, cwd: string): string { +function localLaneKey(operation: TextGenerationOperation, cwd: string): string { return `${operation}:local:${cwd}` } @@ -447,7 +448,7 @@ async function runLocalPlan( cwd: string, env: NodeJS.ProcessEnv | undefined, emptyResultName = 'message', - operation: LocalGenerationOperation = 'commit-message' + operation: TextGenerationOperation = 'commit-message' ): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { @@ -621,12 +622,13 @@ function finalizeFromAgentOutput(args: { async function runRemotePlan( plan: CommitMessagePlan, target: Extract, - emptyResultName = 'message' + emptyResultName = 'message', + operation: TextGenerationOperation = 'commit-message' ): Promise { const { binary, label } = plan let result: RemoteCommitMessageExecResult try { - result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS) + result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS, operation) } catch (error) { console.error('[commit-message] Remote generator request failed:', error) return { @@ -709,8 +711,8 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target, 'details') - : await runLocalPlan(planned.plan, target.cwd, target.env, 'details') + ? await runRemotePlan(planned.plan, target) + : await runLocalPlan(planned.plan, target.cwd, target.env) return formatCommitMessageGenerationResult(internalResult) } @@ -761,7 +763,7 @@ export async function generatePullRequestFieldsFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target) + ? await runRemotePlan(planned.plan, target, 'details', 'pull-request-fields') : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') return formatPullRequestFieldsGenerationResult(internalResult, context) } @@ -788,7 +790,7 @@ export async function generateBranchNameFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target, 'branch name') + ? await runRemotePlan(planned.plan, target, 'branch name', 'branch-name') : await runLocalPlan(planned.plan, target.cwd, target.env, 'branch name', 'branch-name') if (!internalResult.success) { return internalResult diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index da402b7d193..edf9c57bcbf 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -253,6 +253,72 @@ describe('AgentExecHandler', () => { }) }) + it('cancels only the matching operation lane for a cwd', async () => { + const commitChild = createFakeChild() + const pullRequestChild = createFakeChild() + pullRequestChild.pid = 12346 + spawnMock + .mockReturnValueOnce(commitChild as never) + .mockReturnValueOnce(pullRequestChild as never) + const handlers = createHandlers() + + const commit = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'commit-message' + }, + requestContext() + ) + const pullRequest = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent', + args: [], + cwd: '/repo', + stdin: null, + timeoutMs: 5_000, + operation: 'pull-request-fields' + }, + requestContext() + ) + + await expect( + handlers.get('agent.cancelExec')!( + { cwd: '/repo', operation: 'commit-message' }, + requestContext() + ) + ).resolves.toEqual({ canceled: true }) + + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith('taskkill /pid 12345 /T /F', expect.any(Function)) + expect(execMock).not.toHaveBeenCalledWith('taskkill /pid 12346 /T /F', expect.any(Function)) + } else { + expect(commitChild.kill).toHaveBeenCalledWith('SIGKILL') + expect(pullRequestChild.kill).not.toHaveBeenCalled() + } + + commitChild.emit('close', null) + pullRequestChild.stdout.emit( + 'data', + Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') + ) + pullRequestChild.emit('close', 0) + + await expect(commit).resolves.toMatchObject({ + exitCode: null, + timedOut: false, + canceled: true + }) + await expect(pullRequest).resolves.toMatchObject({ + exitCode: 0, + timedOut: false, + canceled: false + }) + }) + it('reports when cancellation has no matching in-flight command', async () => { const handlers = createHandlers() diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 38dc75601a4..2a0ca4ae61a 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -94,12 +94,21 @@ type ExecParams = { stdin: unknown timeoutMs: unknown env: unknown + operation: unknown } type CancelParams = { cwd: unknown + operation: unknown } +function laneKeyFor(cwd: string, operation: unknown): string { + const op = typeof operation === 'string' && operation ? operation : 'default' + return JSON.stringify([op, cwd]) +} + +type InFlightExec = { child: ChildProcess; markCanceled: () => void } + type ExecResult = { stdout: string stderr: string @@ -119,10 +128,13 @@ type ExecResult = { * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { - // Why: a single in-flight exec per cwd is enough — the renderer prevents - // double-clicks. Keying by cwd lets `agent.cancelExec({cwd})` find the - // child without the client having to track relay-internal request ids. - private inFlightByCwd = new Map void }>() + // Why: commit-message and PR-field generation can run together for one cwd; + // operation lanes let cancel target only the user-visible job that stopped. + private inFlightByLane = new Map() + + private laneKey(cwd: string, operation: unknown): string { + return laneKeyFor(cwd, operation) + } constructor(dispatcher: RelayDispatcher) { dispatcher.onRequest('agent.execNonInteractive', (p) => this.exec(p as ExecParams)) @@ -131,7 +143,7 @@ export class AgentExecHandler { private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' - const entry = this.inFlightByCwd.get(cwd) + const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) if (!entry) { return { canceled: false } } @@ -185,24 +197,26 @@ export class AgentExecHandler { let timedOut = false let canceled = false let settled = false - const laneKey = typeof cwd === 'string' ? cwd : '' + const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : '' + let entry: InFlightExec | null = null const finish = (result: ExecResult): void => { if (settled) { return } settled = true - if (laneKey) { - this.inFlightByCwd.delete(laneKey) + if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { + this.inFlightByLane.delete(laneKey) } resolve(result) } if (laneKey) { - this.inFlightByCwd.set(laneKey, { + entry = { child, markCanceled: () => { canceled = true } - }) + } + this.inFlightByLane.set(laneKey, entry) } const timer = setTimeout(() => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 112ea539c48..e38b4b111b5 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -97,7 +97,7 @@ function getWorktreeAgentActivitySummaries( } } - for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { summaryForWorktree(retained.worktreeId).hasRetainedDone = true } From f18dc88834913e0fc9e594783bd10c0b1c8c917a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 00:54:19 -0700 Subject: [PATCH 25/57] fix: guard crash report async state (#3536) --- .../crash-report/CrashReportDialog.tsx | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index c8bad2f979e..e06ea8a12b9 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -21,6 +21,7 @@ function formatSummary(report: CrashReportRecord): string { export function CrashReportDialog(): React.JSX.Element { const promptedThisLaunch = useRef(false) + const mountedRef = useRef(true) const [open, setOpen] = useState(false) const [report, setReport] = useState(null) const [notes, setNotes] = useState('') @@ -53,17 +54,29 @@ export function CrashReportDialog(): React.JSX.Element { console.error('Failed to dismiss crash report after startup prompt:', error) } } + if (!mountedRef.current) { + return + } setReport(displayedReport) - if (nextReport && promptIfPresent) { + if (nextReport && promptIfPresent && mountedRef.current) { setOpen(true) } } catch (error) { console.error('Failed to load crash report:', error) } finally { - setLoading(false) + if (mountedRef.current) { + setLoading(false) + } } } + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { if (promptedThisLaunch.current) { return @@ -74,7 +87,11 @@ export function CrashReportDialog(): React.JSX.Element { useEffect(() => { return window.api.ui.onOpenCrashReport(() => { - void loadCrashReport(false).then(() => setOpen(true)) + void loadCrashReport(false).then(() => { + if (mountedRef.current) { + setOpen(true) + } + }) }) }, []) @@ -118,13 +135,17 @@ export function CrashReportDialog(): React.JSX.Element { const dismissReportIfNeeded = async (): Promise => { if (report?.status === 'pending') { await window.api.crashReports.dismiss({ reportId: report.id }) - setReport({ ...report, status: 'dismissed' }) + if (mountedRef.current) { + setReport({ ...report, status: 'dismissed' }) + } } } const handleDismiss = async (): Promise => { await dismissReportIfNeeded() - setOpen(false) + if (mountedRef.current) { + setOpen(false) + } } const handleSubmit = async (): Promise => { @@ -145,6 +166,9 @@ export function CrashReportDialog(): React.JSX.Element { if (!result.ok) { throw new Error(result.error) } + if (!mountedRef.current) { + return + } setReport(result.report) setNotes('') toast.success('Crash report sent.') @@ -153,7 +177,9 @@ export function CrashReportDialog(): React.JSX.Element { toast.error('Failed to send crash report.') console.error('Failed to submit crash report:', error) } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } } @@ -165,7 +191,11 @@ export function CrashReportDialog(): React.JSX.Element { return } if (!nextOpen) { - void dismissReportIfNeeded().finally(() => setOpen(false)) + void dismissReportIfNeeded().finally(() => { + if (mountedRef.current) { + setOpen(false) + } + }) return } setOpen(true) From a7bd64e6319bc825ce11b90e7f16b95f3f26d4ca Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 30 May 2026 01:00:27 -0700 Subject: [PATCH 26/57] Put AI Capabilities at top of Settings (#3512) * Move AI Capabilities settings near top Add docs/ai-capabilities-settings-top.md for the design rationale. * Highlight AI capabilities at top of settings * rm design doc --- .../src/components/settings/Settings.tsx | 140 +++++++++--------- .../useSettingsNavigationMetadata.test.ts | 24 +++ .../hooks/useSettingsNavigationMetadata.ts | 76 +++++----- 3 files changed, 134 insertions(+), 106 deletions(-) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index a67705af4f5..eeffdeedea7 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -67,10 +67,10 @@ import { } from './settings-load-performance' const SETTINGS_NAV_GROUPS = [ + { id: 'capabilities', title: 'AI Capabilities' }, { id: 'setup', title: 'Set Up' }, { id: 'workflows', title: 'Workflows' }, { id: 'interface', title: 'Interface' }, - { id: 'capabilities', title: 'AI Capabilities' }, { id: 'remote', title: 'Remote Access' }, { id: 'safety', title: 'Safety' }, { id: 'experimental', title: 'Experimental' } @@ -754,17 +754,6 @@ function Settings(): React.JSX.Element {
) : ( - - {isSectionMounted('general') ? ( - - ) : null} - - + + {isSectionMounted('orchestration') ? : null} + + + {showDesktopOnlySettings ? ( + <> + + + + + + + + {computerUsePlatform} Computer Use is an early preview. Some apps + and desktop environments may behave inconsistently. + + + + + ) : null + } + description="Enable agents to control any app on your computer." + searchEntries={getSectionSearchEntries('computer-use')} + > + {isSectionMounted('computer-use') ? : null} + + + + {isSectionMounted('voice') ? ( + + ) : null} + + + ) : null} + + + {isSectionMounted('general') ? ( + + ) : null} + + : null} - - {isSectionMounted('orchestration') ? : null} - - - {showDesktopOnlySettings ? ( - <> - - - - - - - - {computerUsePlatform} Computer Use is an early preview. Some apps - and desktop environments may behave inconsistently. - - - - - ) : null - } - description="Enable agents to control any app on your computer." - searchEntries={getSectionSearchEntries('computer-use')} - > - {isSectionMounted('computer-use') ? : null} - - - - {isSectionMounted('voice') ? ( - - ) : null} - - - ) : null} - { + it('puts AI capability panes at the top on desktop', () => { + expect(ids().slice(0, 8)).toEqual([ + 'agents', + 'accounts', + 'orchestration', + 'computer-use', + 'voice', + 'general', + 'integrations', + 'git' + ]) + }) + + it('puts web-safe AI capability panes at the top while hiding desktop-only panes', () => { + expect(ids({ isWebClient: true }).slice(0, 6)).toEqual([ + 'agents', + 'accounts', + 'orchestration', + 'general', + 'integrations', + 'git' + ]) + }) + it('keeps desktop-only Settings panes out of web metadata', () => { const webIds = ids({ isWebClient: true }) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 78ad6c15d3c..e39f0e72516 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -92,21 +92,13 @@ export function buildSettingsNavigationMetadata({ : RUNTIME_ENVIRONMENTS_SEARCH_ENTRY return [ - { - id: 'general', - title: 'General', - description: 'Workspace defaults, app setup, and maintenance.', - icon: SlidersHorizontal, - searchEntries: GENERAL_PANE_SEARCH_ENTRIES, - group: 'setup' - }, { id: 'agents', title: 'Agents', description: 'Manage AI agents, set a default, and customize commands.', icon: Bot, searchEntries: AGENTS_PANE_SEARCH_ENTRIES, - group: 'setup' + group: 'capabilities' }, { id: 'accounts', @@ -114,9 +106,47 @@ export function buildSettingsNavigationMetadata({ description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.', icon: UserCog, searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES, - group: 'setup', + group: 'capabilities', badge: 'Optional' }, + { + id: 'orchestration', + title: 'Orchestration', + description: 'Coordinate multiple coding agents through Orca.', + icon: Network, + searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES, + group: 'capabilities' + }, + ...(showDesktopOnlySettings + ? [ + { + id: 'computer-use', + title: 'Computer Use', + description: 'Enable agents to control any app on your computer.', + icon: MousePointerClick, + searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES, + group: 'capabilities', + badge: 'Beta' + }, + { + id: 'voice', + title: 'Voice', + description: 'Local speech-to-text dictation with on-device models.', + icon: Mic, + searchEntries: VOICE_PANE_SEARCH_ENTRIES, + group: 'capabilities', + badge: 'Beta' + } + ] + : []), + { + id: 'general', + title: 'General', + description: 'Workspace defaults, app setup, and maintenance.', + icon: SlidersHorizontal, + searchEntries: GENERAL_PANE_SEARCH_ENTRIES, + group: 'setup' + }, { id: 'integrations', title: 'Integrations', @@ -203,14 +233,6 @@ export function buildSettingsNavigationMetadata({ } ] : []), - { - id: 'orchestration', - title: 'Orchestration', - description: 'Coordinate multiple coding agents through Orca.', - icon: Network, - searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES, - group: 'capabilities' - }, { id: 'servers', title: 'Remote Orca Servers', @@ -239,24 +261,6 @@ export function buildSettingsNavigationMetadata({ icon: Smartphone, searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES, group: 'remote' - }, - { - id: 'computer-use', - title: 'Computer Use', - description: 'Enable agents to control any app on your computer.', - icon: MousePointerClick, - searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES, - group: 'capabilities', - badge: 'Beta' - }, - { - id: 'voice', - title: 'Voice', - description: 'Local speech-to-text dictation with on-device models.', - icon: Mic, - searchEntries: VOICE_PANE_SEARCH_ENTRIES, - group: 'capabilities', - badge: 'Beta' } ] : []), From be520d2fa5b31b953f137d54cbe3d4f458b8c2df Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:00:29 -0700 Subject: [PATCH 27/57] fix: replace workspace port advertised listener (#3539) --- src/main/ipc/workspace-ports.test.ts | 20 ++++++++++++++++++++ src/main/ipc/workspace-ports.ts | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index c688cdb156f..ce66b79b8b6 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -258,6 +258,26 @@ describe('registerWorkspacePortHandlers', () => { port: 3002 }) }) + + it('unsubscribes the previous advertised URL listener when handlers are registered again', () => { + const store = makeStore() + const firstUnsubscribe = vi.fn() + const secondUnsubscribe = vi.fn() + const onDidChange = vi + .fn() + .mockReturnValueOnce(firstUnsubscribe) + .mockReturnValueOnce(secondUnsubscribe) + + registerWorkspacePortHandlers(store as never, { + advertisedUrlEvents: { onDidChange } + }) + registerWorkspacePortHandlers(store as never, { + advertisedUrlEvents: { onDidChange } + }) + + expect(firstUnsubscribe).toHaveBeenCalledTimes(1) + expect(secondUnsubscribe).not.toHaveBeenCalled() + }) }) function workspacePort({ pid, port }: { pid: number; port: number }): WorkspacePort { diff --git a/src/main/ipc/workspace-ports.ts b/src/main/ipc/workspace-ports.ts index b2041fa4e09..27d1df254d7 100644 --- a/src/main/ipc/workspace-ports.ts +++ b/src/main/ipc/workspace-ports.ts @@ -19,6 +19,8 @@ type WorkspacePortHandlersOptions = { getWindows?: () => BrowserWindow[] } +let unsubscribeAdvertisedUrlChanges: (() => void) | null = null + export function registerWorkspacePortHandlers( store: Store, options: WorkspacePortHandlersOptions = {} @@ -27,7 +29,8 @@ export function registerWorkspacePortHandlers( const advertisedUrlEvents = options.advertisedUrlEvents ?? advertisedUrlWatcher const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows()) - advertisedUrlEvents.onDidChange((event) => { + unsubscribeAdvertisedUrlChanges?.() + unsubscribeAdvertisedUrlChanges = advertisedUrlEvents.onDidChange((event) => { const localWorktrees = getStoreWorkspacePortProbes(store) if (!localWorktrees.some((worktree) => worktree.id === event.worktreeId)) { return From b7057471ae96ce33d1c166edb62a0d1bed170b6e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:01:21 -0700 Subject: [PATCH 28/57] fix: clear floating icon menu frame from trigger ref (#3540) --- .../FloatingTerminalIconContextMenu.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx index 3384ef9ef7a..68c540b6146 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { EyeOff, PanelBottom, PanelTop } from 'lucide-react' import { DropdownMenu, @@ -28,15 +28,14 @@ export function FloatingTerminalIconContextMenu({ const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const reopenFrameRef = useRef(null) - useEffect( - () => () => { + const setTriggerRef = useCallback((node: HTMLSpanElement | null): void => { + if (node === null) { if (reopenFrameRef.current !== null) { window.cancelAnimationFrame(reopenFrameRef.current) reopenFrameRef.current = null } - }, - [] - ) + } + }, []) const moveAction = useMemo(() => { if (currentLocation === 'floating-button') { @@ -56,6 +55,7 @@ export function FloatingTerminalIconContextMenu({ return ( <> Date: Sat, 30 May 2026 01:02:36 -0700 Subject: [PATCH 29/57] Improve terminal typing latency with many worktrees Index runtime terminal leaves by PTY id so active TUI redraws no longer scan every live terminal before renderer delivery. --- src/main/runtime/orca-runtime.test.ts | 54 +++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 68 +++++++++++++++------------ 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3e22639615c..6867cca9e93 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -837,6 +837,60 @@ describe('OrcaRuntimeService', () => { expect(shown.ptyId).toBe('pty-1') }) + it('routes PTY output through the PTY leaf index in large terminal graphs', () => { + const runtime = new OrcaRuntimeService(store) + const liveLeafCount = 2773 + const targetIndex = liveLeafCount - 17 + const tabs = Array.from({ length: liveLeafCount }, (_, index) => ({ + tabId: `tab-${index}`, + worktreeId: `repo-1::/tmp/worktree-${index}`, + title: `Terminal ${index}`, + activeLeafId: 'pane:1', + layout: null + })) + const leaves = Array.from({ length: liveLeafCount }, (_, index) => ({ + tabId: `tab-${index}`, + worktreeId: `repo-1::/tmp/worktree-${index}`, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: `pty-${index}` + })) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs, leaves }) + + const runtimePrivate = runtime as unknown as { + leaves: Map + leavesByPtyId: Map + } + const originalLeaves = runtimePrivate.leaves + runtimePrivate.leaves = new Proxy(originalLeaves, { + get(target, prop) { + if ( + prop === 'values' || + prop === 'entries' || + prop === 'keys' || + prop === Symbol.iterator + ) { + return () => { + throw new Error('onPtyData should use the PTY leaf index') + } + } + const value = Reflect.get(target, prop, target) + return typeof value === 'function' ? value.bind(target) : value + } + }) as Map + + runtime.onPtyData(`pty-${targetIndex}`, 'hello indexed\n', 123) + + const [targetLeaf] = runtimePrivate.leavesByPtyId.get(`pty-${targetIndex}`) ?? [] + expect(targetLeaf).toMatchObject({ + preview: 'hello indexed', + lastOutputAt: 123 + }) + expect(runtime.getStatus().liveLeafCount).toBe(liveLeafCount) + }) + it('resolves branch selectors when worktrees store refs/heads-prefixed branches', async () => { vi.mocked(listWorktrees).mockResolvedValueOnce([ { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b3c3f42262c..6b38055a34e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1054,6 +1054,9 @@ export class OrcaRuntimeService { private mobileSessionTabsByWorktree = new Map() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() private leaves = new Map() + // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by + // ptyId keeps active TUI redraws independent of the total open terminal count. + private leavesByPtyId = new Map() private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() @@ -1772,6 +1775,7 @@ export class OrcaRuntimeService { } this.leaves = nextLeaves + this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' this.refreshWritableFlags() @@ -2215,10 +2219,8 @@ export class OrcaRuntimeService { registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { this.handleByPtyId.set(ptyId, handle) - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - this.adoptPreAllocatedHandle(leaf) - } + for (const leaf of this.getLeavesForPty(ptyId)) { + this.adoptPreAllocatedHandle(leaf) } } @@ -2227,12 +2229,10 @@ export class OrcaRuntimeService { if (pty) { pty.connected = true } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - leaf.connected = true - leaf.writable = this.graphStatus === 'ready' - this.adoptPreAllocatedHandle(leaf) - } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.connected = true + leaf.writable = this.graphStatus === 'ready' + this.adoptPreAllocatedHandle(leaf) } } @@ -2310,10 +2310,7 @@ export class OrcaRuntimeService { } } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId !== ptyId) { - continue - } + for (const leaf of this.getLeavesForPty(ptyId)) { this.recordPtyWorktree(ptyId, leaf.worktreeId, { connected: true, lastOutputAt: pty?.lastOutputAt ?? at, @@ -2608,15 +2605,13 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - // Why: seed lastOscTitle even when the seeded title doesn't classify - // as an agent state, so worktree.ps recomputes status from the live - // title rather than treating the leaf as agentless. - leaf.lastOscTitle = title - if (status !== null) { - leaf.lastAgentStatus = status - } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: seed lastOscTitle even when the seeded title doesn't classify + // as an agent state, so worktree.ps recomputes status from the live + // title rather than treating the leaf as agentless. + leaf.lastOscTitle = title + if (status !== null) { + leaf.lastAgentStatus = status } } } @@ -3471,10 +3466,7 @@ export class OrcaRuntimeService { this.pruneDisconnectedPtyTranscript(pty) } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId !== ptyId) { - continue - } + for (const leaf of this.getLeavesForPty(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false @@ -9945,6 +9937,7 @@ export class OrcaRuntimeService { this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() this.leaves.clear() + this.leavesByPtyId.clear() this.handles.clear() this.handleByLeafKey.clear() // Why: same as markRendererReloading — pre-allocated CLI handles must @@ -10714,12 +10707,27 @@ export class OrcaRuntimeService { } private leafExistsForPty(ptyId: string): boolean { + return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 + } + + private rebuildLeafPtyIndex(): void { + const next = new Map() for (const leaf of this.leaves.values()) { - if (leaf.ptyId === ptyId) { - return true + if (!leaf.ptyId) { + continue + } + const leaves = next.get(leaf.ptyId) + if (leaves) { + leaves.push(leaf) + } else { + next.set(leaf.ptyId, [leaf]) } } - return false + this.leavesByPtyId = next + } + + private getLeavesForPty(ptyId: string): RuntimeLeafRecord[] { + return this.leavesByPtyId.get(ptyId) ?? [] } private getSummaryForRuntimeWorktreeId( From 9872e9989f5826e47fa8c8ab2782b5cac1f8a8fa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:05:13 -0700 Subject: [PATCH 30/57] Log packaged single-instance lock exits (#3472) --- src/main/index.ts | 16 +++++++--------- src/main/startup/single-instance-lock.test.ts | 17 ++++++++++++++++- src/main/startup/single-instance-lock.ts | 7 +++++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8ba2c81c749..d1d9c5ff7ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,7 +55,10 @@ import { import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { acquireSingleInstanceLock } from './startup/single-instance-lock' +import { + acquireSingleInstanceLock, + logSingleInstanceLockFailure +} from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' @@ -273,14 +276,9 @@ function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope const hasSingleInstanceLock = is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { - if (is.dev) { - // Why: packaged runs have no attached console, but dev runs do. Emit a - // single line so a `pnpm dev` operator does not mistake a silent exit - // for a broken launcher. - console.log( - '[single-instance] Another Orca instance is already running against this userData path — focusing existing window.' - ) - } + // Why: if Electron returns a false negative here, packaged macOS launches + // otherwise look like silent crashes. `open --stderr` can capture this line. + logSingleInstanceLockFailure() app.quit() } diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index 4b1fe7a1c12..5b917f6ac4d 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -1,6 +1,10 @@ import type { App } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { acquireSingleInstanceLock } from './single-instance-lock' +import { + acquireSingleInstanceLock, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE +} from './single-instance-lock' type Listener = (...args: unknown[]) => void @@ -65,3 +69,14 @@ describe('acquireSingleInstanceLock', () => { expect(onSecondInstance).toHaveBeenCalledTimes(1) }) }) + +describe('logSingleInstanceLockFailure', () => { + it('emits a production-visible diagnostic for the early quit path', () => { + const logger = { error: vi.fn() } + + logSingleInstanceLockFailure(logger) + + expect(logger.error).toHaveBeenCalledWith(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) + expect(logger.error.mock.calls[0]?.[0]).toContain('Electron/macOS single-instance lock failure') + }) +}) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 0227d454cc0..67a32c54ae5 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,5 +1,8 @@ import type { App } from 'electron' +export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = + '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' + /** * Why: Orca writes two canonical discovery files into `/`: * `orca-runtime.json` (RPC endpoint + authToken for the bundled CLI) and @@ -25,3 +28,7 @@ export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void app.on('second-instance', onSecondInstance) return true } + +export function logSingleInstanceLockFailure(logger: Pick = console): void { + logger.error(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE) +} From 706ecd7434fc4fbd8da7e9ef8bc7a5392f5efb46 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:05:56 -0700 Subject: [PATCH 31/57] fix: guard daemon session async state (#3543) --- .../components/shared/useDaemonActions.tsx | 40 ++++++++++++++----- .../status-bar/ResourceUsageStatusSegment.tsx | 21 ++++++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/shared/useDaemonActions.tsx b/src/renderer/src/components/shared/useDaemonActions.tsx index b8dd7c91483..0dab5157e87 100644 --- a/src/renderer/src/components/shared/useDaemonActions.tsx +++ b/src/renderer/src/components/shared/useDaemonActions.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' @@ -39,6 +39,22 @@ export type DaemonActionsApi = { export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi { const [pending, setPending] = useState(null) const [busyKind, setBusyKind] = useState(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const clearPendingAction = useCallback((): void => { + if (!mountedRef.current) { + return + } + setBusyKind(null) + setPending(null) + }, []) const runRestart = useCallback(async () => { setBusyKind('restart') @@ -54,11 +70,12 @@ export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActio description: err instanceof Error ? err.message : undefined }) } finally { - setBusyKind(null) - setPending(null) - callbacks?.onRestartSettled?.() + clearPendingAction() + if (mountedRef.current) { + callbacks?.onRestartSettled?.() + } } - }, [callbacks]) + }, [callbacks, clearPendingAction]) const runKillAll = useCallback(async () => { setBusyKind('killAll') @@ -77,16 +94,19 @@ export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActio toast.error(`${remainingCount} session${remainingCount === 1 ? '' : 's'} refused to exit.`) } } catch (err) { - callbacks?.onKillAllError?.() + if (mountedRef.current) { + callbacks?.onKillAllError?.() + } toast.error('Couldn’t kill sessions.', { description: err instanceof Error ? err.message : undefined }) } finally { - setBusyKind(null) - setPending(null) - callbacks?.onKillAllSettled?.() + clearPendingAction() + if (mountedRef.current) { + callbacks?.onKillAllSettled?.() + } } - }, [callbacks]) + }, [callbacks, clearPendingAction]) const runConfirmed = useCallback(() => { if (pending === 'restart') { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index a91ec7eb9f2..a79869011fd 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -695,6 +695,7 @@ export function ResourceUsageStatusSegment({ // somewhere stable for keyboard users. const popoverBodyRef = useRef(null) const popoverBodyFocusFrameRef = useRef(null) + const mountedRef = useRef(true) const cancelPopoverBodyFocusFrame = useCallback((): void => { if (popoverBodyFocusFrameRef.current === null) { @@ -706,6 +707,13 @@ export function ResourceUsageStatusSegment({ useEffect(() => cancelPopoverBodyFocusFrame, [cancelPopoverBodyFocusFrame]) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const setPopoverBodyNode = useCallback( (node: HTMLDivElement | null): void => { // Why: the queued post-kill focus is only valid while the popover body exists. @@ -719,16 +727,23 @@ export function ResourceUsageStatusSegment({ const refreshSessions = useCallback(async () => { if (runtimeEnvironmentActive) { - setSessions([]) - setSessionsError(false) + if (mountedRef.current) { + setSessions([]) + setSessionsError(false) + } return } try { const result = await window.api.pty.listSessions() + if (!mountedRef.current) { + return + } setSessions(result) setSessionsError(false) } catch { - setSessionsError(true) + if (mountedRef.current) { + setSessionsError(true) + } } }, [runtimeEnvironmentActive]) From 170c995d89a12c20a9164c14a15c806c42bb0543 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:09:05 -0700 Subject: [PATCH 32/57] fix: refresh PR state when agents complete (#3545) --- .../src/store/slices/agent-status.test.ts | 123 +++++++++++++++++- src/renderer/src/store/slices/agent-status.ts | 39 ++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 190bcf7e1bb..568fd1ac61a 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -5,8 +5,9 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' +import type { AppState } from '../types' import type { RetainedAgentEntry } from './agent-status' -import { createTestStore } from './store-test-helpers' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' // Why: queueMicrotask is used by the agent-status slice to schedule the // freshness timer after state updates. In tests we need to flush microtasks @@ -15,6 +16,50 @@ function flushMicrotasks(): Promise { return new Promise((resolve) => queueMicrotask(resolve)) } +function stubGitHubPRRefreshApi() { + const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + gh: { enqueuePRRefresh } + } + }) + return enqueuePRRefresh +} + +function seedAgentPRRefreshFixture( + store: ReturnType, + worktreeCardProperties: AppState['worktreeCardProperties'] +): void { + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#999999', + addedAt: 1, + kind: 'git' + } + ], + groupBy: 'repo', + rightSidebarOpen: false, + worktreeCardProperties, + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktrees/pr-from-agent', + branch: 'feature/pr-from-agent' + }) + ] + }, + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) +} + describe('agent status freshness expiry', () => { afterEach(() => { vi.useRealTimers() @@ -260,6 +305,82 @@ describe('agent status tool + assistant fields', () => { }) }) +describe('agent status PR refresh handoff', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('enqueues an active PR refresh for the owning worktree when an agent completes', async () => { + vi.useFakeTimers() + const enqueuePRRefresh = stubGitHubPRRefreshApi() + const store = createTestStore() + seedAgentPRRefreshFixture(store, ['pr']) + + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'create a PR', agentType: 'codex' }) + store + .getState() + .setAgentStatus('tab-1:0', { state: 'done', prompt: 'create a PR', agentType: 'codex' }) + + await flushMicrotasks() + + expect(enqueuePRRefresh).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + repoPath: '/repo', + branch: 'feature/pr-from-agent', + worktreeId: 'wt-1', + linkedPRNumber: null + }), + reason: 'active', + priority: 80 + }) + }) + + it('does not spend a PR refresh when no PR surface is visible', async () => { + vi.useFakeTimers() + const enqueuePRRefresh = stubGitHubPRRefreshApi() + const store = createTestStore() + seedAgentPRRefreshFixture(store, ['comment']) + + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'create a PR', agentType: 'codex' }) + store + .getState() + .setAgentStatus('tab-1:0', { state: 'done', prompt: 'create a PR', agentType: 'codex' }) + + await flushMicrotasks() + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('does not repeat the refresh for same-state done detail updates', async () => { + vi.useFakeTimers() + const enqueuePRRefresh = stubGitHubPRRefreshApi() + const store = createTestStore() + seedAgentPRRefreshFixture(store, ['pr']) + + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'create a PR', agentType: 'codex' }) + store + .getState() + .setAgentStatus('tab-1:0', { state: 'done', prompt: 'create a PR', agentType: 'codex' }) + store.getState().setAgentStatus('tab-1:0', { + state: 'done', + prompt: 'create a PR', + agentType: 'codex', + lastAssistantMessage: 'Opened https://github.com/acme/orca/pull/42' + }) + + await flushMicrotasks() + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) +}) + describe('agent status stateStartedAt', () => { afterEach(() => { vi.useRealTimers() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index bc69374dace..fd13d247ca4 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -124,6 +124,31 @@ function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boo return false } +function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean { + return state === 'done' || state === 'waiting' || state === 'blocked' +} + +function getTabIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { + return null + } + return paneKey.slice(0, separator) +} + +function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null { + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -173,6 +198,7 @@ export const createAgentStatusSlice: StateCreator { const updatedAt = timing?.updatedAt ?? Date.now() + let completionRefreshWorktreeId: string | null = null set((s) => { const existing = s.agentStatusByPaneKey[paneKey] // Why: snapshots and live pushes share receivedAt from the same main-side @@ -263,6 +289,13 @@ export const createAgentStatusSlice: StateCreatorfresh, or receives // a same-state `done` update that may carry the final assistant @@ -329,6 +362,12 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) + if (completionRefreshWorktreeId) { + const worktreeId = completionRefreshWorktreeId + // Why: agents can create a PR via `gh pr create`, bypassing Orca's + // create-PR flow and leaving a fresh "no PR" cache entry in place. + queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId)) + } }, setMigrationUnsupportedPty: (entry) => { From 83ea282b7d55ea382201eab7a558de0876a00712 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:11:39 -0700 Subject: [PATCH 33/57] fix: guard ssh status async state (#3546) --- .../sidebar/SshDisconnectedDialog.tsx | 18 +++++++++++++++--- .../components/status-bar/SshStatusSegment.tsx | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index 50bbfec7baf..b75c386a31a 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Loader2, Server, ServerOff } from 'lucide-react' import { @@ -41,16 +41,28 @@ export function SshDisconnectedDialog({ status }: SshDisconnectedDialogProps): React.JSX.Element { const [connecting, setConnecting] = useState(false) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const handleReconnect = useCallback(async () => { setConnecting(true) try { await window.api.ssh.connect({ targetId }) - onOpenChange(false) + if (mountedRef.current) { + onOpenChange(false) + } } catch (err) { toast.error(err instanceof Error ? err.message : 'Reconnection failed') } finally { - setConnecting(false) + if (mountedRef.current) { + setConnecting(false) + } } }, [targetId, onOpenChange]) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 31c129c6f2b..f593083783c 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { AlertTriangle, Cloud, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { @@ -113,8 +113,16 @@ function TargetRow({ syncStatus: RemoteWorkspaceSyncStatus | undefined }): React.JSX.Element { const [busy, setBusy] = useState(false) + const mountedRef = useRef(true) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const handleConnect = useCallback(async () => { setBusy(true) try { @@ -123,7 +131,9 @@ function TargetRow({ } catch (err) { toast.error(err instanceof Error ? err.message : 'Connection failed') } finally { - setBusy(false) + if (mountedRef.current) { + setBusy(false) + } } }, [recordFeatureInteraction, targetId]) @@ -135,7 +145,9 @@ function TargetRow({ } catch (err) { toast.error(err instanceof Error ? err.message : 'Disconnect failed') } finally { - setBusy(false) + if (mountedRef.current) { + setBusy(false) + } } }, [recordFeatureInteraction, targetId]) From ba6a0cbd2782e18afcc9ab9911f666fda51350eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 08:12:24 +0000 Subject: [PATCH 34/57] release: v1.4.36-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5b2f6dcff2..08f85453ded 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.36-rc.3", + "version": "1.4.36-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 8651cb2eaea1264b33d856fb23986f14a5fb9341 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:14:53 -0700 Subject: [PATCH 35/57] Detect agent-created PR links from terminal output (#3547) --- .../terminal-pane/pty-connection.test.ts | 27 ++++ .../terminal-pane/pty-connection.ts | 5 + .../terminal-github-pr-link-detector.test.ts | 67 +++++++++ .../lib/terminal-github-pr-link-detector.ts | 101 +++++++++++++ .../src/store/slices/worktree-helpers.ts | 2 + .../src/store/slices/worktrees.test.ts | 93 ++++++++++++ src/renderer/src/store/slices/worktrees.ts | 134 +++++++++++++++++- 7 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/terminal-github-pr-link-detector.test.ts create mode 100644 src/renderer/src/lib/terminal-github-pr-link-detector.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index d6f18855bf8..5043d688846 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -73,6 +73,7 @@ type StoreState = { runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record markWorktreeUnread: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType setAgentStatus: ReturnType removeAgentStatus: ReturnType dropAgentStatus: ReturnType @@ -425,6 +426,7 @@ describe('connectPanePty', () => { runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, markWorktreeUnread: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn(), setAgentStatus: vi.fn((paneKey: string, payload: Record) => { mockStoreState.agentStatusByPaneKey[paneKey] = { ...payload, @@ -515,6 +517,31 @@ describe('connectPanePty', () => { logSpy.mockRestore() }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + }) + ) + }) + it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-pane-2') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ef1383d37cf..852c96ddb5e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -60,6 +60,7 @@ import { } from './terminal-bracketed-paste' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' +import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -689,6 +690,7 @@ export function connectPanePty( onWorking: seedCommandCodeOutputWorkingStatus, onDone: scheduleCommandCodeOutputDoneStatus }) + const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const onPtySpawn = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) @@ -1683,6 +1685,9 @@ export function connectPanePty( const dataCallback = (data: string, meta?: PtyDataMeta): void => { resetHiddenOutputRestoreIfPtyChanged() observeTerminalBracketedPasteModeOutput(pane.terminal, data) + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + } commandCodeOutputStatusDetector.observe(data) commandLifecycle.handlePtyData(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts new file mode 100644 index 00000000000..cc6b74ee60d --- /dev/null +++ b/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { createTerminalGitHubPRLinkDetector } from './terminal-github-pr-link-detector' + +describe('createTerminalGitHubPRLinkDetector', () => { + it('extracts GitHub pull request URLs from terminal output', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('Created https://github.com/acme/orca/pull/42\r\n')).toEqual([ + { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + ]) + }) + + it('waits for a boundary when the URL is split across PTY chunks', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('https://github.com/acme/orca/pull/4')).toEqual([]) + expect(observe('2\r\n')).toEqual([ + { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + ]) + }) + + it('detects a URL split inside the GitHub prefix', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('created https://gith')).toEqual([]) + expect(observe('ub.com/acme/orca/pull/42\n')).toEqual([ + { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + ]) + }) + + it('trims terminal punctuation around printed URLs', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('Opened (https://github.com/acme/orca/pull/42).\n')[0]?.url).toBe( + 'https://github.com/acme/orca/pull/42' + ) + }) + + it('does not repeat the same PR URL from overlapping carry text', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('https://github.com/acme/orca/pull/42\n')).toHaveLength(1) + expect(observe('more output\n')).toEqual([]) + }) + + it('ignores non-PR and non-GitHub links', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect( + observe( + 'https://github.com/acme/orca/issues/42 https://github.example.com/acme/orca/pull/42\n' + ) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/renderer/src/lib/terminal-github-pr-link-detector.ts new file mode 100644 index 00000000000..c0b22629951 --- /dev/null +++ b/src/renderer/src/lib/terminal-github-pr-link-detector.ts @@ -0,0 +1,101 @@ +import type { RepoSlug } from './github-links' +import { parseGitHubIssueOrPRLink } from './github-links' + +const GITHUB_PR_URL_RE = + /\bhttps:\/\/(?:www\.)?github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+(?:[/?#][^\s"'<>]*)?/gi +const GITHUB_HOST_MARKER = 'github.com/' +const GITHUB_PR_PATH_MARKER = '/pull/' +const HTTPS_SCHEME_PREFIX = 'https://' +const HTTPS_SCHEME_FRAGMENT_LAST_CHARS = new Set('https:/'.split('')) +const TRAILING_TERMINAL_PUNCTUATION_RE = /[),.;\]}]+$/ +const MAX_CARRY_LENGTH = 512 + +export type TerminalGitHubPRLink = { + url: string + slug: RepoSlug + number: number +} + +function trimTerminalUrl(candidate: string): string { + return candidate.replace(TRAILING_TERMINAL_PUNCTUATION_RE, '') +} + +function parseTerminalGitHubPRUrl(candidate: string): TerminalGitHubPRLink | null { + const url = trimTerminalUrl(candidate) + const parsed = parseGitHubIssueOrPRLink(url) + if (!parsed || parsed.type !== 'pr') { + return null + } + return { url, slug: parsed.slug, number: parsed.number } +} + +function endsWithHttpsSchemePrefixFragment(value: string): string { + for (let length = Math.min(HTTPS_SCHEME_PREFIX.length - 1, value.length); length > 0; length--) { + if (value.endsWith(HTTPS_SCHEME_PREFIX.slice(0, length))) { + return value.slice(value.length - length) + } + } + return '' +} + +function getPotentialGitHubPRCarry(value: string, hasGitHubHost: boolean): string { + if (hasGitHubHost) { + const hostIndex = value.lastIndexOf(GITHUB_HOST_MARKER) + const schemeIndex = value.lastIndexOf('https://', hostIndex) + if (schemeIndex === -1) { + return '' + } + + const tail = value.slice(schemeIndex) + return /\s/.test(tail) ? '' : tail.slice(-MAX_CARRY_LENGTH) + } + + const schemeIndex = value.lastIndexOf('https://') + if (schemeIndex !== -1) { + const tail = value.slice(schemeIndex) + return /\s/.test(tail) ? '' : tail.slice(-MAX_CARRY_LENGTH) + } + + const lastChar = value.at(-1) + if (!lastChar || !HTTPS_SCHEME_FRAGMENT_LAST_CHARS.has(lastChar)) { + return '' + } + + return endsWithHttpsSchemePrefixFragment(value) +} + +export function createTerminalGitHubPRLinkDetector(): (data: string) => TerminalGitHubPRLink[] { + let carry = '' + const seenUrls = new Set() + + return (data: string): TerminalGitHubPRLink[] => { + const combined = carry ? carry + data : data + const hasGitHubHost = combined.includes(GITHUB_HOST_MARKER) + + if (!hasGitHubHost || !combined.includes(GITHUB_PR_PATH_MARKER)) { + carry = getPotentialGitHubPRCarry(combined, hasGitHubHost) + return [] + } + + const links: TerminalGitHubPRLink[] = [] + for (const match of combined.matchAll(GITHUB_PR_URL_RE)) { + const rawUrl = match[0] + const matchEnd = (match.index ?? 0) + rawUrl.length + // Why: PTY chunks can split the PR number; wait for a boundary before + // treating a URL at chunk-end as complete. + if (matchEnd === combined.length) { + continue + } + + const parsed = parseTerminalGitHubPRUrl(rawUrl) + if (!parsed || seenUrls.has(parsed.url)) { + continue + } + seenUrls.add(parsed.url) + links.push(parsed) + } + + carry = getPotentialGitHubPRCarry(combined, true) + return links + } +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index efa925e7e7f..e97c863e841 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -14,6 +14,7 @@ import type { WorktreeRemoteBranchConflictEvent, WorktreeMeta } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { @@ -106,6 +107,7 @@ export type WorktreeSlice = { updatesByWorktreeId: ReadonlyMap> ) => Promise markWorktreeUnread: (worktreeId: string) => void + observeTerminalGitHubPullRequestLink: (worktreeId: string, link: TerminalGitHubPRLink) => void /** Clear the worktree's unread dot. Called on user interaction with any * terminal pane inside the worktree (keystroke, click) — matches * ghostty's "show until interact" model. Persists isUnread=false. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 5872a1c73c7..96fe70499cf 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1858,6 +1858,99 @@ describe('worktree remote runtime mutations', () => { }) }) + it('optimistically links a terminal-observed GitHub PR for the same repo', () => { + const store = createTestStore() + const fetchPRForBranch = vi.fn().mockResolvedValue(null) + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/worktrees/orca', + branch: 'refs/heads/feature/pr-link' + }) + store.setState({ + repos: [ + { id: 'repo1', path: '/repos/orca', displayName: 'orca', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [wt] }, + fetchPRForBranch, + fetchHostedReviewForBranch + } as unknown as Partial) + + store.getState().observeTerminalGitHubPullRequestLink(wt.id, { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + }) + + expect(store.getState().worktreesByRepo.repo1[0]?.linkedPR).toBe(42) + expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled() + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: { linkedPR: 42 } + }) + expect(fetchPRForBranch).toHaveBeenCalledWith('/repos/orca', 'feature/pr-link', { + force: true, + repoId: 'repo1', + linkedPRNumber: 42, + fallbackPRNumber: null, + fallbackPRSource: null + }) + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith( + '/repos/orca', + 'feature/pr-link', + expect.objectContaining({ + force: true, + repoId: 'repo1', + linkedGitHubPR: 42, + linkedGitLabMR: null + }) + ) + }) + + it('waits for exact lookup before linking a terminal PR URL for a differently named repo', async () => { + const store = createTestStore() + const fetchPRForBranch = vi.fn().mockResolvedValue({ number: 42 }) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/worktrees/orca', + branch: 'refs/heads/feature/pr-link' + }) + mockApi.worktrees.resolvePrBase.mockResolvedValueOnce({ baseBranch: 'main' }) + store.setState({ + repos: [ + { id: 'repo1', path: '/repos/orca', displayName: 'orca', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [wt] }, + fetchPRForBranch + } as unknown as Partial) + + store.getState().observeTerminalGitHubPullRequestLink(wt.id, { + url: 'https://github.com/acme/docs/pull/42', + slug: { owner: 'acme', repo: 'docs' }, + number: 42 + }) + + expect(store.getState().worktreesByRepo.repo1[0]?.linkedPR).toBeNull() + expect(fetchPRForBranch).toHaveBeenCalledWith('/repos/orca', 'feature/pr-link', { + force: true, + repoId: 'repo1', + linkedPRNumber: null, + fallbackPRNumber: 42, + fallbackPRSource: 'explicit' + }) + + for (let i = 0; i < 6; i++) { + await Promise.resolve() + } + + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: { linkedPR: 42 } + }) + }) + it('does not surface remote selector misses while persisting activity timestamps', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const store = createTestStore() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index ce82c0a8a3b..08251f85a99 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -6,12 +6,14 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, LocalBaseRefRefreshResult, + Repo, Worktree, WorkspaceVisibleTabType, GitPushTarget, WorktreeLineage, WorktreeMeta } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types' import { findWorktreeById, @@ -26,12 +28,13 @@ import { getActiveRuntimeTarget, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' -import { getHostedReviewCacheKey } from './hosted-review' +import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview-cleanup' import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { branchName } from '@/lib/git-utils' +import { basename } from '@/lib/path' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list @@ -93,6 +96,63 @@ function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): b return a.every((v, i) => v === b[i]) } +function normalizeGitHubRepoName(value: string | null | undefined): string | null { + const trimmed = value?.trim() + if (!trimmed) { + return null + } + return trimmed.replace(/\.git$/i, '').toLowerCase() +} + +function parseGitHubRemoteSlug( + remoteUrl: string | null | undefined +): { owner: string; repo: string } | null { + const trimmed = remoteUrl?.trim() + if (!trimmed) { + return null + } + const withoutGitSuffix = trimmed.replace(/\.git(?:[?#].*)?$/i, '') + const match = + /^git@github\.com:([^/]+)\/([^/?#]+)$/i.exec(withoutGitSuffix) ?? + /^ssh:\/\/(?:[^@/]+@)?github\.com[:/]+([^/]+)\/([^/?#]+)$/i.exec(withoutGitSuffix) ?? + /^https?:\/\/(?:[^@/]+@)?github\.com\/([^/]+)\/([^/?#]+)$/i.exec(withoutGitSuffix) + if (!match) { + return null + } + return { owner: match[1], repo: match[2] } +} + +function githubSlugsEqual( + left: { owner: string; repo: string } | null, + right: { owner: string; repo: string } +): boolean { + return ( + left !== null && + left.owner.toLowerCase() === right.owner.toLowerCase() && + left.repo.toLowerCase() === right.repo.toLowerCase() + ) +} + +function shouldOptimisticallyLinkTerminalGitHubPR( + repo: Repo, + worktree: Pick, + link: TerminalGitHubPRLink +): boolean { + if (githubSlugsEqual(parseGitHubRemoteSlug(worktree.pushTarget?.remoteUrl), link.slug)) { + return true + } + + const observedRepo = normalizeGitHubRepoName(link.slug.repo) + if (!observedRepo) { + return false + } + const localRepoNames = [repo.displayName, basename(repo.path), basename(worktree.path)] + .map(normalizeGitHubRepoName) + .filter((name): name is string => name !== null) + + return localRepoNames.includes(observedRepo) +} + function areLineageRecordsEqual( a: WorktreeLineage | null | undefined, b: WorktreeLineage | null | undefined @@ -1562,6 +1622,78 @@ export const createWorktreeSlice: StateCreator }) }, + observeTerminalGitHubPullRequestLink: (worktreeId, link) => { + const state = get() + const worktree = findKnownWorktreeById(state, worktreeId) + if (!worktree || worktree.isBare || worktree.isArchived) { + return + } + const repo = state.repos.find((candidate) => candidate.id === worktree.repoId) + if (!repo || (repo.kind && repo.kind !== 'git')) { + return + } + if (typeof worktree.linkedPR === 'number' && worktree.linkedPR !== link.number) { + return + } + + const branch = branchName(worktree.branch) + const shouldLinkNow = + worktree.linkedPR === link.number || + shouldOptimisticallyLinkTerminalGitHubPR(repo, worktree, link) + + if (shouldLinkNow && worktree.linkedPR !== link.number) { + set((s) => { + const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { + linkedPR: link.number + }) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + { linkedPR: link.number } + ) + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) + // Why: `gh pr create` prints the canonical PR URL before the agent is + // done. Persist the exact number immediately so the workspace card can + // show it without waiting for the completion-time GitHub refresh. + void get().updateWorktreeMeta(worktreeId, { linkedPR: link.number }) + } + + const fetchPRForBranch = get().fetchPRForBranch + if (typeof fetchPRForBranch === 'function') { + void fetchPRForBranch(repo.path, branch, { + force: true, + repoId: repo.id, + linkedPRNumber: shouldLinkNow ? link.number : null, + fallbackPRNumber: shouldLinkNow ? null : link.number, + fallbackPRSource: shouldLinkNow ? null : 'explicit' + }).then((pr) => { + if (!shouldLinkNow && pr?.number === link.number) { + void get().updateWorktreeMeta(worktreeId, { linkedPR: link.number }) + } + }) + } + + const fetchHostedReviewForBranch = get().fetchHostedReviewForBranch + if (typeof fetchHostedReviewForBranch === 'function') { + void refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: repo.path, + repoId: repo.id, + branch, + linkedGitHubPR: shouldLinkNow ? link.number : null, + fallbackGitHubPR: shouldLinkNow ? null : link.number, + linkedGitLabMR: worktree.linkedGitLabMR ?? null + }) + } + }, + clearWorktreeUnread: (worktreeId) => { let shouldPersist = false set((s) => { From 16a9c36725a53efd1d12030085e1c6168356e028 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:15:47 -0700 Subject: [PATCH 36/57] fix: guard notice async state (#3548) --- .../src/components/FirstLaunchBanner.tsx | 26 +++++++++++++++---- src/renderer/src/components/StarNagCard.tsx | 22 +++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index 8a03379786e..ed93d57168e 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -33,7 +33,7 @@ // off), the notice never returns, because the cohort condition // (`optedIn === null`) clears in all three resolving paths. -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { X } from 'lucide-react' import { Button } from './ui/button' @@ -57,6 +57,14 @@ export function FirstLaunchBanner({ // wasted IPC round-trip, but the guard also blocks a Turn-off click // arriving mid-flight after an acknowledge (or vice versa). const [inFlight, setInFlight] = useState(false) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const handleAcknowledge = async (): Promise => { if (inFlight) { @@ -74,13 +82,17 @@ export function FirstLaunchBanner({ try { await acknowledgeBanner() await fetchSettings() - onResolve() + if (mountedRef.current) { + onResolve() + } } finally { // Why: if `fetchSettings` rejects (IPC error during shutdown, // settings file lock, etc.), `onResolve` never runs and the banner // stays mounted. Without resetting `inFlight`, every button stays // permanently disabled for the rest of the session. - setInFlight(false) + if (mountedRef.current) { + setInFlight(false) + } } } @@ -97,9 +109,13 @@ export function FirstLaunchBanner({ try { await telemetrySetOptIn(false) await fetchSettings() - onResolve() + if (mountedRef.current) { + onResolve() + } } finally { - setInFlight(false) + if (mountedRef.current) { + setInFlight(false) + } } } diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index 43f78576bb5..0b7f69a6034 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Star, X } from 'lucide-react' import { Card } from './ui/card' import { Button } from './ui/button' @@ -19,6 +19,7 @@ export function StarNagCard(): React.JSX.Element | null { const [visible, setVisible] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(false) + const mountedRef = useRef(true) // Why: UpdateCard lives at the same bottom-right slot. When it is visible // (any non-idle / non-not-available state), stack the star-nag card above // it instead of overlapping — we must not cover a pending update prompt @@ -26,6 +27,13 @@ export function StarNagCard(): React.JSX.Element | null { const updateStatus = useAppStore((s) => s.updateStatus) const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { return window.api.starNag.onShow(() => { setError(false) @@ -67,13 +75,19 @@ export function StarNagCard(): React.JSX.Element | null { setBusy(true) setError(false) const ok = await window.api.gh.starOrca('star_nag') - setBusy(false) + if (mountedRef.current) { + setBusy(false) + } if (!ok) { - setError(true) + if (mountedRef.current) { + setError(true) + } return } await window.api.starNag.complete() - setVisible(false) + if (mountedRef.current) { + setVisible(false) + } } return ( From 69e4aaa036ed797d215f95ed738f6f7520d7198b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:19:33 -0700 Subject: [PATCH 37/57] fix: guard repo icon picker async state (#3549) --- .../settings/RepositoryIconPicker.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 87564e927d8..2b993ff6e05 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Github, Image, Link2, RotateCcw } from 'lucide-react' import type { Repo } from '../../../../shared/types' @@ -27,6 +27,7 @@ export function RepositoryIconPicker({ }): React.JSX.Element { const [website, setWebsite] = useState('') const [loadingGitHub, setLoadingGitHub] = useState(false) + const mountedRef = useRef(true) const activeRuntimeEnvironmentId = useAppStore( (state) => state.settings?.activeRuntimeEnvironmentId ?? null ) @@ -58,10 +59,17 @@ export function RepositoryIconPicker({ const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon }) const setBadgeColor = (badgeColor: string) => updateRepo(repo.id, { badgeColor }) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const handleUploadImage = async () => { try { const result = await window.api.shell.pickRepoIconImage() - if (!result) { + if (!result || !mountedRef.current) { return } setIcon({ @@ -99,7 +107,12 @@ export function RepositoryIconPicker({ ) : await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id }) if (!slug) { - toast.error('No GitHub remote found for this repo.') + if (mountedRef.current) { + toast.error('No GitHub remote found for this repo.') + } + return + } + if (!mountedRef.current) { return } setIcon({ @@ -109,9 +122,13 @@ export function RepositoryIconPicker({ label: `${slug.owner}/${slug.repo}` }) } catch { - toast.error('Failed to resolve the GitHub repo.') + if (mountedRef.current) { + toast.error('Failed to resolve the GitHub repo.') + } } finally { - setLoadingGitHub(false) + if (mountedRef.current) { + setLoadingGitHub(false) + } } } From 033501cd9359f3bf9ae8a9fcde2d7753fb90e294 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:23:54 -0700 Subject: [PATCH 38/57] fix: guard ssh pane async state (#3550) --- .../src/components/settings/SshPane.tsx | 88 ++++++++++++------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 5fff75037bb..58b0e8c9692 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' import { @@ -29,6 +29,7 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [testingIds, setTestingIds] = useState>(new Set()) + const mountedRef = useRef(true) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const clearRemovedSshTargetState = useAppStore((s) => s.clearRemovedSshTargetState) @@ -37,13 +38,13 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { async (opts?: { signal?: AbortSignal }) => { try { const result = (await window.api.ssh.listTargets()) as SshTarget[] - if (opts?.signal?.aborted) { + if (opts?.signal?.aborted || !mountedRef.current) { return } setTargets(result) setSshTargetsMetadata(result) } catch { - if (!opts?.signal?.aborted) { + if (!opts?.signal?.aborted && mountedRef.current) { toast.error('Failed to load SSH targets') } } @@ -51,6 +52,13 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { [setSshTargetsMetadata] ) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { const abortController = new AbortController() void loadTargets({ signal: abortController.signal }) @@ -97,20 +105,22 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { } try { - if (editingId) { - await window.api.ssh.updateTarget({ id: editingId, updates: target }) - toast.success('Target updated') - } else { - await window.api.ssh.addTarget({ target }) - toast.success('Target added') - } + await (editingId + ? window.api.ssh.updateTarget({ id: editingId, updates: target }) + : window.api.ssh.addTarget({ target })) recordFeatureInteraction('ssh') + if (!mountedRef.current) { + return + } + toast.success(editingId ? 'Target updated' : 'Target added') setShowForm(false) setEditingId(null) setForm(EMPTY_FORM) await loadTargets() } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save target') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to save target') + } } } @@ -135,10 +145,14 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { // Why: a deleted passphrase-gated target may still have deferred // reconnect metadata; clear it so focused SSH tabs stop retrying it. clearRemovedSshTargetState(id) - toast.success('Target removed') + if (mountedRef.current) { + toast.success('Target removed') + } await loadTargets() } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to remove target') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to remove target') + } } } @@ -193,10 +207,14 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const handleResetRelay = async (targetId: string): Promise => { try { await window.api.ssh.resetRelay({ targetId }) - toast.success('Remote relay reset') + if (mountedRef.current) { + toast.success('Remote relay reset') + } await loadTargets() } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to reset remote relay') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to reset remote relay') + } } } @@ -205,19 +223,25 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { try { const result = await window.api.ssh.testConnection({ targetId }) recordFeatureInteraction('ssh') - if (result.success) { - toast.success('Connection successful') - } else { - toast.error(result.error ?? 'Connection test failed') + if (mountedRef.current) { + if (result.success) { + toast.success('Connection successful') + } else { + toast.error(result.error ?? 'Connection test failed') + } } } catch (err) { - toast.error(err instanceof Error ? err.message : 'Test failed') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Test failed') + } } finally { - setTestingIds((prev) => { - const next = new Set(prev) - next.delete(targetId) - return next - }) + if (mountedRef.current) { + setTestingIds((prev) => { + const next = new Set(prev) + next.delete(targetId) + return next + }) + } } } @@ -225,14 +249,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { try { const imported = (await window.api.ssh.importConfig()) as SshTarget[] recordFeatureInteraction('ssh') - if (imported.length === 0) { - toast('No new hosts found in ~/.ssh/config') - } else { - toast.success(`Imported ${imported.length} host${imported.length > 1 ? 's' : ''}`) + if (mountedRef.current) { + if (imported.length === 0) { + toast('No new hosts found in ~/.ssh/config') + } else { + toast.success(`Imported ${imported.length} host${imported.length > 1 ? 's' : ''}`) + } } await loadTargets() } catch (err) { - toast.error(err instanceof Error ? err.message : 'Import failed') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Import failed') + } } } From 1844a992e8c385366f6f78997ded2a561789fe5c Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 01:25:41 -0700 Subject: [PATCH 39/57] fix: pr-bug-scan validated finding from #2947 (#2965) * fix: address pr-bug-scan validated finding from #2947 Prepended `:(literal)` pathspec magic to each path passed to `git clean -ffdx --` in both `cleanUntrackedPaths` call sites, so untracked filenames containing pathspec glob magic (`*`, `?`, `[`, `]`) a * fix: literalize discard git pathspecs --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/main/git/status-discard-symlink.test.ts | 47 +++++++++++++++++++++ src/main/git/status.test.ts | 20 ++++++--- src/main/git/status.ts | 38 ++++++++++++----- src/relay/git-handler.test.ts | 31 ++++++++++++++ src/relay/git-handler.ts | 36 +++++++++++++--- 5 files changed, 150 insertions(+), 22 deletions(-) diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts index 0f0bfaad801..88a22d06009 100644 --- a/src/main/git/status-discard-symlink.test.ts +++ b/src/main/git/status-discard-symlink.test.ts @@ -91,6 +91,53 @@ describe('discardChanges symlink safety', () => { await expect(access(path.join(repo, 'ignored'))).rejects.toThrow() }) + it('treats untracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, 'ignored.log'), 'ignored') + + await discardChanges(repo, '*.log') + + await expect(access(path.join(repo, '*.log'))).rejects.toThrow() + await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() + }) + + it('treats tracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'keep') + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'track log fixtures'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected modified') + await writeFile(path.join(repo, 'keep.log'), 'keep modified') + + await discardChanges(repo, '*.log') + + await expect(readFile(path.join(repo, '*.log'), 'utf8')).resolves.toBe('selected') + await expect(readFile(path.join(repo, 'keep.log'), 'utf8')).resolves.toBe('keep modified') + }) + + it('treats bulk untracked discard paths with Git glob characters as literal paths', async () => { + const { repo } = await createRepoWithOutsideDirectory() + await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') + execFileSync('git', ['add', '.gitignore'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, 'ignored.log'), 'ignored') + + await bulkDiscardChanges(repo, ['*.log']) + + await expect(access(path.join(repo, '*.log'))).rejects.toThrow() + await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() + }) + it('removes untracked nested git repos selected for discard', async () => { const { repo } = await createRepoWithOutsideDirectory() const nestedRepo = path.join(repo, 'nested') diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2547f60b7b3..252b23ff608 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -75,14 +75,14 @@ describe('discardChanges', () => { expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['ls-files', '--error-unmatch', '--', 'src/file.ts'], + ['ls-files', '--error-unmatch', '--', ':(literal)src/file.ts'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts'], + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts'], { cwd: '/repo' } @@ -98,7 +98,7 @@ describe('discardChanges', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['clean', '-ffdx', '--', 'src/new-file.ts'], + ['clean', '-ffdx', '--', ':(literal)src/new-file.ts'], { cwd: '/repo' } @@ -178,7 +178,15 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['ls-files', '-z', '--', 'src/file.ts', 'src/new-file.ts', 'docs', 'scratch'], + [ + 'ls-files', + '-z', + '--', + ':(literal)src/file.ts', + ':(literal)src/new-file.ts', + ':(literal)docs', + ':(literal)scratch' + ], { cwd: '/repo' } @@ -187,14 +195,14 @@ describe('bulk git helpers', () => { // tracked descendant, which keeps directory pathspecs on the restore path. expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts', 'docs'], + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts', ':(literal)docs'], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['clean', '-ffdx', '--', 'src/new-file.ts', 'scratch'], + ['clean', '-ffdx', '--', ':(literal)src/new-file.ts', ':(literal)scratch'], { cwd: '/repo' } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ed34cd56bdf..5ab2c757d83 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1105,7 +1105,7 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', filePath], { + await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { cwd: worktreePath }) tracked = true @@ -1114,9 +1114,12 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr } if (tracked) { - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', filePath], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + { + cwd: worktreePath + } + ) return } @@ -1129,6 +1132,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } +function literalPathspec(filePath: string): string { + // Why: source-control selections are concrete paths, not user-authored Git globs. + return `:(literal)${filePath}` +} + function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { const normalized = normalizeGitPathForCompare(filePath) return trackedPaths.some((trackedPath) => { @@ -1144,9 +1152,12 @@ async function listTrackedPathSpecs( const trackedPaths: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await gitExecFileAsync(['ls-files', '-z', '--', ...chunk], { - cwd: worktreePath - }) + const { stdout } = await gitExecFileAsync( + ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + { + cwd: worktreePath + } + ) trackedPaths.push(...stdout.split('\0').filter(Boolean)) } return trackedPaths @@ -1160,7 +1171,9 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { + cwd: worktreePath + }) } } } @@ -1193,9 +1206,12 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--worktree', '--source=HEAD', '--', ...chunk], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + { + cwd: worktreePath + } + ) } } ) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index f99c8acc5c4..326e42059a4 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -448,6 +448,37 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) + it('treats untracked discard paths with Git glob characters as literal paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated') + writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + + await expect(fs.access(path.join(tmpDir, '*.log'))).rejects.toThrow() + await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined() + }) + + it('treats tracked discard paths with Git glob characters as literal paths', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, 'keep.log'), 'keep') + gitCommit(tmpDir, 'track log fixtures') + writeFileSync(path.join(tmpDir, '*.log'), 'selected modified') + writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified') + + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + + await expect(fs.readFile(path.join(tmpDir, '*.log'), 'utf-8')).resolves.toBe('selected') + await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe( + 'keep modified' + ) + }) + it('bulk discards tracked and untracked files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'a.txt'), 'a') diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 9956a4e7668..b532ab070ff 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -236,14 +236,20 @@ export class GitHandler { let tracked = false try { - await this.git(['ls-files', '--error-unmatch', '--', filePath], worktreePath) + await this.git( + ['ls-files', '--error-unmatch', '--', this.literalPathspec(filePath)], + worktreePath + ) tracked = true } catch { // untracked } if (tracked) { - await this.git(['restore', '--worktree', '--source=HEAD', '--', filePath], worktreePath) + await this.git( + ['restore', '--worktree', '--source=HEAD', '--', this.literalPathspec(filePath)], + worktreePath + ) return } @@ -266,7 +272,10 @@ export class GitHandler { const trackedPathSpecs: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await this.git(['ls-files', '-z', '--', ...chunk], worktreePath) + const { stdout } = await this.git( + ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], + worktreePath + ) trackedPathSpecs.push(...stdout.split('\0').filter(Boolean)) } @@ -283,18 +292,35 @@ export class GitHandler { async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['restore', '--worktree', '--source=HEAD', '--', ...chunk], worktreePath) + await this.git( + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((p) => this.literalPathspec(p)) + ], + worktreePath + ) } } ) } + private literalPathspec(filePath: string): string { + // Why: source-control selections are concrete paths, not user-authored Git globs. + return `:(literal)${filePath}` + } + private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await this.git(['clean', '-ffdx', '--', ...chunk], worktreePath) + await this.git( + ['clean', '-ffdx', '--', ...chunk.map((p) => this.literalPathspec(p))], + worktreePath + ) } } } From e7473a07e30ef0e5e747a3d79567fd6d2fefcd38 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:27:54 -0700 Subject: [PATCH 40/57] fix: guard browser profile async state (#3553) --- .../browser-pane/BrowserToolbarMenu.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 100b7ef10d5..e3d6da35693 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Check, Ellipsis, Import, Monitor, Plus, Settings } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -71,6 +71,7 @@ export function BrowserToolbarMenu({ const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState( undefined ) + const mountedRef = useRef(true) const effectiveProfileId = currentProfileId ?? 'default' @@ -81,6 +82,13 @@ export function BrowserToolbarMenu({ ? [defaultProfile, ...browserSessionProfiles.filter((p) => p.id !== 'default')] : browserSessionProfiles + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const handleSwitchProfile = (profileId: string | null): void => { const targetId = profileId ?? 'default' if (targetId === effectiveProfileId) { @@ -114,7 +122,13 @@ export function BrowserToolbarMenu({ try { const profile = await createBrowserSessionProfile('isolated', trimmed) if (!profile) { - toast.error('Failed to create profile.') + if (mountedRef.current) { + toast.error('Failed to create profile.') + } + return + } + + if (!mountedRef.current) { return } @@ -125,7 +139,9 @@ export function BrowserToolbarMenu({ switchBrowserTabProfile(workspaceId, profile.id) toast.success(`Created and switched to ${profile.label} profile`) } finally { - setIsCreatingProfile(false) + if (mountedRef.current) { + setIsCreatingProfile(false) + } } } From 5e5ad16625dada33c795d90275b80f166236f457 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:28:11 -0700 Subject: [PATCH 41/57] perf: reduce terminal observer hot-path work (#3551) --- .../terminal-pane/bell-detector.test.ts | 26 ++++ .../components/terminal-pane/bell-detector.ts | 9 ++ .../command-code-output-status.test.ts | 39 ++++++ .../command-code-output-status.ts | 116 +++++++++++++----- 4 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/bell-detector.test.ts diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/renderer/src/components/terminal-pane/bell-detector.test.ts new file mode 100644 index 00000000000..bd48ba98381 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/bell-detector.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { createBellDetector } from './bell-detector' + +describe('createBellDetector', () => { + it('skips ANSI chunks without losing later real bells', () => { + const detector = createBellDetector() + + expect(detector.chunkContainsBell('\x1b[32mbuild\x1b[0m output')).toBe(false) + expect(detector.chunkContainsBell('\x07')).toBe(true) + }) + + it('keeps split OSC state so title terminators are not reported as bells', () => { + const detector = createBellDetector() + + expect(detector.chunkContainsBell('\x1b]0;Codex working')).toBe(false) + expect(detector.chunkContainsBell('\x07')).toBe(false) + expect(detector.chunkContainsBell('\x07')).toBe(true) + }) + + it('treats BEL after a split non-OSC escape as a real bell', () => { + const detector = createBellDetector() + + expect(detector.chunkContainsBell('\x1b')).toBe(false) + expect(detector.chunkContainsBell('\x07')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/renderer/src/components/terminal-pane/bell-detector.ts index 04ba241ee66..1afdb51e28c 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/renderer/src/components/terminal-pane/bell-detector.ts @@ -28,6 +28,15 @@ export function createBellDetector(): BellDetector { return { chunkContainsBell(data: string): boolean { + if (!inOsc && !pendingEscape && !data.includes('\x07')) { + // Why: CSI/plain chunks with no BEL and no OSC start cannot affect + // bell state; avoid walking every byte of normal terminal output. + if (!data.includes('\x1b]')) { + pendingEscape = data.endsWith('\x1b') + return false + } + } + for (let i = 0; i < data.length; i += 1) { const char = data[i] diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts index dc1027ae783..68db625a54a 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts +++ b/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts @@ -28,6 +28,45 @@ describe('createCommandCodeOutputStatusDetector', () => { expect(onWorking).toHaveBeenCalledWith('') }) + it('detects the Command Code banner across PTY chunk boundaries', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking + }) + + expect(detector.observe('# Command')).toBe(false) + expect(detector.observe(' Code v0.27.2')).toBe(false) + expect(detector.observe('⌘ Parsing...')).toBe(true) + + expect(onWorking).toHaveBeenCalledWith('') + }) + + it('detects the Command Code banner when ANSI styling splits the words', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking + }) + + expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2')).toBe(false) + expect(detector.observe('⌘ Parsing...')).toBe(true) + + expect(onWorking).toHaveBeenCalledWith('') + }) + + it('does not trust near-miss Command Code banner text', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking + }) + + expect(detector.observe('NotCommand CodeX\r\n⌘ Parsing...')).toBe(false) + + expect(onWorking).not.toHaveBeenCalled() + }) + it.each([ 'Pondering', 'Contemplating', diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/renderer/src/components/terminal-pane/command-code-output-status.ts index 8449f5f86af..dc3b7db2cad 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/renderer/src/components/terminal-pane/command-code-output-status.ts @@ -107,8 +107,12 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( `(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()` ) const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ +const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ function stripTerminalControl(data: string): string { + if (!terminalControlMayAffectText(data)) { + return data + } const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') let output = '' for (let index = 0; index < withoutAnsi.length; index += 1) { @@ -121,6 +125,21 @@ function stripTerminalControl(data: string): string { return output } +function terminalControlMayAffectText(data: string): boolean { + for (let index = 0; index < data.length; index += 1) { + const code = data.charCodeAt(index) + if ( + code === 0x0d || + code === 0x1b || + (code <= 0x1f && code !== 0x0a) || + (code >= 0x7f && code <= 0x9f) + ) { + return true + } + } + return false +} + function cleanPromptCandidate(value: string): string { return stripTerminalControl(value).replace(/\s+/g, ' ').trim() } @@ -136,32 +155,55 @@ function isCommandCodeLaunchCommand(command: string | null | undefined): boolean return /(?:^|[\s;&|])(?:command-code|commandcode|cmdc)(?:\s|$)/.test(command) } -function patternOverlapsCurrentRawText( +function rawTextMayContainCommandCodeBanner(rawText: string): boolean { + // Why: every terminal pane observes this detector, but only Command Code + // panes need the ANSI/control stripping path. Use a broad no-false-negative + // letter prefilter so ANSI styling inside the banner words still works. + return rawText.includes('C') && rawText.includes('o') && rawText.includes('d') +} + +function patternOverlapsSanitizedText( pattern: RegExp, - previousRawText: string, - currentRawText: string + previousTextLength: number, + combinedText: string ): boolean { - const previousLength = stripTerminalControl(previousRawText).length - const combinedText = stripTerminalControl(previousRawText + currentRawText) const re = new RegExp(pattern.source, 'g') for (const match of combinedText.matchAll(re)) { const start = match.index ?? 0 - if (start + match[0].length > previousLength) { + if (start + match[0].length > previousTextLength) { return true } } return false } -function isActiveStatusText(currentRawText: string, previousRawText = ''): boolean { +type StatusScanContext = { + combinedText: string + previousTextLength: number + combinedTextWithChunkBoundary: string + previousTextWithChunkBoundaryLength: number +} + +function patternOverlapsStatusContext(pattern: RegExp, context: StatusScanContext): boolean { return ( - patternOverlapsCurrentRawText(ACTIVE_LLM_STATUS_RE, previousRawText, currentRawText) || - patternOverlapsCurrentRawText(ACTIVE_EXECUTION_STATUS_RE, previousRawText, currentRawText) + patternOverlapsSanitizedText(pattern, context.previousTextLength, context.combinedText) || + patternOverlapsSanitizedText( + pattern, + context.previousTextWithChunkBoundaryLength, + context.combinedTextWithChunkBoundary + ) ) } -function isIdlePromptText(currentRawText: string, previousRawText = ''): boolean { - return patternOverlapsCurrentRawText(IDLE_PROMPT_RE, previousRawText, currentRawText) +function isActiveStatusText(context: StatusScanContext): boolean { + return ( + patternOverlapsStatusContext(ACTIVE_LLM_STATUS_RE, context) || + patternOverlapsStatusContext(ACTIVE_EXECUTION_STATUS_RE, context) + ) +} + +function isIdlePromptText(context: StatusScanContext): boolean { + return patternOverlapsStatusContext(IDLE_PROMPT_RE, context) } export function createCommandCodeOutputStatusDetector(args: { @@ -176,19 +218,39 @@ export function createCommandCodeOutputStatusDetector(args: { return { observe(data: string): boolean { const previousRawText = recentRawText - const scanText = stripTerminalControl(previousRawText + data) - const scanTextWithChunkBoundary = stripTerminalControl( - previousRawText ? `${previousRawText}\n${data}` : data - ) - recentRawText = (previousRawText + data).slice(-RECENT_TEXT_LIMIT) - if ( - !hasSeenCommandCodeUi && - (/\bCommand Code\b/.test(scanText) || /\bCommand Code\b/.test(scanTextWithChunkBoundary)) - ) { + const rawText = previousRawText + data + recentRawText = rawText.slice(-RECENT_TEXT_LIMIT) + + if (!hasSeenCommandCodeUi) { + if (!rawTextMayContainCommandCodeBanner(rawText)) { + return false + } + const scanText = stripTerminalControl(rawText) + const scanTextWithChunkBoundary = previousRawText + ? stripTerminalControl(`${previousRawText}\n${data}`) + : scanText + if ( + !COMMAND_CODE_BANNER_RE.test(scanText) && + !COMMAND_CODE_BANNER_RE.test(scanTextWithChunkBoundary) + ) { + return false + } hasSeenCommandCodeUi = true } - if (!hasSeenCommandCodeUi) { - return false + + const scanText = stripTerminalControl(rawText) + const scanTextWithChunkBoundary = previousRawText + ? stripTerminalControl(`${previousRawText}\n${data}`) + : scanText + const previousTextLength = previousRawText ? stripTerminalControl(previousRawText).length : 0 + const previousTextWithChunkBoundaryLength = previousRawText + ? stripTerminalControl(`${previousRawText}\n`).length + : 0 + const statusContext: StatusScanContext = { + combinedText: scanText, + previousTextLength, + combinedTextWithChunkBoundary: scanTextWithChunkBoundary, + previousTextWithChunkBoundaryLength } for (const promptMatch of scanText.matchAll(/(?:^|[\r\n])\s*[❯>]\s+([^\r\n]+)(?=[\r\n])/g)) { const prompt = cleanPromptCandidate(promptMatch[1] ?? '') @@ -199,20 +261,14 @@ export function createCommandCodeOutputStatusDetector(args: { // Why: Command Code lacks a prompt-start hook. Its TUI prints these // status words while a submitted prompt is actively running, including // no-tool turns that would otherwise jump straight from idle to done. - if ( - isActiveStatusText(data, previousRawText) || - isActiveStatusText(data, `${previousRawText}\n`) - ) { + if (isActiveStatusText(statusContext)) { args.onWorking(lastSubmittedPrompt) return true } // Why: Command Code does not reliably emit a Stop hook for no-tool turns. // When a submitted prompt has returned to the idle composer, let the pane // connection settle-check the current row and mark that turn done. - if ( - lastSubmittedPrompt && - (isIdlePromptText(data, previousRawText) || isIdlePromptText(data, `${previousRawText}\n`)) - ) { + if (lastSubmittedPrompt && isIdlePromptText(statusContext)) { args.onDone?.(lastSubmittedPrompt) return true } From 527151c1c086818eb833eec0cf9eb247d1feec83 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:32:07 -0700 Subject: [PATCH 42/57] fix: guard resource kill async state (#3554) --- .../status-bar/ResourceUsageStatusSegment.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index a79869011fd..2e1b6f0eaaf 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -1080,19 +1080,21 @@ export function ResourceUsageStatusSegment({ } catch { /* already dead — fall through */ } finally { - setKilling(false) - setKillConfirm(null) - // Why: after the killed row unmounts, focus would otherwise drop to - // . Park focus on the popover body so keyboard users land back - // in the list rather than outside the popover. - cancelPopoverBodyFocusFrame() - if (popoverBodyRef.current) { - popoverBodyFocusFrameRef.current = requestAnimationFrame(() => { - popoverBodyFocusFrameRef.current = null - popoverBodyRef.current?.focus() - }) + if (mountedRef.current) { + setKilling(false) + setKillConfirm(null) + // Why: after the killed row unmounts, focus would otherwise drop to + // . Park focus on the popover body so keyboard users land back + // in the list rather than outside the popover. + cancelPopoverBodyFocusFrame() + if (popoverBodyRef.current) { + popoverBodyFocusFrameRef.current = requestAnimationFrame(() => { + popoverBodyFocusFrameRef.current = null + popoverBodyRef.current?.focus() + }) + } + void refreshSessions() } - void refreshSessions() } }, [cancelPopoverBodyFocusFrame, killConfirm, refreshSessions]) From 5ef97d581cc382c49c775a316f1fbbae4cd4ad82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:36:49 -0700 Subject: [PATCH 43/57] fix: guard status bar account async state (#3555) --- .../src/components/status-bar/StatusBar.tsx | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index a7b4cce58b6..96bcb24daae 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -588,6 +588,7 @@ function ClaudeSwitcherMenu({ activeAccountIdsByRuntime: { host: null, wsl: {} } }) const [isSwitching, setIsSwitching] = useState(false) + const mountedRef = useRef(true) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const fetchSettings = useAppStore((s) => s.fetchSettings) @@ -609,9 +610,18 @@ function ClaudeSwitcherMenu({ }) const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const loadAccounts = useCallback(async () => { const next = await window.api.claudeAccounts.list() - setAccounts(next) + if (mountedRef.current) { + setAccounts(next) + } }, []) useEffect(() => { @@ -648,13 +658,19 @@ function ClaudeSwitcherMenu({ wslDistro: target.wslDistro }) recordFeatureInteraction('claude-account-switching') - setAccounts(next) + if (mountedRef.current) { + setAccounts(next) + } await fetchSettings() - setAccountsExpanded(false) + if (mountedRef.current) { + setAccountsExpanded(false) + } } catch (error) { console.error('Failed to switch Claude account from status bar:', error) } finally { - setIsSwitching(false) + if (mountedRef.current) { + setIsSwitching(false) + } } } @@ -1046,6 +1062,7 @@ function CodexSwitcherMenu({ }) const [isSwitching, setIsSwitching] = useState(false) const [reauthenticatingAccountId, setReauthenticatingAccountId] = useState(null) + const mountedRef = useRef(true) const accountsExpandedRef = useRef(accountsExpanded) // Why: Radix item selection is separate from the nested button click, so // propagation stops alone do not prevent the row switch action. @@ -1079,7 +1096,16 @@ function CodexSwitcherMenu({ const loadAccounts = useCallback(async () => { const next = await window.api.codexAccounts.list() - setAccounts(next) + if (mountedRef.current) { + setAccounts(next) + } + }, []) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } }, []) useEffect(() => { @@ -1112,7 +1138,9 @@ function CodexSwitcherMenu({ wslDistro: target.wslDistro }) recordFeatureInteraction('codex-account-switching') - setAccounts(next) + if (mountedRef.current) { + setAccounts(next) + } await fetchSettings() const nextActiveAccountId = getCodexStatusActiveId(next, target) if (previousActiveAccountId !== nextActiveAccountId) { @@ -1124,12 +1152,16 @@ function CodexSwitcherMenu({ // for live Codex terminals. Keeping the switcher open and collapsing // back to the summary row lets the follow-up "restart open tabs" // prompt appear in the same flow instead of feeling detached. - setAccountsExpanded(false) + if (mountedRef.current) { + setAccountsExpanded(false) + } } } catch (error) { console.error('Failed to switch Codex account from status bar:', error) } finally { - setIsSwitching(false) + if (mountedRef.current) { + setIsSwitching(false) + } } } @@ -1141,15 +1173,19 @@ function CodexSwitcherMenu({ try { const next = await window.api.codexAccounts.reauthenticate({ accountId }) recordFeatureInteraction('codex-account-switching') - setAccounts(next) + if (mountedRef.current) { + setAccounts(next) + } await fetchSettings() - if (accountsExpandedRef.current) { + if (mountedRef.current && accountsExpandedRef.current) { await fetchInactiveCodexAccountUsage() } } catch (error) { console.error('Failed to re-authenticate Codex account from status bar:', error) } finally { - setReauthenticatingAccountId(null) + if (mountedRef.current) { + setReauthenticatingAccountId(null) + } } } @@ -1432,6 +1468,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const toggleStatusBarItem = useAppStore((s) => s.toggleStatusBarItem) const containerRef = useRef(null) + const mountedRef = useRef(true) const [isRefreshing, setIsRefreshing] = useState(false) const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) @@ -1439,6 +1476,13 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const [containerWidth, setContainerWidth] = useState(900) const resizeObserverRef = useRef(null) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { const closeMenu = (): void => setMenuOpen(false) window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) @@ -1482,7 +1526,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // appears (and a removed CLI's bar hides) without restarting Orca. await Promise.all([refreshRateLimits(), refreshDetectedAgents()]) } finally { - setIsRefreshing(false) + if (mountedRef.current) { + setIsRefreshing(false) + } } }, [isRefreshing, refreshRateLimits, refreshDetectedAgents]) From 7eb0148e8639742894a8523ce2725d4cd2d8f429 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:36:59 -0700 Subject: [PATCH 44/57] perf: guard SSH target row async state without effect (#3556) --- src/renderer/src/components/sidebar/SshTargetRow.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 30338596f07..228c0012486 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -4,7 +4,7 @@ * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit * while isolating the inline-connect interaction logic. */ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' @@ -57,15 +57,15 @@ export function SshTargetRow({ }) } - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } + const handleRowRootRef = useCallback((node: HTMLDivElement | null): void => { + // Why: SSH connects can resolve after this row is removed; the row ref + // gives the async completion the same guard without a mount-only Effect. + mountedRef.current = node !== null }, []) return (
Date: Sat, 30 May 2026 01:41:52 -0700 Subject: [PATCH 45/57] fix: guard combined diff notes async state (#3558) --- .../src/components/editor/CombinedDiffViewer.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 62a55ad8270..f965cb25342 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -181,6 +181,7 @@ export default function CombinedDiffViewer({ const [clearNotesDialogOpen, setClearNotesDialogOpen] = useState(false) const [isClearingNotes, setIsClearingNotes] = useState(false) const [notesCopied, setNotesCopied] = useState(false) + const mountedRef = useRef(true) // Why: clipboard IPC can resolve after the combined diff unmounts; skip // copied feedback instead of starting a reset timer on a stale viewer. const notesCopyMountedRef = useRef(false) @@ -203,6 +204,13 @@ export default function CombinedDiffViewer({ scrollContainerRef.current = node notesCopyMountedRef.current = node !== null }, []) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const loadSchedulerRef = useRef( createCombinedDiffLoadScheduler({ loadSection: (index) => loadSectionRef.current(index) @@ -906,13 +914,18 @@ export default function CombinedDiffViewer({ setIsClearingNotes(true) try { const ok = await clearDiffComments(file.worktreeId) + if (!mountedRef.current) { + return + } if (ok) { setClearNotesDialogOpen(false) } else { toast.error('Failed to clear notes.') } } finally { - setIsClearingNotes(false) + if (mountedRef.current) { + setIsClearingNotes(false) + } } }, [clearDiffComments, diffCommentCount, file.worktreeId, isClearingNotes]) From c58a47c412484bdc0ee97b17356530ec8e67ed10 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:47:05 -0700 Subject: [PATCH 46/57] perf: guard SSH target card async state without effect (#3559) --- .../src/components/settings/SshTargetCard.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index c1b0c139320..d794c64b921 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { CircleStop, Loader2, @@ -87,11 +87,10 @@ export function SshTargetCard({ const removeInFlight = busyAction === 'remove' const mountedRef = useRef(true) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } + const handleCardRef = useCallback((node: HTMLDivElement | null): void => { + // Why: SSH target actions can resolve after the card is removed; the root + // ref gives async completions the same stale-write guard without an Effect. + mountedRef.current = node !== null }, []) const clearActionInFlight = (): void => { @@ -226,7 +225,10 @@ export function SshTargetCard({ ) return ( -
+
From 53edb10e0dfbbf29d1a56989403b07fe348ddf4d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:47:23 -0700 Subject: [PATCH 47/57] fix: guard comment composer async state (#3560) --- .../src/components/LinearItemDrawer.tsx | 19 +++++++++++++++++-- .../src/components/editor/MarkdownPreview.tsx | 15 ++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 173c287bcbc..0e8e99e7585 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -953,6 +953,14 @@ export function LinearIssueCommentFooter({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const autoGrow = useCallback(() => { const el = textareaRef.current @@ -972,6 +980,9 @@ export function LinearIssueCommentFooter({ try { const result = await linearAddIssueComment(settings, issueId, trimmed, workspaceId) const typed = result as { ok: boolean; id?: string; error?: string } + if (!mountedRef.current) { + return + } if (typed.ok) { setBody('') onCommentAdded({ @@ -983,9 +994,13 @@ export function LinearIssueCommentFooter({ toast.error(typed.error ?? 'Failed to add comment') } } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add comment') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to add comment') + } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } }, [body, issueId, onCommentAdded, settings, workspaceId]) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 218d6e4942c..64364ed8d55 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -1713,6 +1713,14 @@ function MarkdownAnnotationComposer({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) useEffect(() => { textareaRef.current?.focus() @@ -1727,11 +1735,16 @@ function MarkdownAnnotationComposer({ setSubmitting(true) try { const ok = await onSubmit(trimmed) + if (!mountedRef.current) { + return + } if (ok) { setBody('') } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } } From afb05887746eacf6700773d57ae942b124c4f192 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:51:57 -0700 Subject: [PATCH 48/57] fix: guard github comment async state (#3561) --- .../src/components/GitHubItemDialog.tsx | 34 +++++++++++++++++-- .../src/components/PullRequestPage.tsx | 34 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 1b350a15ae7..fdeeec7877e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -3112,6 +3112,14 @@ function CommentReplyForm({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) useEffect(() => { textareaRef.current?.focus() @@ -3125,11 +3133,16 @@ function CommentReplyForm({ setSubmitting(true) try { const ok = await onSubmit(trimmed) + if (!mountedRef.current) { + return + } if (ok) { setBody('') } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } }, [body, onSubmit, submitting]) @@ -5008,6 +5021,14 @@ function GHCommentComposer({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const autoGrow = useCallback(() => { const el = textareaRef.current @@ -5032,6 +5053,9 @@ function GHCommentComposer({ body: trimmed, type: itemType }) + if (!mountedRef.current) { + return + } if (result.ok) { setBody('') requestAnimationFrame(autoGrow) @@ -5042,9 +5066,13 @@ function GHCommentComposer({ toast.error(result.error ?? 'Failed to add comment') } } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add comment') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to add comment') + } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } }, [autoGrow, body, repoPath, repoId, issueNumber, itemType, onCommentAdded]) diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 0b4d0191add..88816c6e7a9 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -3241,6 +3241,14 @@ function CommentReplyForm({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) useEffect(() => { textareaRef.current?.focus() @@ -3254,11 +3262,16 @@ function CommentReplyForm({ setSubmitting(true) try { const ok = await onSubmit(trimmed) + if (!mountedRef.current) { + return + } if (ok) { setBody('') } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } }, [body, onSubmit, submitting]) @@ -4903,6 +4916,14 @@ function GHCommentComposer({ const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const autoGrow = useCallback(() => { const el = textareaRef.current @@ -4927,6 +4948,9 @@ function GHCommentComposer({ body: trimmed, type: itemType }) + if (!mountedRef.current) { + return + } if (result.ok) { setBody('') requestAnimationFrame(autoGrow) @@ -4937,9 +4961,13 @@ function GHCommentComposer({ toast.error(result.error ?? 'Failed to add comment') } } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add comment') + if (mountedRef.current) { + toast.error(err instanceof Error ? err.message : 'Failed to add comment') + } } finally { - setSubmitting(false) + if (mountedRef.current) { + setSubmitting(false) + } } }, [autoGrow, body, repoPath, repoId, issueNumber, itemType, onCommentAdded]) From 5862e82285d3b3f9c6110ee22d87fa156da7fa79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:57:29 -0700 Subject: [PATCH 49/57] fix: guard cli setup async state (#3562) --- .../FloatingTerminalOrchestrationDialog.tsx | 50 +++++++++++++---- .../FloatingTerminalPanel.tsx | 16 +++++- .../settings/WslCliRegistration.tsx | 53 ++++++++++++++----- 3 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 6e4d0044a32..4f0002fb601 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Check, Clipboard, Copy, Loader2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -40,17 +40,35 @@ export function FloatingTerminalOrchestrationDialog({ const [cliLoading, setCliLoading] = useState(false) const [cliBusy, setCliBusy] = useState(false) const [skillBusy, setSkillBusy] = useState(false) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const setCliStatusIfMounted = useCallback((status: CliInstallStatus | null): void => { + if (mountedRef.current) { + setCliStatus(status) + } + }, []) const refreshCliStatus = useCallback(async (): Promise => { setCliLoading(true) try { - setCliStatus(await window.api.cli.getInstallStatus()) + setCliStatusIfMounted(await window.api.cli.getInstallStatus()) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to load CLI status.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to load CLI status.') + } } finally { - setCliLoading(false) + if (mountedRef.current) { + setCliLoading(false) + } } - }, []) + }, [setCliStatusIfMounted]) useEffect(() => { if (open) { @@ -70,8 +88,11 @@ export function FloatingTerminalOrchestrationDialog({ setCliBusy(true) try { const next = await ensureOrcaCliAvailableForAgentSkillTerminal({ - onStatusChange: setCliStatus + onStatusChange: setCliStatusIfMounted }) + if (!mountedRef.current) { + return + } if (next) { notifyOrchestrationSetupStateChanged() onSetupStateChange() @@ -80,7 +101,9 @@ export function FloatingTerminalOrchestrationDialog({ toast.success('Registered the Orca CLI in PATH.') } } finally { - setCliBusy(false) + if (mountedRef.current) { + setCliBusy(false) + } } } @@ -88,8 +111,11 @@ export function FloatingTerminalOrchestrationDialog({ setSkillBusy(true) try { const nextCliStatus = await ensureOrcaCliAvailableForAgentSkillTerminal({ - onStatusChange: setCliStatus + onStatusChange: setCliStatusIfMounted }) + if (!mountedRef.current) { + return + } localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, '1') localStorage.removeItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY) notifyOrchestrationSetupStateChanged() @@ -112,9 +138,13 @@ export function FloatingTerminalOrchestrationDialog({ onOpenChange(false) } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy skill command.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to copy skill command.') + } } finally { - setSkillBusy(false) + if (mountedRef.current) { + setSkillBusy(false) + } } } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 84ca4f83727..a6109500646 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -141,6 +141,7 @@ export function FloatingTerminalPanel({ const panelRef = useRef(null) const shortcutFocusFrameRef = useRef(null) const shortcutFocusTimeoutRef = useRef(null) + const mountedRef = useRef(true) const dragRef = useRef<{ pointerId: number startX: number @@ -344,6 +345,13 @@ export function FloatingTerminalPanel({ handleSaveDialogCancel() }, [handleSaveDialogCancel]) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { if (!open || normalizedInitialBoundsRef.current || typeof window === 'undefined') { return @@ -410,9 +418,13 @@ export function FloatingTerminalPanel({ } try { const status = await window.api.cli.getInstallStatus() - setShowOrchestrationSetup(!isOrcaCliAvailableOnPath(status)) + if (mountedRef.current) { + setShowOrchestrationSetup(!isOrcaCliAvailableOnPath(status)) + } } catch { - setShowOrchestrationSetup(true) + if (mountedRef.current) { + setShowOrchestrationSetup(true) + } } }, []) diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index c1d9f1eda47..a05224ecc5c 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' @@ -26,17 +26,32 @@ export function WslCliRegistration({ const [loading, setLoading] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null) + const mountedRef = useRef(true) const { wslAvailable } = useWindowsTerminalCapabilities(currentPlatform === 'win32') const showWslCli = currentPlatform === 'win32' && wslAvailable + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const refreshStatus = async (): Promise => { setLoading(true) try { - setStatus(await window.api.cli.getWslInstallStatus()) + const next = await window.api.cli.getWslInstallStatus() + if (mountedRef.current) { + setStatus(next) + } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to load WSL CLI status.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to load WSL CLI status.') + } } finally { - setLoading(false) + if (mountedRef.current) { + setLoading(false) + } } } @@ -58,15 +73,22 @@ export function WslCliRegistration({ setBusyAction('install') try { const next = await window.api.cli.installWsl() + if (!mountedRef.current) { + return + } setStatus(next) setDialogOpen(false) toast.success(`Registered \`${next.commandName}\` in WSL.`) } catch (error) { - toast.error( - error instanceof Error ? error.message : `Failed to register \`${commandName}\` in WSL.` - ) + if (mountedRef.current) { + toast.error( + error instanceof Error ? error.message : `Failed to register \`${commandName}\` in WSL.` + ) + } } finally { - setBusyAction(null) + if (mountedRef.current) { + setBusyAction(null) + } } } @@ -74,15 +96,22 @@ export function WslCliRegistration({ setBusyAction('remove') try { const next = await window.api.cli.removeWsl() + if (!mountedRef.current) { + return + } setStatus(next) setDialogOpen(false) toast.success(`Removed \`${next.commandName}\` from WSL.`) } catch (error) { - toast.error( - error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from WSL.` - ) + if (mountedRef.current) { + toast.error( + error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from WSL.` + ) + } } finally { - setBusyAction(null) + if (mountedRef.current) { + setBusyAction(null) + } } } From 916624a40d5c46c4732f5bb1897a9f2764bbacdd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 01:57:53 -0700 Subject: [PATCH 50/57] perf: guard voice pane permission state without effect (#3563) --- src/renderer/src/components/settings/VoicePane.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index dd057108c8e..1dd5dc5f0b1 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../shared/types' import { getDefaultVoiceSettings } from '../../../../shared/constants' import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types' @@ -34,11 +34,10 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const [permissionPending, setPermissionPending] = useState(false) const mountedRef = useRef(true) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } + const handlePaneRef = useCallback((node: HTMLDivElement | null): void => { + // Why: the microphone permission prompt can resolve after Settings closes; + // the pane ref gives that completion a stale-write guard without an Effect. + mountedRef.current = node !== null }, []) useEffect(() => { @@ -116,7 +115,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const selectedIsReady = selectedModelState?.status === 'ready' return ( -
+
From 90b7879f1c0a9dad11bbced05a8b553999a4b533 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 02:01:50 -0700 Subject: [PATCH 51/57] fix: guard linear connect async state (#3564) --- src/renderer/src/components/TaskPage.tsx | 17 +++++++++++++++-- .../onboarding/IntegrationsStep.tsx | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 52f0f922dc0..42905687002 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -3170,6 +3170,14 @@ export default function TaskPage(): React.JSX.Element { 'idle' ) const [linearConnectError, setLinearConnectError] = useState(null) + const linearConnectMountedRef = useRef(true) + + useEffect(() => { + linearConnectMountedRef.current = true + return () => { + linearConnectMountedRef.current = false + } + }, []) const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) const selectedGitHubRepoExternalLink = useMemo(() => { @@ -4259,6 +4267,9 @@ export default function TaskPage(): React.JSX.Element { setLinearConnectError(null) try { const result = await connectLinear(key) + if (!linearConnectMountedRef.current) { + return + } if (result.ok) { setLinearApiKeyDraft('') setLinearConnectState('idle') @@ -4268,8 +4279,10 @@ export default function TaskPage(): React.JSX.Element { setLinearConnectError(result.error) } } catch (error) { - setLinearConnectState('error') - setLinearConnectError(error instanceof Error ? error.message : 'Connection failed') + if (linearConnectMountedRef.current) { + setLinearConnectState('error') + setLinearConnectError(error instanceof Error ? error.message : 'Connection failed') + } } }, [connectLinear, linearApiKeyDraft]) diff --git a/src/renderer/src/components/onboarding/IntegrationsStep.tsx b/src/renderer/src/components/onboarding/IntegrationsStep.tsx index 322e236953c..8f38b6a675d 100644 --- a/src/renderer/src/components/onboarding/IntegrationsStep.tsx +++ b/src/renderer/src/components/onboarding/IntegrationsStep.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ExternalLink, Github, Loader2, Terminal } from 'lucide-react' import { LinearIcon } from '@/components/icons/LinearIcon' import { Button } from '@/components/ui/button' @@ -123,6 +123,14 @@ export function LinearRow(props: { compact?: boolean } = {}): React.JSX.Element const [apiKeyDraft, setApiKeyDraft] = useState('') const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') const [connectError, setConnectError] = useState(null) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const workspaceCount = linearStatus.workspaces?.length ?? (linearStatus.connected ? 1 : 0) @@ -135,6 +143,9 @@ export function LinearRow(props: { compact?: boolean } = {}): React.JSX.Element setConnectError(null) try { const result = await connectLinear(apiKey) + if (!mountedRef.current) { + return + } if (result.ok) { setApiKeyDraft('') setConnectState('idle') @@ -144,8 +155,10 @@ export function LinearRow(props: { compact?: boolean } = {}): React.JSX.Element setConnectState('error') setConnectError(result.error) } catch (error) { - setConnectState('error') - setConnectError(error instanceof Error ? error.message : 'Connection failed') + if (mountedRef.current) { + setConnectState('error') + setConnectError(error instanceof Error ? error.message : 'Connection failed') + } } } From 4c1e17bce5e0f78662497909b902ffedaab7df59 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 02:03:39 -0700 Subject: [PATCH 52/57] perf: guard Linear comment footer async state without effect (#3565) --- .../src/components/LinearItemDrawer.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 0e8e99e7585..e8dc2c36d7e 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -955,11 +955,10 @@ export function LinearIssueCommentFooter({ const textareaRef = useRef(null) const mountedRef = useRef(true) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } + const handleFooterRef = useCallback((node: HTMLDivElement | null): void => { + // Why: comment submission can resolve after the footer unmounts; the root + // ref keeps that completion from writing stale local state without an Effect. + mountedRef.current = node !== null }, []) const autoGrow = useCallback(() => { @@ -1016,7 +1015,10 @@ export function LinearIssueCommentFooter({ if (variant === 'linear-page') { return ( -
+