Merge remote-tracking branch 'origin/main' into nwparker/react-perf-picker-focus-ref-cleanup

# Conflicts:
#	src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
This commit is contained in:
Jinwoo-H
2026-05-30 02:25:10 -07:00
134 changed files with 5939 additions and 778 deletions
+108
View File
@@ -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.
+1 -1
View File
@@ -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",
@@ -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'
}
}
@@ -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')
+14 -6
View File
@@ -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'
}
+27 -11
View File
@@ -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
}
)
}
}
)
+7 -9
View File
@@ -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()
}
+38 -3
View File
@@ -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,
+6 -6
View File
@@ -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)
+20
View File
@@ -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 {
+4 -1
View File
@@ -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
+83 -39
View File
@@ -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 () => {
+42 -22
View File
@@ -105,15 +105,22 @@ export class SshGitProvider implements IGitProvider {
async executeCommitMessagePlan(
plan: CommitMessagePlan,
cwd: string,
timeoutMs: number
timeoutMs: number,
operation = 'commit-message'
): Promise<RemoteCommitMessageExecResult> {
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<void> {
const queue = this.nonInteractiveExecQueues.get(cwd)
async cancelNonInteractiveExec(cwd: string, operation?: string): Promise<void> {
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<void> {
private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise<void> {
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<void> {
async cancelGenerateCommitMessage(
worktreePath: string,
operation = 'commit-message'
): Promise<void> {
// 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<string, string>
operation?: string
},
signal?: AbortSignal
signal?: AbortSignal,
operation?: string
): Promise<RemoteCommitMessageExecResult> {
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)
}
}
}
@@ -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<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
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
})
})
})
+7 -2
View File
@@ -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<Provi
session: null,
weekly: null,
updatedAt: Date.now(),
error: withMacTailscaleDnsHint('PTY timeout', output),
error: extractCodexAuthError(output) ?? withMacTailscaleDnsHint('PTY timeout', output),
status: 'error'
})
}
@@ -464,7 +465,8 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
error:
session || weekly
? null
: withMacTailscaleDnsHint('CLI exited before status was available', clean),
: (extractCodexAuthError(clean) ??
withMacTailscaleDnsHint('CLI exited before status was available', clean)),
status: session || weekly ? 'ok' : 'error'
})
}
@@ -488,6 +490,9 @@ export async function fetchCodexRateLimits(
if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') {
return rpcResult
}
if (isCodexAuthError(rpcResult.error)) {
return rpcResult
}
if (options?.allowPtyFallback === false) {
return rpcResult
}
+6 -4
View File
@@ -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)
+54
View File
@@ -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<string, unknown>
leavesByPtyId: Map<string, { preview?: string; lastOutputAt?: number | null }[]>
}
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<string, unknown>
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([
{
+38 -30
View File
@@ -1054,6 +1054,9 @@ export class OrcaRuntimeService {
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>()
private leaves = new Map<string, RuntimeLeafRecord>()
// 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<string, RuntimeLeafRecord[]>()
private handles = new Map<string, TerminalHandleRecord>()
private handleByLeafKey = new Map<string, string>()
private handleByPtyId = new Map<string, string>()
@@ -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<string, RuntimeLeafRecord[]>()
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(
+280 -6
View File
@@ -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 })
})
})
+171 -4
View File
@@ -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<typeof setTimeout>
}
const clipboardImageUploads = new Map<string, ClipboardImageUpload>()
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<typeof setTimeout> {
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)
}
}
+16 -1
View File
@@ -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')
})
})
+7
View File
@@ -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 `<userData>/`:
* `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, 'error'> = console): void {
logger.error(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE)
}
@@ -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<typeof vi.fn>
listeners: Map<string, (value: unknown) => void>
}[] = []
spawnMock.mockImplementation(() => {
const listeners = new Map<string, (value: unknown) => 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<string, (value: unknown) => void>()
spawnMock.mockReturnValue({
@@ -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<RemoteCommitMessageExecResult>
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<string, () => 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<InternalTextGenerationResult> {
const { binary, args, stdinPayload, label } = plan
return new Promise((resolve) => {
@@ -621,12 +622,13 @@ function finalizeFromAgentOutput(args: {
async function runRemotePlan(
plan: CommitMessagePlan,
target: Extract<CommitMessageGenerationTarget, { kind: 'remote' }>,
emptyResultName = 'message'
emptyResultName = 'message',
operation: TextGenerationOperation = 'commit-message'
): Promise<InternalTextGenerationResult> {
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
+9 -18
View File
@@ -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 {
+2 -9
View File
@@ -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<void>
writeSelectionClipboardText: (text: string) => Promise<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
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
+26 -59
View File
@@ -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 }
: {})
})
+66
View File
@@ -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()
+24 -10
View File
@@ -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<string, { child: ChildProcess; markCanceled: () => 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<string, InFlightExec>()
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(() => {
+31
View File
@@ -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')
+31 -5
View File
@@ -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
)
}
}
}
@@ -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<void> => {
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)
}
}
}
@@ -3112,6 +3112,14 @@ function CommentReplyForm({
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>(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])
@@ -5440,6 +5468,12 @@ export default function GitHubItemDialog({
const files = details?.files ?? []
const checks = details?.checks ?? []
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => 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)
const setLinkCopyButtonRef = useCallback((node: HTMLButtonElement | null) => {
linkCopyMountedRef.current = node !== null
}, [])
useEffect(() => {
setLinkCopied(false)
@@ -5461,6 +5495,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 {
@@ -5580,6 +5617,7 @@ export default function GitHubItemDialog({
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={setLinkCopyButtonRef}
type="button"
variant="ghost"
size="icon-sm"
@@ -5722,6 +5760,7 @@ export default function GitHubItemDialog({
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={setLinkCopyButtonRef}
type="button"
variant="ghost"
size="icon-sm"
@@ -953,6 +953,13 @@ export function LinearIssueCommentFooter({
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const mountedRef = useRef(true)
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(() => {
const el = textareaRef.current
@@ -972,6 +979,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 +993,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])
@@ -1001,7 +1015,10 @@ export function LinearIssueCommentFooter({
if (variant === 'linear-page') {
return (
<div className="rounded-xl border border-border/70 bg-background shadow-xs">
<div
ref={handleFooterRef}
className="rounded-xl border border-border/70 bg-background shadow-xs"
>
<textarea
ref={textareaRef}
value={body}
@@ -1036,7 +1053,10 @@ export function LinearIssueCommentFooter({
}
return (
<div className="flex items-end gap-2 border-t border-border/60 bg-background/40 px-4 py-3">
<div
ref={handleFooterRef}
className="flex items-end gap-2 border-t border-border/60 bg-background/40 px-4 py-3"
>
<textarea
ref={textareaRef}
value={body}
@@ -3241,6 +3241,14 @@ function CommentReplyForm({
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>(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])
@@ -5325,6 +5353,12 @@ export default function PullRequestPage({
const files = details?.files ?? []
const checks = details?.checks ?? []
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => 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)
const setLinkCopyButtonRef = useCallback((node: HTMLButtonElement | null) => {
linkCopyMountedRef.current = node !== null
}, [])
useEffect(() => {
setLinkCopied(false)
@@ -5346,6 +5380,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 {
@@ -5471,6 +5508,7 @@ export default function PullRequestPage({
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={setLinkCopyButtonRef}
type="button"
variant="ghost"
size="icon-sm"
@@ -67,6 +67,9 @@ function InstallRgGuidance({
}): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(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) {
@@ -77,6 +80,7 @@ function InstallRgGuidance({
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
@@ -95,6 +99,9 @@ function InstallRgGuidance({
void window.api.ui
.writeClipboardText(command)
.then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
+18 -4
View File
@@ -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 (
+15 -2
View File
@@ -3170,6 +3170,14 @@ export default function TaskPage(): React.JSX.Element {
'idle'
)
const [linearConnectError, setLinearConnectError] = useState<string | null>(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])
@@ -71,17 +71,26 @@ export default function BrowserAddressBar({
const blurCloseTimerRef = useRef<number | null>(null)
const closingResetTimerRef = useRef<number | null>(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({
>
<PopoverTrigger asChild>
<form
ref={setAddressBarFormRef}
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
@@ -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<string | null | undefined>(
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)
}
}
}
@@ -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<CrashReportRecord | null>(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<void> => {
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<void> => {
await dismissReportIfNeeded()
setOpen(false)
if (mountedRef.current) {
setOpen(false)
}
}
const handleSubmit = async (): Promise<void> => {
@@ -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)
@@ -11,6 +11,9 @@ export default function CodeBlockCopyButton({
}: CodeBlockCopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(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) {
@@ -21,6 +24,7 @@ export default function CodeBlockCopyButton({
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
@@ -45,6 +49,9 @@ export default function CodeBlockCopyButton({
void window.api.ui
.writeClipboardText(text)
.then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
@@ -181,6 +181,10 @@ 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)
const [fileTreeCollapsed, setFileTreeCollapsedState] = useState(() =>
getInitialCombinedDiffFileTreeCollapsed(settings?.combinedDiffFileTreeVisibleByDefault)
)
@@ -196,6 +200,17 @@ export default function CombinedDiffViewer({
const sectionsRef = useRef<DiffSection[]>([])
const generationRef = useRef(0)
const loadSectionRef = useRef<(index: number) => Promise<void>>(async () => {})
const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => {
scrollContainerRef.current = node
notesCopyMountedRef.current = node !== null
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const loadSchedulerRef = useRef(
createCombinedDiffLoadScheduler({
loadSection: (index) => loadSectionRef.current(index)
@@ -882,6 +897,9 @@ export default function CombinedDiffViewer({
}
try {
await window.api.ui.writeClipboardText(diffCommentsPrompt)
if (!notesCopyMountedRef.current) {
return
}
setNotesCopied(true)
} catch {
// Why: clipboard writes can fail while the app is not focused; this
@@ -896,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])
@@ -1130,7 +1153,10 @@ export default function CombinedDiffViewer({
onCollapsedChange={setFileTreeCollapsed}
onNavigate={handleTreeNavigate}
/>
<div ref={scrollContainerRef} className="min-w-0 flex-1 overflow-auto scrollbar-editor">
<div
ref={setScrollContainerRef}
className="min-w-0 flex-1 overflow-auto scrollbar-editor"
>
{skippedConflictNotice}
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
@@ -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(() => {
@@ -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 {
@@ -50,6 +50,13 @@ function EditorPanelInner({
const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>(
null
)
// 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)
@@ -89,6 +96,7 @@ function EditorPanelInner({
useEffect(() => acquireExportPdfListener(), [])
useClosedEditorTabCleanup(openFiles)
useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview })
useEffect(() => {
if (!copiedPathToast) {
return
@@ -181,8 +189,14 @@ function EditorPanelInner({
}
try {
await window.api.ui.writeClipboardText(copyState.copyText)
if (!pathCopyMountedRef.current) {
return
}
setCopiedPathToast({ fileId: activeFile.id, token: Date.now() })
} catch {
if (!pathCopyMountedRef.current) {
return
}
setCopiedPathToast(null)
}
}, [activeFile])
@@ -278,7 +292,7 @@ function EditorPanelInner({
return (
<EditorPanelShell
panelRef={panelRef}
panelRef={setPanelRef}
activeFile={activeFile}
activeViewStateId={activeViewStateId}
model={model}
@@ -1,4 +1,4 @@
import { Suspense, type JSX, type RefObject } from 'react'
import { Suspense, type JSX, type Ref } from 'react'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
@@ -13,7 +13,7 @@ import { getUntitledFileRoot } from './untitled-file-rename-path'
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
type EditorPanelShellProps = {
panelRef: RefObject<HTMLDivElement | null>
panelRef: Ref<HTMLDivElement>
activeFile: OpenFile
activeViewStateId: string | null | undefined
model: EditorPanelRenderModel
@@ -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()
})
}, [])
@@ -527,6 +527,9 @@ export default function MarkdownPreview({
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
// Why: clipboard IPC can resolve after the preview unmounts; skip copied
// feedback instead of starting a reset timer on a stale preview.
const reviewNotesCopyMountedRef = useRef(false)
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
@@ -662,6 +665,17 @@ export default function MarkdownPreview({
}
}, [])
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
const body = bodyRef.current
@@ -797,6 +811,9 @@ export default function MarkdownPreview({
}
try {
await window.api.ui.writeClipboardText(markdownReviewPrompt)
if (!reviewNotesCopyMountedRef.current) {
return
}
clearReviewNotesCopiedResetTimer()
setReviewNotesCopied(true)
reviewNotesCopiedResetTimerRef.current = window.setTimeout(() => {
@@ -1491,7 +1508,7 @@ export default function MarkdownPreview({
return (
<div className="markdown-preview-shell">
<div
ref={rootRef}
ref={setRootRef}
tabIndex={0}
style={{ fontSize: `${editorFontSize}px` }}
className={`markdown-preview h-full min-h-0 overflow-auto scrollbar-editor ${isDark ? 'markdown-dark' : 'markdown-light'}`}
@@ -1696,6 +1713,14 @@ function MarkdownAnnotationComposer({
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
textareaRef.current?.focus()
@@ -1710,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)
}
}
}
@@ -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
@@ -45,6 +45,9 @@ export function RichMarkdownCodeBlock({
const language = (node.attrs.language as string) || ''
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(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' ||
@@ -61,6 +64,7 @@ export function RichMarkdownCodeBlock({
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
@@ -82,6 +86,9 @@ export function RichMarkdownCodeBlock({
void window.api.ui
.writeClipboardText(text)
.then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
@@ -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
@@ -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<number | null>(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 (
<>
<span
ref={setTriggerRef}
className={className}
style={style}
data-floating-terminal-toggle
@@ -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<void> => {
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)
}
}
}
@@ -141,6 +141,7 @@ export function FloatingTerminalPanel({
const panelRef = useRef<HTMLDivElement>(null)
const shortcutFocusFrameRef = useRef<number | null>(null)
const shortcutFocusTimeoutRef = useRef<number | null>(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
@@ -356,15 +364,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(() => {
@@ -394,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)
}
}
}, [])
@@ -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<string | null>(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')
}
}
}
@@ -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(() => {
@@ -369,6 +369,8 @@ describe('FileExplorerRow collapse folder action', () => {
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject: vi.fn(),
canAddFolderAsProject: () => false,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree,
onFindInFolder: vi.fn(),
@@ -418,6 +420,8 @@ describe('FileExplorerRow collapse folder action', () => {
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject: vi.fn(),
canAddFolderAsProject: () => false,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree: vi.fn(),
onFindInFolder,
@@ -1,4 +1,4 @@
/* eslint-disable max-lines -- File Explorer coordinates tree state, selection, drag/drop, and toolbar actions in one component. */
/* eslint-disable max-lines -- Why: FileExplorer coordinates tree data, selection, drag/drop, and virtual rows; splitting it during this merge would obscure the interaction invariants. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
@@ -28,9 +28,13 @@ import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh'
import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
import {
buildAddProjectFromFolderModalData,
canShowAddAsProjectAction
} from './file-explorer-add-project-action'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerSelection } from './useFileExplorerSelection'
import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows'
import type { TreeNode } from './file-explorer-types'
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -49,6 +53,7 @@ function FileExplorerInner(): React.JSX.Element {
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const closeFile = useAppStore((s) => s.closeFile)
const openModal = useAppStore((s) => s.openModal)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const worktreePath = activeWorktree?.path ?? null
@@ -376,6 +381,19 @@ function FileExplorerInner(): React.JSX.Element {
[activeWorktreeId, seedFileSearchIncludePattern, setRightSidebarTab, setRightSidebarOpen]
)
const handleAddFolderAsProject = useCallback(
(node: TreeNode) => {
if (!activeRepo || !canShowAddAsProjectAction(node, activeRepo)) {
return
}
openModal(
'confirm-add-project-from-folder',
buildAddProjectFromFolderModalData(node, activeRepo)
)
},
[activeRepo, openModal]
)
if (!worktreePath) {
return (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground px-4 text-center">
@@ -487,6 +505,8 @@ function FileExplorerInner(): React.JSX.Element {
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onAddFolderAsProject={handleAddFolderAsProject}
canAddFolderAsProject={(node) => canShowAddAsProjectAction(node, activeRepo)}
onRequestDelete={handleContextMenuDelete}
onCollapseFolderSubtree={handleCollapseFolderSubtree}
onFindInFolder={handleFindInFolder}
@@ -1,4 +1,4 @@
/* eslint-disable max-lines -- File Explorer rows own dense context-menu and drag/drop interactions. */
/* eslint-disable max-lines -- Why: the row owns dense file-tree rendering plus its context menu, drag target, and inline-input sibling contract. */
import React, { useCallback, useEffect, useRef } from 'react'
import { basename } from '@/lib/path'
import {
@@ -252,6 +252,8 @@ type FileExplorerRowProps = {
onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onAddFolderAsProject: () => void
canAddAsProject: boolean
onRequestDelete: () => void
onCollapseFolderSubtree: () => void
onFindInFolder: () => void
@@ -292,6 +294,8 @@ export function FileExplorerRow({
onStartNew,
onStartRename,
onDuplicate,
onAddFolderAsProject,
canAddAsProject,
onRequestDelete,
onCollapseFolderSubtree,
onFindInFolder,
@@ -507,6 +511,12 @@ export function FileExplorerRow({
Duplicate
</ContextMenuItem>
)}
{canAddAsProject && (
<ContextMenuItem onSelect={onAddFolderAsProject}>
<FolderPlus />
Add as Project...
</ContextMenuItem>
)}
{!node.isDirectory && activeWorktreeId && (
<ContextMenuItem onSelect={handleOpenInOrcaBrowser}>
<Globe />
@@ -31,6 +31,8 @@ type FileExplorerVirtualRowsProps = {
onStartNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onAddFolderAsProject: (node: TreeNode) => void
canAddFolderAsProject: (node: TreeNode) => boolean
onRequestDelete: (node: TreeNode) => void
onCollapseFolderSubtree: (node: TreeNode) => void
onFindInFolder: (node: TreeNode) => void
@@ -69,6 +71,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartNew,
onStartRename,
onDuplicate,
onAddFolderAsProject,
canAddFolderAsProject,
onRequestDelete,
onCollapseFolderSubtree,
onFindInFolder,
@@ -169,6 +173,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
onStartNew={onStartNew}
onStartRename={onStartRename}
onDuplicate={onDuplicate}
onAddFolderAsProject={() => onAddFolderAsProject(n)}
canAddAsProject={canAddFolderAsProject(n)}
onRequestDelete={() => onRequestDelete(n)}
onCollapseFolderSubtree={() => onCollapseFolderSubtree(n)}
onFindInFolder={() => onFindInFolder(n)}
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import { FileExplorerRow } from './FileExplorerRow'
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
import type { TreeNode } from './file-explorer-types'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findFileExplorerRow(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.type === FileExplorerRow) {
found = entry
}
})
if (!found) {
throw new Error('file explorer row not found')
}
return found
}
const directoryNode: TreeNode = {
name: 'src',
path: '/repo/src',
relativePath: 'src',
isDirectory: true,
depth: 0
}
describe('FileExplorerVirtualRows add-as-project action', () => {
it('passes visibility and the row node to the add-as-project handler', () => {
const onAddFolderAsProject = vi.fn()
const element = FileExplorerVirtualRows({
virtualizer: {
getTotalSize: () => 26,
getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }],
measureElement: vi.fn()
} as never,
inlineInputIndex: -1,
flatRows: [directoryNode],
inlineInput: null,
handleInlineSubmit: vi.fn(),
dismissInlineInput: vi.fn(),
folderStatusByRelativePath: new Map(),
statusByRelativePath: new Map(),
ignoredByRelativePath: new Set(),
expanded: new Set([directoryNode.path]),
dirCache: {},
selectedPaths: new Set(),
activeFileId: null,
flashingPath: null,
deleteShortcutLabel: 'Del',
onClick: vi.fn(),
onDoubleClick: vi.fn(),
onContextMenuSelect: vi.fn(),
onCopyPaths: vi.fn(),
onStartNew: vi.fn(),
onStartRename: vi.fn(),
onDuplicate: vi.fn(),
onAddFolderAsProject,
canAddFolderAsProject: (node) => node.path === directoryNode.path,
onRequestDelete: vi.fn(),
onCollapseFolderSubtree: vi.fn(),
onFindInFolder: vi.fn(),
onMoveDrop: vi.fn(),
onDragTargetChange: vi.fn(),
onDragSourceChange: vi.fn(),
onDragExpandDir: vi.fn(),
onNativeDragTargetChange: vi.fn(),
onNativeDragExpandDir: vi.fn(),
dropTargetDir: null,
dragSourcePath: null,
nativeDropTargetDir: null
})
const row = findFileExplorerRow(element)
expect(row.props.canAddAsProject).toBe(true)
;(row.props.onAddFolderAsProject as () => void)()
expect(onAddFolderAsProject).toHaveBeenCalledWith(directoryNode)
})
})
@@ -1030,6 +1030,13 @@ function SourceControlInner(): React.JSX.Element {
const [pendingDiffCommentsClear, setPendingDiffCommentsClear] =
useState<PendingDiffCommentsClear | null>(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)
const setSourceControlRootRef = useCallback((node: HTMLDivElement | null) => {
sourceControlRef.current = node
diffCommentsCopyMountedRef.current = node !== null
}, [])
useEffect(() => {
return () => cancelSourceControlEditorRevealFrames(pendingCommentEditorRevealFrameIdsRef)
@@ -1041,6 +1048,9 @@ function SourceControlInner(): React.JSX.Element {
}
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.
@@ -3926,7 +3936,7 @@ function SourceControlInner(): React.JSX.Element {
return (
<>
<div ref={sourceControlRef} className="relative flex h-full flex-col overflow-hidden">
<div ref={setSourceControlRootRef} className="relative flex h-full flex-col overflow-hidden">
<div className="flex items-center px-3 pt-2 border-b border-border">
{(['all', 'uncommitted'] as const).map((value) => (
<button
@@ -5843,6 +5853,12 @@ function DiffCommentsInlineList({
}, [comments])
const [copiedId, setCopiedId] = useState<string | null>(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)
const setInlineDiffCommentsListRef = useCallback((node: HTMLDivElement | null) => {
copiedIdMountedRef.current = node !== null
}, [])
// 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 +5874,9 @@ function DiffCommentsInlineList({
const handleCopyOne = useCallback(async (c: DiffComment): Promise<void> => {
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.
@@ -5873,7 +5892,7 @@ function DiffCommentsInlineList({
}
return (
<div className="bg-muted/20">
<div ref={setInlineDiffCommentsListRef} className="bg-muted/20">
{groups.map(([filePath, list]) => (
<div key={filePath} className="px-3 py-1.5">
<div className="group/file flex items-center gap-1">
@@ -357,6 +357,9 @@ export function ChecksList({
function CopyButton({ text }: { text: string }): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(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) {
@@ -367,6 +370,7 @@ function CopyButton({ text }: { text: string }): React.JSX.Element {
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
@@ -378,6 +382,9 @@ function CopyButton({ text }: { text: string }): React.JSX.Element {
(e: React.MouseEvent) => {
e.stopPropagation()
void window.api.ui.writeClipboardText(text).then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import type { Repo } from '../../../../shared/types'
import type { TreeNode } from './file-explorer-types'
import {
buildAddProjectFromFolderModalData,
canShowAddAsProjectAction
} from './file-explorer-add-project-action'
const folderNode: TreeNode = {
name: 'child-project',
path: '/projects/child-project',
relativePath: 'child-project',
isDirectory: true,
depth: 0
}
const fileNode: TreeNode = {
...folderNode,
name: 'README.md',
path: '/projects/README.md',
relativePath: 'README.md',
isDirectory: false
}
const folderRepo: Repo = {
id: 'folder-repo',
path: '/projects',
displayName: 'projects',
badgeColor: '#000',
addedAt: 1,
kind: 'folder'
}
const gitRepo: Repo = {
...folderRepo,
id: 'git-repo',
kind: 'git'
}
describe('file explorer add project action', () => {
it('shows only for directories in folder-mode projects', () => {
expect(canShowAddAsProjectAction(folderNode, folderRepo)).toBe(true)
expect(canShowAddAsProjectAction(fileNode, folderRepo)).toBe(false)
expect(canShowAddAsProjectAction(folderNode, gitRepo)).toBe(false)
expect(canShowAddAsProjectAction(folderNode, null)).toBe(false)
})
it('preserves the selected folder path and active SSH connection', () => {
expect(
buildAddProjectFromFolderModalData(folderNode, {
...folderRepo,
connectionId: 'ssh-target-1'
})
).toEqual({
folderPath: '/projects/child-project',
connectionId: 'ssh-target-1'
})
})
})
@@ -0,0 +1,22 @@
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
import type { TreeNode } from './file-explorer-types'
export type AddProjectFromFolderModalData = {
folderPath: string
connectionId?: string
}
export function canShowAddAsProjectAction(node: TreeNode, activeRepo: Repo | null): boolean {
return node.isDirectory && Boolean(activeRepo && isFolderRepo(activeRepo))
}
export function buildAddProjectFromFolderModalData(
node: TreeNode,
activeRepo: Repo
): AddProjectFromFolderModalData {
return {
folderPath: node.path,
...(activeRepo.connectionId ? { connectionId: activeRepo.connectionId } : {})
}
}
@@ -15,7 +15,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { useAppStore } from '../../store'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons'
import { toast } from 'sonner'
@@ -39,6 +39,7 @@ import {
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { getCodexAccountAuthWarning } from './codex-account-auth-warning'
export { ACCOUNTS_PANE_SEARCH_ENTRIES }
@@ -234,6 +235,8 @@ export function AccountsPane({
wslCapabilitiesLoading = false
}: AccountsPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => 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<Set<'cookie' | 'workspaceId'>>(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<void> => {
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 ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{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.`}
</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label>Accounts</Label>
@@ -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`}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -762,9 +791,23 @@ export function AccountsPane({
Active
</Badge>
) : null}
{systemCodexNeedsReauthentication ? (
<Badge
variant="destructive"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none"
>
Needs sign-in
</Badge>
) : null}
</div>
<span className="truncate text-[11px] text-muted-foreground">
Use your current {accountRuntime.label} Codex login.
<span
className={`truncate text-[11px] ${
systemCodexNeedsReauthentication ? 'text-destructive' : 'text-muted-foreground'
}`}
>
{systemCodexNeedsReauthentication
? `Codex reported this ${accountRuntime.label} login is out of date.`
: `Use your current ${accountRuntime.label} Codex login.`}
</span>
</div>
</button>
@@ -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({
<div
key={account.id}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
needsReauthentication
? 'border-destructive/50 bg-destructive/5'
: isActive
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
>
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
@@ -820,12 +873,28 @@ export function AccountsPane({
Active
</Badge>
) : null}
{needsReauthentication ? (
<Badge
variant="destructive"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none"
>
Needs re-auth
</Badge>
) : null}
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground max-sm:flex-wrap">
{account.workspaceLabel ? (
<div
className={`flex min-w-0 items-center gap-1.5 text-[11px] max-sm:flex-wrap ${
needsReauthentication ? 'text-destructive' : 'text-muted-foreground'
}`}
>
{needsReauthentication ? (
<span className="truncate">
Codex reported this sign-in is out of date
</span>
) : account.workspaceLabel ? (
<span className="truncate">{account.workspaceLabel}</span>
) : null}
{account.workspaceLabel ? (
{needsReauthentication || account.workspaceLabel ? (
<span className="shrink-0 opacity-50">•</span>
) : null}
<span className="shrink-0">
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: Browser Use setup keeps enablement, CLI registration, skill install, cookie import, examples, and interaction tracking in one pane so the three-step setup state stays coherent. */
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Import, Loader2, MousePointerClick } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
@@ -58,6 +58,20 @@ export function BrowserUseSetup({
const [cliStatus, setCliStatus] = useState<CliInstallStatus | null>(null)
const [cliLoading, setCliLoading] = useState(true)
const [cliBusy, setCliBusy] = useState(false)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const handleCliStatusChange = useCallback((nextStatus: CliInstallStatus): void => {
if (mountedRef.current) {
setCliStatus(nextStatus)
}
}, [])
// Why: the toggle gates only whether we show the setup instructions. We
// persist it in localStorage instead of global settings because it has no
@@ -75,16 +89,20 @@ export function BrowserUseSetup({
}
}
const refreshCli = async (): Promise<void> => {
const refreshCli = useCallback(async (): Promise<void> => {
setCliLoading(true)
try {
setCliStatus(await window.api.cli.getInstallStatus())
handleCliStatusChange(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)
}
}
}
}, [handleCliStatusChange])
useEffect(() => {
// Why: skip IPC work when the feature is toggled off — the component
@@ -94,7 +112,7 @@ export function BrowserUseSetup({
}
void refreshCli()
void fetchBrowserSessionProfiles()
}, [browserUseEnabled, fetchBrowserSessionProfiles])
}, [browserUseEnabled, fetchBrowserSessionProfiles, refreshCli])
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
// Why: this step explicitly imports into the default profile, so completion
@@ -121,13 +139,15 @@ export function BrowserUseSetup({
setCliBusy(true)
try {
const next = await ensureOrcaCliAvailableForAgentSkillTerminal({
onStatusChange: setCliStatus
onStatusChange: handleCliStatusChange
})
if (isOrcaCliAvailableOnPath(next)) {
if (mountedRef.current && isOrcaCliAvailableOnPath(next)) {
toast.success('Registered the Orca CLI in PATH.')
}
} finally {
setCliBusy(false)
if (mountedRef.current) {
setCliBusy(false)
}
}
}
@@ -328,7 +348,9 @@ export function BrowserUseSetup({
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setCliStatus })
await ensureOrcaCliAvailableForAgentSkillTerminal({
onStatusChange: handleCliStatusChange
})
}}
onRecheck={refreshSkill}
/>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
@@ -64,6 +64,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
const mountedRef = useRef(true)
const {
installed: cliSkillDetected,
loading: cliSkillLoading,
@@ -73,20 +74,37 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const refreshStatus = async (): Promise<void> => {
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const handleStatusChange = useCallback((nextStatus: CliInstallStatus): void => {
if (mountedRef.current) {
setStatus(nextStatus)
}
}, [])
const refreshStatus = useCallback(async (): Promise<void> => {
setLoading(true)
try {
setStatus(await window.api.cli.getInstallStatus())
handleStatusChange(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 {
setLoading(false)
if (mountedRef.current) {
setLoading(false)
}
}
}
}, [handleStatusChange])
useEffect(() => {
void refreshStatus()
}, [])
}, [refreshStatus])
const isEnabled = status?.state === 'installed'
const isSupported = status?.supported ?? false
@@ -100,15 +118,21 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
setBusyAction('install')
try {
const next = await window.api.cli.install()
setStatus(next)
setDialogOpen(false)
toast.success(`Registered \`${next.commandName}\` in PATH.`)
if (mountedRef.current) {
setStatus(next)
setDialogOpen(false)
toast.success(`Registered \`${next.commandName}\` in PATH.`)
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : `Failed to register \`${commandName}\` in PATH.`
)
if (mountedRef.current) {
toast.error(
error instanceof Error ? error.message : `Failed to register \`${commandName}\` in PATH.`
)
}
} finally {
setBusyAction(null)
if (mountedRef.current) {
setBusyAction(null)
}
}
}
@@ -116,15 +140,21 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
setBusyAction('remove')
try {
const next = await window.api.cli.remove()
setStatus(next)
setDialogOpen(false)
toast.success(`Removed \`${next.commandName}\` from PATH.`)
if (mountedRef.current) {
setStatus(next)
setDialogOpen(false)
toast.success(`Removed \`${next.commandName}\` from PATH.`)
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from PATH.`
)
if (mountedRef.current) {
toast.error(
error instanceof Error ? error.message : `Failed to remove \`${commandName}\` from PATH.`
)
}
} finally {
setBusyAction(null)
if (mountedRef.current) {
setBusyAction(null)
}
}
}
@@ -248,7 +278,9 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
error={cliSkillError}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
onBeforeOpenTerminal={async () => {
await ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: setStatus })
await ensureOrcaCliAvailableForAgentSkillTerminal({
onStatusChange: handleStatusChange
})
}}
onRecheck={refreshCliSkill}
/>
@@ -81,6 +81,7 @@ export function ComputerUsePane(): React.JSX.Element {
// Why: reset changes OS permission state, so older status probes must not overwrite it.
const resettingRef = useRef(false)
const permissionOperationSequence = useRef(0)
const mountedRef = useRef(true)
const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(null)
const {
installed: computerUseSkillDetected,
@@ -120,6 +121,14 @@ export function ComputerUsePane(): React.JSX.Element {
PERMISSIONS.length - grantedCount === 1 ? '' : 's'
} required before agents can operate app windows.`
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
permissionOperationSequence.current += 1
}
}, [])
const refresh = useCallback(async (): Promise<void> => {
if (resettingRef.current) {
return
@@ -132,18 +141,21 @@ export function ComputerUsePane(): React.JSX.Element {
if (operationId !== permissionOperationSequence.current) {
return
}
if (!mountedRef.current) {
return
}
setPlatform(result.platform)
setStates(result.permissions)
setHelperUnavailableReason(result.helperUnavailableReason)
} catch (error) {
if (operationId !== permissionOperationSequence.current) {
if (operationId !== permissionOperationSequence.current || !mountedRef.current) {
return
}
toast.error(
error instanceof Error ? error.message : 'Could not load Computer Use permissions'
)
} finally {
if (operationId === permissionOperationSequence.current) {
if (operationId === permissionOperationSequence.current && mountedRef.current) {
setLoading(false)
}
}
@@ -168,6 +180,9 @@ export function ComputerUsePane(): React.JSX.Element {
setPendingId(id)
try {
const result = await window.api.computerUsePermissions.openSetup({ id })
if (!mountedRef.current) {
return
}
if (result.launchedHelper) {
toast.message('Opened macOS Privacy & Security')
} else {
@@ -178,11 +193,15 @@ export function ComputerUsePane(): React.JSX.Element {
)
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Could not open Computer Use permissions'
)
if (mountedRef.current) {
toast.error(
error instanceof Error ? error.message : 'Could not open Computer Use permissions'
)
}
} finally {
setPendingId(null)
if (mountedRef.current) {
setPendingId(null)
}
}
}
@@ -199,19 +218,22 @@ export function ComputerUsePane(): React.JSX.Element {
if (operationId !== permissionOperationSequence.current) {
return
}
if (!mountedRef.current) {
return
}
setPlatform(result.platform)
setStates(result.permissions)
setHelperUnavailableReason(result.helperUnavailableReason)
toast.message('Reset Computer Use access')
} catch (error) {
if (operationId !== permissionOperationSequence.current) {
if (operationId !== permissionOperationSequence.current || !mountedRef.current) {
return
}
toast.error(
error instanceof Error ? error.message : 'Could not reset Computer Use permissions'
)
} finally {
if (operationId === permissionOperationSequence.current) {
if (operationId === permissionOperationSequence.current && mountedRef.current) {
resettingRef.current = false
setResetting(false)
setLoading(false)
@@ -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(() => {
@@ -53,6 +53,9 @@ export function MobilePane(): React.JSX.Element {
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
const codeCopiedResetTimerRef = useRef<number | null>(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) {
@@ -63,6 +66,7 @@ export function MobilePane(): React.JSX.Element {
const setPairingCodeButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCodeCopiedResetTimer()
}
@@ -158,6 +162,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(() => {
@@ -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)
}
}
}
@@ -56,6 +56,9 @@ export function RepositoryPane({
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [copiedTemplate, setCopiedTemplate] = useState(false)
const copiedTemplateResetTimerRef = useRef<number | null>(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)
@@ -69,6 +72,7 @@ export function RepositoryPane({
const setRepositoryPaneRootRef = useCallback(
(node: HTMLDivElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedTemplateResetTimer()
}
@@ -100,6 +104,9 @@ export function RepositoryPane({
pnpm worktree:setup
archive: |
echo "Cleaning up before archive"`)
if (!isMountedRef.current) {
return
}
clearCopiedTemplateResetTimer()
setCopiedTemplate(true)
copiedTemplateResetTimerRef.current = window.setTimeout(() => {
@@ -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 {
</div>
) : (
<ActiveSettingsSectionProvider value={activeSectionId}>
<SettingsSection
id="general"
title="General"
description="Workspace defaults, app setup, and maintenance."
searchEntries={getSectionSearchEntries('general')}
>
{isSectionMounted('general') ? (
<GeneralPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="agents"
title="Agents"
@@ -800,6 +789,75 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="orchestration"
title="Orchestration"
description="Coordinate multiple coding agents through Orca."
searchEntries={getSectionSearchEntries('orchestration')}
>
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<>
<SettingsSection
id="computer-use"
title="Computer Use"
badge="Beta"
badgeAccessory={
showComputerUsePreviewTooltip ? (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`${computerUsePlatform} Computer Use preview details`}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
<span>
{computerUsePlatform} Computer Use is an early preview. Some apps
and desktop environments may behave inconsistently.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null
}
description="Enable agents to control any app on your computer."
searchEntries={getSectionSearchEntries('computer-use')}
>
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
</SettingsSection>
<SettingsSection
id="voice"
title="Voice"
badge="Beta"
description="Local speech-to-text dictation with on-device models."
searchEntries={getSectionSearchEntries('voice')}
>
{isSectionMounted('voice') ? (
<VoicePane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
</>
) : null}
<SettingsSection
id="general"
title="General"
description="Workspace defaults, app setup, and maintenance."
searchEntries={getSectionSearchEntries('general')}
>
{isSectionMounted('general') ? (
<GeneralPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="integrations"
title="Integrations"
@@ -987,64 +1045,6 @@ function Settings(): React.JSX.Element {
{isSectionMounted('stats') ? <StatsPane /> : null}
</SettingsSection>
<SettingsSection
id="orchestration"
title="Orchestration"
description="Coordinate multiple coding agents through Orca."
searchEntries={getSectionSearchEntries('orchestration')}
>
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<>
<SettingsSection
id="computer-use"
title="Computer Use"
badge="Beta"
badgeAccessory={
showComputerUsePreviewTooltip ? (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`${computerUsePlatform} Computer Use preview details`}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
<span>
{computerUsePlatform} Computer Use is an early preview. Some apps
and desktop environments may behave inconsistently.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null
}
description="Enable agents to control any app on your computer."
searchEntries={getSectionSearchEntries('computer-use')}
>
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
</SettingsSection>
<SettingsSection
id="voice"
title="Voice"
badge="Beta"
description="Local speech-to-text dictation with on-device models."
searchEntries={getSectionSearchEntries('voice')}
>
{isSectionMounted('voice') ? (
<VoicePane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
</>
) : null}
<SettingsSection
id="servers"
title="Remote Orca Servers"
@@ -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<string | null>(null)
const [form, setForm] = useState<EditingTarget>(EMPTY_FORM)
const [testingIds, setTestingIds] = useState<Set<string>>(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<void> => {
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')
}
}
}
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
CircleStop,
Loader2,
@@ -85,13 +85,26 @@ export function SshTargetCard({
const terminateInFlight = actionInFlight === 'terminate' || busyAction === 'terminate'
const resetInFlight = actionInFlight === 'reset' || busyAction === 'reset'
const removeInFlight = busyAction === 'remove'
const mountedRef = useRef(true)
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 => {
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 +112,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 +120,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 +128,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 => (
@@ -212,7 +225,10 @@ export function SshTargetCard({
)
return (
<div className="flex items-center gap-3 rounded-lg border border-border/50 bg-card/40 px-4 py-3">
<div
ref={handleCardRef}
className="flex items-center gap-3 rounded-lg border border-border/50 bg-card/40 px-4 py-3"
>
<Server className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
@@ -1,4 +1,4 @@
import { useEffect, 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'
@@ -32,13 +32,28 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
const shortcutLabel = useShortcutLabel('voice.dictation')
const [catalog, setCatalog] = useState<SpeechModelManifest[]>([])
const [permissionPending, setPermissionPending] = useState(false)
const mountedRef = useRef(true)
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(() => {
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 +99,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)
}
}
}
@@ -98,7 +115,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
const selectedIsReady = selectedModelState?.status === 'ready'
return (
<div className="space-y-1">
<div ref={handlePaneRef} className="space-y-1">
<div className="flex items-center justify-between gap-4 py-2">
<div className="space-y-0.5">
<Label>Enable Voice Dictation</Label>
@@ -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<void> => {
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)
}
}
}
@@ -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']
}
]
@@ -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)
})
})
@@ -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.'
}
@@ -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<PendingConfirm>(null)
const [busyKind, setBusyKind] = useState<DaemonActionKind | null>(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') {
@@ -0,0 +1,234 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
type ButtonCapture = {
label: string
onClick?: () => unknown
disabled?: boolean
}
const mocks = vi.hoisted(() => ({
buttons: [] as ButtonCapture[],
state: {
activeModal: 'confirm-add-project-from-folder',
modalData: {} as Record<string, unknown>,
closeModal: vi.fn(),
openModal: vi.fn(),
addRepoPath: vi.fn(),
updateRepo: vi.fn(),
fetchWorktrees: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
worktreesByRepo: {} as Record<string, Worktree[]>,
detectedWorktreesByRepo: {},
hideDefaultBranchWorkspace: false,
setHideDefaultBranchWorkspace: vi.fn(),
clearOrcaHookTrustForRepo: vi.fn(),
repos: [] as Repo[]
},
addRemote: vi.fn(),
toastSuccess: vi.fn(),
track: vi.fn()
}))
function textContent(node: ReactModule.ReactNode): string {
if (node == null || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(textContent).join('')
}
if (typeof node === 'object' && 'props' in node) {
return textContent((node as { props?: { children?: ReactModule.ReactNode } }).props?.children)
}
return ''
}
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
{
getState: () => mocks.state,
setState: (next: Partial<typeof mocks.state>) => {
Object.assign(mocks.state, next)
}
}
)
return { useAppStore }
})
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactModule.ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactModule.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactModule.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactModule.ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactModule.ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children: ReactModule.ReactNode }) => <h1>{children}</h1>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled
}: {
children: ReactModule.ReactNode
onClick?: () => unknown
disabled?: boolean
}) => {
mocks.buttons.push({ label: textContent(children), onClick, disabled })
return (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
}))
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 }) => <div>setup:{repoName}</div>,
getProjectAddedPrimaryBranchName: () => 'main'
}))
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/projects/child',
displayName: 'child',
badgeColor: '#999999',
addedAt: 1,
kind: 'git',
...overrides
}
}
async function clickAddProject(): Promise<void> {
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(<AddProjectFromFolderDialog />)
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(<AddProjectFromFolderDialog />)
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(<AddProjectFromFolderDialog />)
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(<AddProjectFromFolderDialog />)
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()
})
})
@@ -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<Repo | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
{addedRepo ? (
<SetupStep
repoName={addedRepo.displayName}
hiddenWorktreeCount={hiddenWorktreeCount}
primaryBranchName={primaryBranchName}
onStartPrimaryWorktree={handleStartPrimaryWorktree}
onUseExistingWorktrees={() => void handleUseExistingWorktrees()}
onCreateWorktree={handleCreateWorktree}
onConfigureRepo={handleConfigureRepo}
/>
) : (
<>
<DialogHeader>
<DialogTitle>Add Project</DialogTitle>
<DialogDescription>Add this folder as a separate Orca project.</DialogDescription>
</DialogHeader>
{folderPath && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-mono text-muted-foreground">{folderPath}</div>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isAdding}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!folderPath || isAdding}>
{isAdding ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FolderPlus className="size-4" />
)}
Add Project
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
})
export default AddProjectFromFolderDialog
@@ -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<string | null>(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') => {
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -24,6 +24,13 @@ export function ProjectGroupDeleteDialog({
}: ProjectGroupDeleteDialogProps): React.JSX.Element {
const [deleting, setDeleting] = useState(false)
const [wasOpen, setWasOpen] = useState(open)
const mountedRef = useRef(true)
const handleDialogContentRef = useCallback((node: HTMLDivElement | null): void => {
// Why: deleting can resolve after the dialog closes; the content ref keeps
// late completions from mutating stale dialog state without an Effect.
mountedRef.current = node !== null
}, [])
// Why: opening the dialog must clear a stale in-flight state before the
// destructive button renders; an Effect would leave one disabled frame.
@@ -41,11 +48,15 @@ export function ProjectGroupDeleteDialog({
setDeleting(true)
try {
await onConfirm()
setDeleting(false)
onOpenChange(false)
if (mountedRef.current) {
setDeleting(false)
onOpenChange(false)
}
} catch (error) {
console.error('Failed to delete project group:', error)
setDeleting(false)
if (mountedRef.current) {
setDeleting(false)
}
}
}, [deleting, onConfirm, onOpenChange])
@@ -59,7 +70,11 @@ export function ProjectGroupDeleteDialog({
onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogContent
ref={handleDialogContentRef}
className="max-w-sm sm:max-w-sm"
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="text-sm">Delete Project Group</DialogTitle>
<DialogDescription className="text-xs">
@@ -35,8 +35,15 @@ export function ProjectGroupNameDialog({
const [name, setName] = useState(initialName)
const [submitting, setSubmitting] = useState(false)
const [previousOpenState, setPreviousOpenState] = useState({ open, initialName })
const mountedRef = useRef(true)
const trimmedName = name.trim()
const handleDialogContentRef = useCallback((node: HTMLDivElement | null): void => {
// Why: save can finish after the dialog closes; the content ref keeps late
// completions from mutating stale dialog state without an Effect.
mountedRef.current = node !== null
}, [])
// Why: the input should mount already seeded and selectable for the active
// group; Effect-based hydration shows one frame with the prior draft.
if (open !== previousOpenState.open || initialName !== previousOpenState.initialName) {
@@ -56,10 +63,14 @@ export function ProjectGroupNameDialog({
setSubmitting(true)
try {
await onSubmit(trimmedName)
onOpenChange(false)
if (mountedRef.current) {
onOpenChange(false)
}
} catch (error) {
console.error('Failed to save project group name:', error)
setSubmitting(false)
if (mountedRef.current) {
setSubmitting(false)
}
}
},
[onOpenChange, onSubmit, submitting, trimmedName]
@@ -68,6 +79,7 @@ export function ProjectGroupNameDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={handleDialogContentRef}
className="max-w-sm sm:max-w-sm"
onOpenAutoFocus={(event) => {
event.preventDefault()
@@ -58,6 +58,9 @@ export function RemoteFileBrowser({
const inputRef = useRef<HTMLInputElement>(null)
const fileHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Why: paste resolution intentionally runs next tick; closing the picker
// before then should cancel stale preview work.
const pasteResolveTimerRef = useRef<ReturnType<typeof setTimeout> | 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
@@ -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])
@@ -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, { useCallback, 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,11 +50,22 @@ export function SshTargetRow({
return
}
setConnecting(true)
void onConnect(target.id).finally(() => setConnecting(false))
void onConnect(target.id).finally(() => {
if (mountedRef.current) {
setConnecting(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 (
<div
ref={handleRowRootRef}
role={isConnected ? 'button' : undefined}
tabIndex={isConnected ? 0 : undefined}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md border text-xs transition-colors ${
@@ -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<string, WorktreeSectionActivitySummary>
// 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<Row, { type: 'item' }>
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 (
<span
className="inline-flex h-4 shrink-0 overflow-hidden rounded-full border border-sidebar-border bg-sidebar-accent text-[9px] font-medium leading-none text-muted-foreground/90"
aria-label={badgeLabel}
>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex h-full min-w-4 items-center justify-center px-1.5">
{count}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{totalTooltipLabel}
</TooltipContent>
</Tooltip>
{showStatus && hasRunning ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex h-full min-w-4 items-center justify-center gap-1 border-l border-sidebar-border/80 bg-amber-500/10 px-1.5 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
<span
className="block size-1.5 rounded-full bg-amber-500 animate-pulse"
aria-hidden="true"
/>
<span>{runningCount}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{runningLabel}
</TooltipContent>
</Tooltip>
) : null}
</span>
)
}
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 (
<div
key={vItem.key}
@@ -2419,13 +2489,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-semibold leading-none">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
<div className="rounded-full bg-black/12 px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground/90">
{row.count}
</div>
<SectionMetricsBadge
count={row.count}
summary={sectionActivity}
showStatus={showSectionStatus}
/>
</div>
</div>
@@ -2433,7 +2505,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<ChevronDown
className={cn(
'size-3.5 cursor-pointer transition-transform [&_path]:cursor-pointer',
collapsedGroups.has(row.key) && '-rotate-90'
isCollapsed && '-rotate-90'
)}
/>
</div>
@@ -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<string, WorktreeSectionActivitySummary>(),
[
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}
/>
@@ -41,10 +41,17 @@ export function WorktreeTitleInlineRename({
}: WorktreeTitleInlineRenameProps): React.JSX.Element {
const editingRef = useRef(false)
const savingRef = useRef(false)
const mountedRef = useRef(true)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(displayName)
const [saving, setSaving] = useState(false)
const handleRootRef = useCallback((node: HTMLSpanElement | null): void => {
// Why: rename can resolve after this inline title unmounts; the rendered
// root owns that stale-write guard without a mount-only Effect.
mountedRef.current = node !== null
}, [])
const setEditingMode = useCallback(
(nextEditing: boolean) => {
if (editingRef.current === nextEditing) {
@@ -104,12 +111,18 @@ export function WorktreeTitleInlineRename({
setSaving(true)
try {
await onRename(commit.displayName)
setEditingMode(false)
if (mountedRef.current) {
setEditingMode(false)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename workspace.')
if (mountedRef.current) {
toast.error(err instanceof Error ? err.message : 'Failed to rename workspace.')
}
} finally {
savingRef.current = false
setSaving(false)
if (mountedRef.current) {
setSaving(false)
}
}
}, [cancelRename, displayName, onRename, setEditingMode, value])
@@ -130,6 +143,7 @@ export function WorktreeTitleInlineRename({
if (editing) {
return (
<span
ref={handleRootRef}
className={cn(
'relative grid min-w-0 truncate leading-tight text-foreground',
showUnreadEmphasis ? 'font-semibold' : 'font-normal',
@@ -173,6 +187,7 @@ export function WorktreeTitleInlineRename({
const title = (
<span
ref={handleRootRef}
className={cn(
'block min-w-0 truncate leading-tight text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring',
showUnreadEmphasis ? 'font-semibold' : 'font-normal',
@@ -11,10 +11,14 @@ 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'
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
@@ -36,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`)
@@ -63,7 +68,9 @@ function Sidebar({
<TooltipProvider delayDuration={400}>
<div
ref={containerRef}
data-native-file-drop-target={sidebarOpen ? nativeDropTarget : undefined}
className="relative min-h-0 flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent"
{...dropHandlers}
>
{sidebarOpen && (
<>
@@ -83,6 +90,23 @@ function Sidebar({
</>
)}
{sidebarOpen && affordance.visible ? (
<div
className={cn(
'pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-1.5 rounded-md border bg-sidebar-accent/95 px-4 text-center text-sidebar-accent-foreground shadow-xs',
affordance.tone === 'blocked' ? 'border-destructive/70' : 'border-sidebar-ring/70'
)}
>
{affordance.tone === 'busy' ? (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
) : (
<FolderPlus className="size-5 text-muted-foreground" />
)}
<div className="text-sm font-medium">{affordance.label}</div>
<div className="text-xs text-muted-foreground">{affordance.description}</div>
</div>
) : null}
{/* Resize handle */}
{sidebarOpen && (
<div
@@ -98,6 +122,7 @@ function Sidebar({
<NonGitFolderDialog />
<RemoveFolderDialog />
<AddRepoDialog />
<AddProjectFromFolderDialog />
<ProjectAddedDialog />
<WorktreeVisibilityDialog />
<OrcaYamlTrustDialog />
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
getSidebarProjectDropAffordance,
isRemoteRuntimeActive,
resolveSidebarProjectDropPath
} from './sidebar-project-drop'
describe('resolveSidebarProjectDropPath', () => {
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' })
})
})
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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'
}
}
@@ -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<HTMLElement>) => void
onDragOver: (event: React.DragEvent<HTMLElement>) => void
onDragLeave: (event: React.DragEvent<HTMLElement>) => void
}
export function useSidebarProjectDrop(): {
nativeDropTarget: typeof NATIVE_FILE_DROP_TARGET.projectSidebar
dropHandlers: SidebarProjectDropHandlers
affordance: ReturnType<typeof getSidebarProjectDropAffordance>
} {
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<SidebarProjectDropHandlers>(
() => ({
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
})
}
}
@@ -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])
})
@@ -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
}
@@ -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> = {}): Repo {
return {
id: 'repo-1',
path: '/tmp/repo-1',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0,
...overrides
}
}
function makeProjectGroup(overrides: Partial<ProjectGroup>): 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> = {}): 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> = {}): 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> = {}
): 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()
})
})

Some files were not shown because too many files have changed in this diff Show More