diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts new file mode 100644 index 000000000..ad66359d9 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { + matchingWorktreeBaseRepoIds, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +const COMMON_DIR = join('/repos', 'project', '.git') + +function makeGitCommonTarget(): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${COMMON_DIR}`, + kind: 'git-common', + path: COMMON_DIR, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } +} + +describe('matchingWorktreeBaseRepoIds (git-common)', () => { + it('matches linked-worktree metadata under worktrees/', () => { + const target = makeGitCommonTarget() + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD') + }) + ).toEqual(['repo-1']) + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'create', + path: join(COMMON_DIR, 'worktrees', 'wt-b') + }) + ).toEqual(['repo-1']) + }) + + it('matches primary-checkout branch/index metadata at the common-dir top level', () => { + const target = makeGitCommonTarget() + for (const file of ['HEAD', 'packed-refs', 'index']) { + expect( + matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) }) + ).toEqual(['repo-1']) + } + }) + + it('ignores non-status common-dir churn', () => { + const target = makeGitCommonTarget() + for (const path of [ + join(COMMON_DIR, 'config'), + join(COMMON_DIR, 'FETCH_HEAD'), + join(COMMON_DIR, 'COMMIT_EDITMSG'), + join(COMMON_DIR, 'objects', 'ab', 'cdef'), + join(COMMON_DIR, 'refs', 'heads', 'main'), + join(COMMON_DIR, 'logs', 'HEAD'), + // Nested HEAD outside worktrees/ must not be mistaken for the primary's. + join(COMMON_DIR, 'modules', 'sub', 'HEAD') + ]) { + expect(matchingWorktreeBaseRepoIds(target, { type: 'update', path })).toEqual([]) + } + }) + + it('ignores paths outside the watch root', () => { + const target = makeGitCommonTarget() + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join('/repos', 'project', 'HEAD') + }) + ).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 4c9d294cf..c12da5d21 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -82,11 +82,22 @@ function matchingBaseRepoIds( return repoIds } +// Why: branch switches and commits in the primary checkout rewrite these +// top-level common-dir files; matching them keeps root-checkout branch/status +// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored. +const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index']) + // Why: Git records linked worktrees under the common dir's `worktrees` // metadata, which is lower churn than watching checkout contents. function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] { const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) - if (!parts || parts[0] !== 'worktrees') { + if (!parts) { + return [] + } + if ( + parts[0] !== 'worktrees' && + !(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0])) + ) { return [] } return [...target.repos.keys()] diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index cf8ce22a6..292530f08 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -204,6 +204,33 @@ describe('worktree base directory poller', () => { ) }) + it('reports primary-checkout HEAD changes via polling', async () => { + const commonDir = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + // Force the non-darwin poll path so this test is deterministic on all CI. + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === headFile) + ) + + // A branch switch rewrites HEAD in place; the mtime diff must surface it. + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(headFile, 'ref: refs/heads/feature') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + }) + it('emits deletes for all known worktrees when the root vanishes', async () => { const root = await makeRoot() const worktree = join(root, 'external-5') @@ -255,6 +282,34 @@ describe('worktree base directory poller', () => { ) }) + it('covers primary-checkout metadata alongside the narrow stream', async () => { + const commonDir = await makeRoot() + await mkdir(join(commonDir, 'worktrees')) + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'darwin' } + ) + cleanups.push(() => poller.unsubscribe()) + + // The narrow stream is rooted at worktrees/, so top-level HEAD writes + // must arrive through the companion metadata poll. + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === headFile) + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(headFile, 'ref: refs/heads/feature') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index d328220aa..1529f9de1 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -6,14 +6,37 @@ import type { WorktreeBaseSubscription } from './worktree-base-directory-poller' -// Watches a repo's `/.git/worktrees` metadata — the only subtree the -// git-common event filter consumes. -// macOS: a narrow native stream rooted there — a tiny, rare-churn tree — -// gives instant detection with zero idle cost and zero wide-scope fseventsd -// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and +// Watches a repo's `/.git/worktrees` metadata plus the primary +// checkout's shallow branch/index files — the only paths the git-common event +// filter consumes. +// macOS: a narrow native stream rooted at `worktrees/` — a tiny, rare-churn +// tree — gives instant detection with zero idle cost and zero wide-scope +// fseventsd delivery; the primary files are covered by a few stat calls per +// tick (a native stream would have to span the whole common dir, objects +// included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// Why: branch switches and commits made in the primary checkout rewrite these +// top-level files (linked-worktree equivalents live under `worktrees/`). +// Deliberately excludes FETCH_HEAD-style churn that carries no status change. +const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index'] + +async function snapshotPrimaryCheckoutMetadata( + commonDirPath: string +): Promise> { + const mtimes = new Map() + for (const name of PRIMARY_CHECKOUT_METADATA_FILES) { + const filePath = join(commonDirPath, name) + try { + mtimes.set(filePath, (await stat(filePath)).mtimeMs) + } catch { + // Missing file (e.g. no packed-refs yet) diffs into a create later. + } + } + return mtimes +} + async function snapshotGitCommon(commonDirPath: string): Promise> { const mtimes = new Map() const worktreesDir = join(commonDirPath, 'worktrees') @@ -59,15 +82,15 @@ function diffGitCommon( return events } -async function startGitCommonPoller( - target: WorktreeBaseWatchTarget, +async function startSnapshotDiffPoller( + takeSnapshot: () => Promise>, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, onFullScan?: () => void ): Promise { let disposed = false let ticking = false - let snapshot = await snapshotGitCommon(target.path) + let snapshot = await takeSnapshot() const timer = setInterval(() => { if (disposed || ticking) { @@ -75,7 +98,7 @@ async function startGitCommonPoller( } ticking = true onFullScan?.() - void snapshotGitCommon(target.path) + void takeSnapshot() .then((next) => { if (disposed) { return @@ -103,6 +126,19 @@ async function startGitCommonPoller( } } +async function snapshotGitCommonAndPrimaryMetadata( + commonDirPath: string +): Promise> { + const [worktrees, primary] = await Promise.all([ + snapshotGitCommon(commonDirPath), + snapshotPrimaryCheckoutMetadata(commonDirPath) + ]) + for (const [path, mtime] of primary) { + worktrees.set(path, mtime) + } + return worktrees +} + async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, @@ -227,7 +263,25 @@ export async function startGitCommonWatch( onFullScan?: () => void ): Promise { if (platform === 'darwin') { - return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs) + const [narrowWatch, primaryMetadataPoll] = await Promise.all([ + startGitCommonNarrowWatch(target, onEvents, pollIntervalMs), + startSnapshotDiffPoller( + () => snapshotPrimaryCheckoutMetadata(target.path), + onEvents, + pollIntervalMs, + onFullScan + ) + ]) + return { + unsubscribe: async () => { + await Promise.all([narrowWatch.unsubscribe(), primaryMetadataPoll.unsubscribe()]) + } + } } - return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan) + return startSnapshotDiffPoller( + () => snapshotGitCommonAndPrimaryMetadata(target.path), + onEvents, + pollIntervalMs, + onFullScan + ) } diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts new file mode 100644 index 000000000..e5bbde860 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as React from 'react' +import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT } from '@/hooks/terminal-command-finished-event' + +type WorktreesChangedCallback = (data: { repoId: string }) => void +type HookParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +async function renderHookOnce(params: HookParams): Promise<{ + emitWorktreesChanged: (repoId: string) => void + emitCommandFinished: (worktreeId: string) => void + onChangedSubscribe: ReturnType + windowListeners: Map + cleanups: (() => void)[] +}> { + vi.resetModules() + + const cleanups: (() => void)[] = [] + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect() + if (typeof cleanup === 'function') { + cleanups.push(cleanup) + } + }, + useRef: (initial: T) => ({ current: initial }) + } + }) + + let worktreesChangedCallback: WorktreesChangedCallback | null = null + const onChangedUnsubscribe = vi.fn() + const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => { + worktreesChangedCallback = callback + return onChangedUnsubscribe + }) + const windowListeners = new Map() + + vi.stubGlobal('window', { + api: { + worktrees: { + onChanged: onChangedSubscribe + } + }, + addEventListener: vi.fn((type: string, listener: EventListener) => { + windowListeners.set(type, listener) + }), + removeEventListener: vi.fn((type: string) => { + windowListeners.delete(type) + }) + }) + vi.stubGlobal('document', { + visibilityState: 'visible', + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh') + function PushSignalRefreshHarness(props: HookParams): null { + useGitStatusPushSignalRefresh(props) + return null + } + PushSignalRefreshHarness(params) + + return { + emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }), + emitCommandFinished: (worktreeId: string) => { + const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT) + listener?.({ detail: { worktreeId } } as unknown as Event) + }, + onChangedSubscribe, + windowListeners, + cleanups + } +} + +describe('useGitStatusPushSignalRefresh', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('nudges status when the active repo reports a worktrees change', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitWorktreesChanged('repo-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitWorktreesChanged('repo-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('nudges status when a terminal command finishes in the active worktree', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitCommandFinished('wt-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitCommandFinished('wt-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('drops nudges while the window is hidden', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + ;(document as unknown as { visibilityState: string }).visibilityState = 'hidden' + harness.emitWorktreesChanged('repo-1') + harness.emitCommandFinished('wt-1') + expect(fetchStatus).not.toHaveBeenCalled() + }) + + it('subscribes to nothing while disabled', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: false, + fetchStatus + }) + + expect(harness.onChangedSubscribe).not.toHaveBeenCalled() + expect(harness.windowListeners.size).toBe(0) + }) + + it('unsubscribes both signals on cleanup', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + expect(harness.cleanups.length).toBe(2) + for (const cleanup of harness.cleanups) { + cleanup() + } + expect(harness.windowListeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts new file mode 100644 index 000000000..e6e0ea6d8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +type UseGitStatusPushSignalRefreshParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +// Why: these push signals close the latency gap left by the slow terminal-only +// fallback poll — branch switches and commits made inside shells surface at +// the coalescer's floor instead of waiting out the fallback cadence. Bursts +// are safe: the main-process watcher debounces and fetchStatus feeds a +// coalesced runner with a minimum interval. +export function useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled, + fetchStatus +}: UseGitStatusPushSignalRefreshParams): void { + const fetchStatusRef = useRef(fetchStatus) + fetchStatusRef.current = fetchStatus + + useEffect(() => { + if (!enabled || !activeRepoId) { + return + } + // Why: remote web surfaces have no preload bridge; the fallback poll + // still covers them. + const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged + if (!subscribeToWorktreesChanged) { + return + } + // Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows + // skip the nudge; the visibility interval refreshes immediately on reveal. + return subscribeToWorktreesChanged(({ repoId }) => { + if (repoId !== activeRepoId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + }) + }, [enabled, activeRepoId]) + + useEffect(() => { + if (!enabled || !activeWorktreeId) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [enabled, activeWorktreeId]) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 83ad40e3a..5c4d57338 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -114,6 +114,9 @@ async function usePollingOnce( watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), @@ -362,6 +365,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -471,6 +477,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -592,6 +601,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -707,6 +719,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 0fc1bc8b4..895b69012 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -14,11 +14,13 @@ import { } from '@/lib/passive-macos-app-data-access' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' +import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' const MIN_STATUS_REFRESH_INTERVAL_MS = 3000 const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS -// Why: file-watch refreshes cover content changes; terminal-only polling is -// just a fallback for branch switches made inside shells. +// Why: file-watch refreshes cover content changes and push signals (repo +// metadata watch, shell command completion) cover branch switches; the +// terminal-only poll is a last-resort backstop for shells without either. const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { @@ -182,6 +184,13 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { worktreePath }) + useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled: shouldPollActiveWorktreeGitStatus, + fetchStatus + }) + // Why: poll conflict operation for non-active worktrees that have a stale // non-unknown operation. This is a lightweight fs-only check (no git status) // so it won't cause performance issues even with many worktrees. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 3147e2916..f9b025da2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -85,6 +85,7 @@ import { import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' +import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' @@ -1373,6 +1374,9 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(deps.worktreeId) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts new file mode 100644 index 000000000..79bf8a978 --- /dev/null +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -0,0 +1,16 @@ +export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finished' + +export type TerminalCommandFinishedEventDetail = { + worktreeId: string +} + +// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets +// decoupled consumers (e.g. git status refresh) react to shell commands +// finishing without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId } + }) + ) +}