Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/main/git.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { spawn } from 'child_process'
import { createHash } from 'crypto'
import { existsSync } from 'fs'
import { existsSync, mkdirSync } from 'fs'
import { appendFile, mkdtemp, readFile, rm, stat } from 'fs/promises'
Comment on lines +3 to 4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid importing and using synchronous file system methods like mkdirSync in asynchronous functions within the Electron main process, as they can block the main thread. Instead, import mkdir from fs/promises.

Suggested change
import { existsSync, mkdirSync } from 'fs'
import { appendFile, mkdtemp, readFile, rm, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { appendFile, mkdir, mkdtemp, readFile, rm, stat } from 'fs/promises'

import { join } from 'path'
import { dirname, join } from 'path'
import { tmpdir } from 'os'
import { assertDirectory } from './path-utils'
import { cacheAvatarFromUrl, cachedAvatarUrl } from './avatar-cache'
Expand Down Expand Up @@ -1584,3 +1584,21 @@ export async function getSelectedDiff(path: string, input: { wholeFiles: string[
const patch = input.patch?.trim() ? input.patch.trim() : ''
return [...diffs, patch].filter(Boolean).join('\n')
}

export async function getGitRoot(path: string): Promise<string | null> {
try {
const result = await runGit(['rev-parse', '--show-toplevel'], path)
return result.trim()
} catch {
return null
}
}

export async function createWorktree(repoPath: string, worktreePath: string, branchName: string): Promise<void> {
mkdirSync(dirname(worktreePath), { recursive: true })
await runGit(['worktree', 'add', '-b', branchName, worktreePath], repoPath)
}
Comment on lines +1597 to +1600
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the branch specified by branchName already exists in the repository, git worktree add -b <branch> will fail. To make this more robust, check if the branch already exists using listBranches and omit the -b flag if it does. Also, use the asynchronous mkdir from fs/promises instead of mkdirSync to avoid blocking the main thread.

Suggested change
export async function createWorktree(repoPath: string, worktreePath: string, branchName: string): Promise<void> {
mkdirSync(dirname(worktreePath), { recursive: true })
await runGit(['worktree', 'add', '-b', branchName, worktreePath], repoPath)
}
export async function createWorktree(repoPath: string, worktreePath: string, branchName: string): Promise<void> {
await mkdir(dirname(worktreePath), { recursive: true })
const branches = await listBranches(repoPath)
if (branches.includes(branchName)) {
await runGit(['worktree', 'add', worktreePath, branchName], repoPath)
} else {
await runGit(['worktree', 'add', '-b', branchName, worktreePath], repoPath)
}
}


export function worktreeExists(worktreePath: string): boolean {
return existsSync(join(worktreePath, '.git'))
}
41 changes: 39 additions & 2 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app, ipcMain, dialog, BrowserWindow, shell } from 'electron'
import { resolve } from 'path'
import { createHash } from 'crypto'
import { basename, join, resolve } from 'path'
import type { SpawnPtyInput, SendMessageInput } from '@agent-relay/harness-driver'
import {
loadStore,
Expand All @@ -12,6 +13,7 @@ import {
setProjectChannelPeople,
addProjectRoot,
removeProjectRoot,
findProjectsWithPath,
addProjectIntegration,
removeProjectIntegration
} from './store'
Expand Down Expand Up @@ -158,7 +160,38 @@ export function registerIpcHandlers(): void {
if (result.canceled || !result.filePaths[0]) return null
path = result.filePaths[0]
}
return addProjectRoot(projectId, path, name)

const conflict = findProjectsWithPath(path).find((p) => p.id !== projectId)
if (conflict) {
return { kind: 'conflict', projectId, existingProjectId: conflict.id, existingProjectName: conflict.name, path }
}

return { kind: 'added', root: addProjectRoot(projectId, path, name) }
})

ipcMain.handle('project:create-worktree-root', async (_, projectId: string, repoPath: string, projectName: string, name?: string) => {
const gitRoot = await git.getGitRoot(repoPath)
if (!gitRoot) throw new Error(`Not a git repository: ${repoPath}`)

const repoBasename = basename(gitRoot)
const repoHash = createHash('sha1').update(gitRoot).digest('hex').slice(0, 8)
const worktreePath = join(app.getPath('userData'), 'worktrees', projectId, `${repoBasename}-${repoHash}`)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const branchSlug = projectName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'pear'
const projectSlug = projectId
.toLowerCase()
.replace(/[^a-z0-9]+/g, '')
.slice(0, 8)
const branchName = `pear/${branchSlug}${projectSlug ? `-${projectSlug}` : ''}`

if (!git.worktreeExists(worktreePath)) {
await git.createWorktree(gitRoot, worktreePath, branchName)
}
return addProjectRoot(projectId, worktreePath, name || repoBasename)
})

ipcMain.handle('project:remove-root', (_, projectId: string, rootId: string) => {
Expand Down Expand Up @@ -250,6 +283,10 @@ export function registerIpcHandlers(): void {
return brokerManager.connectCloud('cloud', win)
})

ipcMain.handle('broker:send-input', async (_, projectId: string | undefined, name: string, data: string) => {
return brokerManager.sendInput(projectId, name, data)
})

ipcMain.on('broker:send-input-fast', (_, projectId: string | undefined, name: string, data: string) => {
brokerManager.sendInputFireAndForget(projectId, name, data)
})
Expand Down
10 changes: 9 additions & 1 deletion src/main/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app } from 'electron'
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs'
import { basename, join } from 'path'
import { basename, join, resolve } from 'path'
import { z } from 'zod'
import type { ProactiveAgentBinding, ProactiveAgentDraft } from './proactive-agent.types'
import {
Expand Down Expand Up @@ -193,6 +193,14 @@ export function setProjectChannelPeople(projectId: string, channelName: string,
return normalizedPeople
}

export function findProjectsWithPath(rootPath: string): Array<{ id: string; name: string }> {
const data = loadStore()
const normalizedRootPath = resolve(rootPath)
return data.projects
.filter((p) => p.roots.some((r) => resolve(r.path) === normalizedRootPath))
.map((p) => ({ id: p.id, name: p.name }))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function addProjectRoot(projectId: string, rootPath: string, name?: string): ProjectRoot {
const data = loadStore()
const project = withProject(data, projectId)
Expand Down
15 changes: 13 additions & 2 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AiHistSession,
AiHistStats,
AiHistStatusResponse,
AddRootResult,
AuthLoginInput,
AuthStatus,
BrokerAttachTerminalInput,
Expand Down Expand Up @@ -66,6 +67,8 @@ import type {
ProactiveAgentRunsPage,
ProactiveAgentTranscript,
ProjectListResult,
ProjectIntegrationResult,
ProjectRootRecord,
TerminalAttachMode,
UpdaterState,
WorkforcePersona
Expand All @@ -77,6 +80,7 @@ export type {
AiHistResumeEntry,
AiHistSession,
AiHistSource,
AddRootResult,
AiHistStats,
AiHistStatusResponse,
AgentCurrentState,
Expand Down Expand Up @@ -160,6 +164,9 @@ export type {
ProactiveAgentTranscript,
ProactiveAgentWatchEventKind,
ProjectListResult,
ProjectIntegrationResult,
ProjectRootConflict,
ProjectRootRecord,
TerminalAttachMode,
ViewMode,
WorkforcePersona
Expand Down Expand Up @@ -198,11 +205,13 @@ const api = {
setChannelPeople: (projectId: string, channelName: string, people: string[]) =>
invoke<string[]>('project:set-channel-people', projectId, channelName, people),
addRoot: (projectId: string, name?: string, rootPath?: string) =>
invoke<unknown>('project:add-root', projectId, name, rootPath),
invoke<AddRootResult | null>('project:add-root', projectId, name, rootPath),
removeRoot: (projectId: string, rootId: string) =>
invoke<void>('project:remove-root', projectId, rootId),
createWorktreeRoot: (projectId: string, repoPath: string, projectName: string, name?: string) =>
invoke<ProjectRootRecord>('project:create-worktree-root', projectId, repoPath, projectName, name),
addIntegration: (projectId: string, name: string, type?: string) =>
invoke<unknown>('project:add-integration', projectId, name, type),
invoke<ProjectIntegrationResult>('project:add-integration', projectId, name, type),
removeIntegration: (projectId: string, integrationId: string) =>
invoke<void>('project:remove-integration', projectId, integrationId)
},
Expand Down Expand Up @@ -235,6 +244,8 @@ const api = {
invoke<BrokerSpawnAgentResult>('broker:spawn-persona', projectId, personaId),
attachTerminal: (input: BrokerAttachTerminalInput) =>
invoke<BrokerAttachTerminalResult>('broker:attach-terminal', input),
sendInput: (projectId: string | undefined, name: string, data: string) =>
invoke<{ name: string; bytes_written: number }>('broker:send-input', projectId, name, data),
sendInputFast: (projectId: string | undefined, name: string, data: string): void => {
ipcRenderer.send('broker:send-input-fast', projectId, name, data)
},
Expand Down
45 changes: 45 additions & 0 deletions src/renderer/src/components/settings/ProjectSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -842,9 +842,13 @@ export function ProjectSettings(): React.ReactNode {
const activeChannelName = useProjectStore((s) => s.activeChannelName)
const setActiveRoot = useProjectStore((s) => s.setActiveRoot)
const setActiveChannel = useProjectStore((s) => s.setActiveChannel)
const setActiveProject = useProjectStore((s) => s.setActiveProject)
const updateProject = useProjectStore((s) => s.updateProject)
const removeProject = useProjectStore((s) => s.removeProject)
const addRoot = useProjectStore((s) => s.addRoot)
const clearRootConflict = useProjectStore((s) => s.clearRootConflict)
const createWorktreeRoot = useProjectStore((s) => s.createWorktreeRoot)
const pendingRootConflict = useProjectStore((s) => s.pendingRootConflict)
const removeRoot = useProjectStore((s) => s.removeRoot)
const addChannel = useProjectStore((s) => s.addChannel)
const removeChannel = useProjectStore((s) => s.removeChannel)
Expand Down Expand Up @@ -1023,6 +1027,47 @@ export function ProjectSettings(): React.ReactNode {
}}
/>
))}
{pendingRootConflict?.projectId === project.id && (
<div className="rounded-lg border border-[var(--pear-yellow,#f59e0b)]/30 bg-[var(--pear-yellow,#f59e0b)]/10 p-4 text-sm">
<p className="mb-1 font-medium text-[var(--pear-text)]">Repo already in another project</p>
<p className="mb-3 text-[var(--pear-text-dim)]">
<span className="font-mono text-xs">{pendingRootConflict.path}</span> is already part of{' '}
<strong>{pendingRootConflict.existingProjectName}</strong>. Create an isolated git worktree
so both projects can work independently, or go to the existing project.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
void run(async () => {
const root = await createWorktreeRoot(pendingRootConflict.path)
if (root) clearRootConflict()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
className="rounded-md bg-[var(--pear-accent)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--pear-accent-bright)]"
>
Create worktree
</button>
<button
type="button"
onClick={() => {
clearRootConflict()
void setActiveProject(pendingRootConflict.existingProjectId)
Comment on lines +1054 to +1055
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: setActiveProject is async in the project store, but this click handler fire-and-forgets it without error handling. If IPC/hydration fails, the promise rejection is unhandled and the UI may remain in an inconsistent state. Route this through run(...) (or await with try/catch) so failures surface properly. [possible bug]

Severity Level: Major ⚠️
- ❌ Project switch failures surface as unhandled promise rejections.
- ⚠️ Root conflict banner cleared even when switch fails.
- ⚠️ Users see inconsistent state with no visible error message.
Steps of Reproduction ✅
1. Produce a root conflict as in suggestion 1: `addRoot()` in `ProjectSettings` triggers a
`{ kind: 'conflict' }` result, which sets `pendingRootConflict` in `project-store`
(src/renderer/src/stores/project-store.ts:23–32) and causes the conflict banner to render
(ProjectSettings.tsx:231–271).

2. Click the "Go to \"\"" button in the conflict banner; its onClick handler runs
`clearRootConflict()` and then `void
setActiveProject(pendingRootConflict.existingProjectId)`
(src/renderer/src/components/settings/ProjectSettings.tsx:253–257).

3. `setActiveProject` is asynchronous (project-store.ts:202–213): it awaits
`pear.project.setActive(id)`, then updates
`activeProjectId`/`activeRootId`/`activeChannelName`, and finally awaits `ensureBroker()`
when `id` is truthy. Because the click handler calls it with `void` and does not wrap it
in `run(...)` or a try/catch, any rejection from `pear.project.setActive` or
`ensureBroker` becomes an unhandled promise rejection.

4. On failure, the UI remains on the original project (since the store never completes the
state update), but `clearRootConflict()` has already nulled `pendingRootConflict`
(project-store.ts:42–44), so the banner disappears and the user has no in-UI feedback that
switching projects failed; the only indication is the unhandled rejection in the
console/runtime.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/src/components/settings/ProjectSettings.tsx
**Line:** 1054:1055
**Comment:**
	*Possible Bug: `setActiveProject` is async in the project store, but this click handler fire-and-forgets it without error handling. If IPC/hydration fails, the promise rejection is unhandled and the UI may remain in an inconsistent state. Route this through `run(...)` (or await with try/catch) so failures surface properly.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}}
className="rounded-md border border-[var(--pear-border)] px-3 py-1.5 text-xs text-[var(--pear-text-dim)] hover:bg-[var(--pear-bg-overlay)] hover:text-[var(--pear-text)]"
>
Go to &ldquo;{pendingRootConflict.existingProjectName}&rdquo;
</button>
<button
type="button"
onClick={clearRootConflict}
className="rounded-md px-3 py-1.5 text-xs text-[var(--pear-text-faint)] hover:text-[var(--pear-text-dim)]"
>
Cancel
</button>
</div>
</div>
)}
</div>
</Section>

Expand Down
40 changes: 35 additions & 5 deletions src/renderer/src/components/sidebar/SpawnAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Loader2, X } from 'lucide-react'
import { ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
import { listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent'
import type { WorkforcePersona } from '@/lib/ipc'
import { useProjectStore } from '@/stores/project-store'
import { useProjectStore, type ProjectRoot } from '@/stores/project-store'
import { useUIStore } from '@/stores/ui-store'

const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
Expand All @@ -20,8 +20,12 @@ export function SpawnAgentDialog(): React.ReactNode {
const [selectedPersonaId, setSelectedPersonaId] = useState('')
const [customName, setCustomName] = useState('')
const [error, setError] = useState<string | null>(null)
const [selectedRootId, setSelectedRootId] = useState<string | null>(null)
const project = useProjectStore((s) => s.getActiveProject())
const root = useProjectStore((s) => s.getActiveRoot())
const defaultRoot = useProjectStore((s) => s.getActiveRoot())
const selectedRoot = project?.roots.find((r) => r.id === selectedRootId)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const root: ProjectRoot | undefined = selectedRoot ?? defaultRoot
const safeSelectedRootId = project?.roots.some((r) => r.id === selectedRootId) ? selectedRootId ?? '' : root?.id ?? ''
const closeDialog = useUIStore((s) => s.closeDialog)
const openDialog = useUIStore((s) => s.openDialog)
const dialogRef = useRef<HTMLDivElement>(null)
Expand All @@ -35,6 +39,12 @@ export function SpawnAgentDialog(): React.ReactNode {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [closeDialog])

useEffect(() => {
if (selectedRootId && !project?.roots.some((r) => r.id === selectedRootId)) {
setSelectedRootId(root?.id ?? null)
}
}, [project, root?.id, selectedRootId])

useEffect(() => {
let cancelled = false

Expand All @@ -48,7 +58,7 @@ export function SpawnAgentDialog(): React.ReactNode {

setLoadingPersonas(true)
try {
const discovered = await listProjectPersonas(project)
const discovered = await listProjectPersonas(project, root)
if (cancelled) return
setError(null)
setPersonas(discovered)
Expand Down Expand Up @@ -104,7 +114,7 @@ export function SpawnAgentDialog(): React.ReactNode {
setError(null)
setSpawningCli(cli)
try {
await spawnProjectAgent(project, cli, customName)
await spawnProjectAgent(project, cli, customName, root)
closeDialog()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
Expand All @@ -127,7 +137,7 @@ export function SpawnAgentDialog(): React.ReactNode {
setError(null)
setSpawningPersona(true)
try {
await spawnProjectPersona(project, selectedPersonaId)
await spawnProjectPersona(project, selectedPersonaId, root)
closeDialog()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
Expand Down Expand Up @@ -160,6 +170,26 @@ export function SpawnAgentDialog(): React.ReactNode {
<div className="px-5 py-5">
{project ? (
<div className="space-y-3">
{project.roots.length > 1 && (
<div>
<label htmlFor="spawn-root-select" className="mb-1 block text-xs font-medium text-[var(--pear-text-dim)]">
Spawn into
</label>
<select
id="spawn-root-select"
value={safeSelectedRootId}
onChange={(e) => setSelectedRootId(e.target.value)}
disabled={spawning}
className="h-9 w-full rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-3 text-sm text-[var(--pear-text)] outline-none focus:border-[var(--pear-accent-dim)] disabled:opacity-50"
>
{project.roots.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
)}
<div className="truncate text-xs text-[var(--pear-text-faint)]">{root?.path || project.rootPath}</div>
<div>
<label htmlFor="spawn-agent-name" className="mb-1 block text-xs font-medium text-[var(--pear-text-dim)]">
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/components/terminal/TerminalInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ interface Props {
active: boolean
mode: TerminalAttachMode
onActivate?: () => void
autoHold?: boolean
onAutoHoldStart?: () => Promise<void> | void
onAutoHoldRelease?: (flush: boolean) => Promise<void> | void
}

export function TerminalInstance({ agentName, projectId, visible, active, mode, onActivate }: Props): React.ReactNode {
export function TerminalInstance({ agentName, projectId, visible, active, mode, onActivate, autoHold, onAutoHoldStart, onAutoHoldRelease }: Props): React.ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
useTerminal(containerRef, agentName, projectId, visible, active, mode)
useTerminal(containerRef, agentName, projectId, visible, active, mode, autoHold, onAutoHoldStart, onAutoHoldRelease)

return (
<div
Expand Down
Loading
Loading