Skip to content

Commit 08d06f8

Browse files
khaliqgantclaudeagent-relay-code[bot]kjgbot
authored
Add repo picker to spawn dialog and auto-worktree on project collision (#119)
* Add repo picker to spawn dialog and auto-worktree on project collision - SpawnAgentDialog: show root selector dropdown when project has multiple roots, passing the chosen root as cwd to spawnProjectAgent/spawnProjectPersona - spawn-agent: accept rootOverride parameter to spawn into any root, not just the active one - project:add-root IPC: detect when selected path already belongs to another project and return a conflict descriptor instead of silently reusing it - project:create-worktree-root IPC: given a repo path, discovers the git root, creates a new worktree at ~/.pear/worktrees/{projectId}/{repo} on a fresh pear/{project-slug} branch, and registers it as a root - ProjectSettings: render an inline conflict banner with "Create worktree" and "Go to existing project" buttons when a collision is detected - project-store: expose pendingRootConflict state, clearRootConflict, and createWorktreeRoot actions - git: add getGitRoot and createWorktree helpers https://claude.ai/code/session_01MhKe5JsErX12X8Wbjv9aU2 * Auto-HOLD terminal when typing with multiple agents active When a user starts typing in any terminal and more than one agent is running in the project, the terminal automatically switches to drive (hold) mode so keystrokes are queued rather than immediately injected into the agent. This prevents accidental input interference across agents. - useTerminal: accept autoHold flag plus onAutoHoldStart/onAutoHoldRelease callbacks; on first keypress in passthrough mode fires onAutoHoldStart; on Enter fires onAutoHoldRelease(flush:true); on container blur fires onAutoHoldRelease(flush:false) to release hold without flushing - TerminalInstance: threads autoHold + callback props through to useTerminal - TerminalProject + SplitTerminalTile: thread autoHold props - SplitTerminalPage: passes makeAutoHoldHandlers factory through to tiles - TerminalPane: computes autoHold = runningAgents > 1; makeAutoHoldHandlers creates per-agent callbacks that call handleDeliveryModeChange and flushPending on Enter release https://claude.ai/code/session_01MhKe5JsErX12X8Wbjv9aU2 * chore: apply pr-reviewer fixes for #119 * chore: apply pr-reviewer fixes for #119 * chore: apply pr-reviewer fixes for #119 * chore: apply pr-reviewer fixes for #119 * Fix spawn dialog cloud detach and auto-hold release --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com> Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
1 parent a96c9d0 commit 08d06f8

12 files changed

Lines changed: 401 additions & 65 deletions

File tree

src/main/git.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { spawn } from 'child_process'
22
import { createHash } from 'crypto'
3-
import { existsSync } from 'fs'
3+
import { existsSync, mkdirSync } from 'fs'
44
import { appendFile, mkdtemp, readFile, rm, stat } from 'fs/promises'
5-
import { join } from 'path'
5+
import { dirname, join } from 'path'
66
import { tmpdir } from 'os'
77
import { assertDirectory } from './path-utils'
88
import { cacheAvatarFromUrl, cachedAvatarUrl } from './avatar-cache'
@@ -1584,3 +1584,21 @@ export async function getSelectedDiff(path: string, input: { wholeFiles: string[
15841584
const patch = input.patch?.trim() ? input.patch.trim() : ''
15851585
return [...diffs, patch].filter(Boolean).join('\n')
15861586
}
1587+
1588+
export async function getGitRoot(path: string): Promise<string | null> {
1589+
try {
1590+
const result = await runGit(['rev-parse', '--show-toplevel'], path)
1591+
return result.trim()
1592+
} catch {
1593+
return null
1594+
}
1595+
}
1596+
1597+
export async function createWorktree(repoPath: string, worktreePath: string, branchName: string): Promise<void> {
1598+
mkdirSync(dirname(worktreePath), { recursive: true })
1599+
await runGit(['worktree', 'add', '-b', branchName, worktreePath], repoPath)
1600+
}
1601+
1602+
export function worktreeExists(worktreePath: string): boolean {
1603+
return existsSync(join(worktreePath, '.git'))
1604+
}

src/main/ipc-handlers.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { app, ipcMain, dialog, BrowserWindow, shell } from 'electron'
2-
import { resolve } from 'path'
2+
import { createHash } from 'crypto'
3+
import { basename, join, resolve } from 'path'
34
import type { SpawnPtyInput, SendMessageInput } from '@agent-relay/harness-driver'
45
import {
56
loadStore,
@@ -12,6 +13,7 @@ import {
1213
setProjectChannelPeople,
1314
addProjectRoot,
1415
removeProjectRoot,
16+
findProjectsWithPath,
1517
addProjectIntegration,
1618
removeProjectIntegration
1719
} from './store'
@@ -158,7 +160,38 @@ export function registerIpcHandlers(): void {
158160
if (result.canceled || !result.filePaths[0]) return null
159161
path = result.filePaths[0]
160162
}
161-
return addProjectRoot(projectId, path, name)
163+
164+
const conflict = findProjectsWithPath(path).find((p) => p.id !== projectId)
165+
if (conflict) {
166+
return { kind: 'conflict', projectId, existingProjectId: conflict.id, existingProjectName: conflict.name, path }
167+
}
168+
169+
return { kind: 'added', root: addProjectRoot(projectId, path, name) }
170+
})
171+
172+
ipcMain.handle('project:create-worktree-root', async (_, projectId: string, repoPath: string, projectName: string, name?: string) => {
173+
const gitRoot = await git.getGitRoot(repoPath)
174+
if (!gitRoot) throw new Error(`Not a git repository: ${repoPath}`)
175+
176+
const repoBasename = basename(gitRoot)
177+
const repoHash = createHash('sha1').update(gitRoot).digest('hex').slice(0, 8)
178+
const worktreePath = join(app.getPath('userData'), 'worktrees', projectId, `${repoBasename}-${repoHash}`)
179+
180+
const branchSlug = projectName
181+
.toLowerCase()
182+
.replace(/[^a-z0-9]+/g, '-')
183+
.replace(/^-+|-+$/g, '')
184+
.slice(0, 40) || 'pear'
185+
const projectSlug = projectId
186+
.toLowerCase()
187+
.replace(/[^a-z0-9]+/g, '')
188+
.slice(0, 8)
189+
const branchName = `pear/${branchSlug}${projectSlug ? `-${projectSlug}` : ''}`
190+
191+
if (!git.worktreeExists(worktreePath)) {
192+
await git.createWorktree(gitRoot, worktreePath, branchName)
193+
}
194+
return addProjectRoot(projectId, worktreePath, name || repoBasename)
162195
})
163196

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

286+
ipcMain.handle('broker:send-input', async (_, projectId: string | undefined, name: string, data: string) => {
287+
return brokerManager.sendInput(projectId, name, data)
288+
})
289+
253290
ipcMain.on('broker:send-input-fast', (_, projectId: string | undefined, name: string, data: string) => {
254291
brokerManager.sendInputFireAndForget(projectId, name, data)
255292
})

src/main/store.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { app } from 'electron'
22
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs'
3-
import { basename, join } from 'path'
3+
import { basename, join, resolve } from 'path'
44
import { z } from 'zod'
55
import type { ProactiveAgentBinding, ProactiveAgentDraft } from './proactive-agent.types'
66
import {
@@ -193,6 +193,14 @@ export function setProjectChannelPeople(projectId: string, channelName: string,
193193
return normalizedPeople
194194
}
195195

196+
export function findProjectsWithPath(rootPath: string): Array<{ id: string; name: string }> {
197+
const data = loadStore()
198+
const normalizedRootPath = resolve(rootPath)
199+
return data.projects
200+
.filter((p) => p.roots.some((r) => resolve(r.path) === normalizedRootPath))
201+
.map((p) => ({ id: p.id, name: p.name }))
202+
}
203+
196204
export function addProjectRoot(projectId: string, rootPath: string, name?: string): ProjectRoot {
197205
const data = loadStore()
198206
const project = withProject(data, projectId)

src/preload/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AiHistSession,
77
AiHistStats,
88
AiHistStatusResponse,
9+
AddRootResult,
910
AuthLoginInput,
1011
AuthStatus,
1112
BrokerAttachTerminalInput,
@@ -66,6 +67,8 @@ import type {
6667
ProactiveAgentRunsPage,
6768
ProactiveAgentTranscript,
6869
ProjectListResult,
70+
ProjectIntegrationResult,
71+
ProjectRootRecord,
6972
TerminalAttachMode,
7073
UpdaterState,
7174
WorkforcePersona
@@ -77,6 +80,7 @@ export type {
7780
AiHistResumeEntry,
7881
AiHistSession,
7982
AiHistSource,
83+
AddRootResult,
8084
AiHistStats,
8185
AiHistStatusResponse,
8286
AgentCurrentState,
@@ -160,6 +164,9 @@ export type {
160164
ProactiveAgentTranscript,
161165
ProactiveAgentWatchEventKind,
162166
ProjectListResult,
167+
ProjectIntegrationResult,
168+
ProjectRootConflict,
169+
ProjectRootRecord,
163170
TerminalAttachMode,
164171
ViewMode,
165172
WorkforcePersona
@@ -198,11 +205,13 @@ const api = {
198205
setChannelPeople: (projectId: string, channelName: string, people: string[]) =>
199206
invoke<string[]>('project:set-channel-people', projectId, channelName, people),
200207
addRoot: (projectId: string, name?: string, rootPath?: string) =>
201-
invoke<unknown>('project:add-root', projectId, name, rootPath),
208+
invoke<AddRootResult | null>('project:add-root', projectId, name, rootPath),
202209
removeRoot: (projectId: string, rootId: string) =>
203210
invoke<void>('project:remove-root', projectId, rootId),
211+
createWorktreeRoot: (projectId: string, repoPath: string, projectName: string, name?: string) =>
212+
invoke<ProjectRootRecord>('project:create-worktree-root', projectId, repoPath, projectName, name),
204213
addIntegration: (projectId: string, name: string, type?: string) =>
205-
invoke<unknown>('project:add-integration', projectId, name, type),
214+
invoke<ProjectIntegrationResult>('project:add-integration', projectId, name, type),
206215
removeIntegration: (projectId: string, integrationId: string) =>
207216
invoke<void>('project:remove-integration', projectId, integrationId)
208217
},
@@ -235,6 +244,8 @@ const api = {
235244
invoke<BrokerSpawnAgentResult>('broker:spawn-persona', projectId, personaId),
236245
attachTerminal: (input: BrokerAttachTerminalInput) =>
237246
invoke<BrokerAttachTerminalResult>('broker:attach-terminal', input),
247+
sendInput: (projectId: string | undefined, name: string, data: string) =>
248+
invoke<{ name: string; bytes_written: number }>('broker:send-input', projectId, name, data),
238249
sendInputFast: (projectId: string | undefined, name: string, data: string): void => {
239250
ipcRenderer.send('broker:send-input-fast', projectId, name, data)
240251
},

src/renderer/src/components/settings/ProjectSettings.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,9 +842,13 @@ export function ProjectSettings(): React.ReactNode {
842842
const activeChannelName = useProjectStore((s) => s.activeChannelName)
843843
const setActiveRoot = useProjectStore((s) => s.setActiveRoot)
844844
const setActiveChannel = useProjectStore((s) => s.setActiveChannel)
845+
const setActiveProject = useProjectStore((s) => s.setActiveProject)
845846
const updateProject = useProjectStore((s) => s.updateProject)
846847
const removeProject = useProjectStore((s) => s.removeProject)
847848
const addRoot = useProjectStore((s) => s.addRoot)
849+
const clearRootConflict = useProjectStore((s) => s.clearRootConflict)
850+
const createWorktreeRoot = useProjectStore((s) => s.createWorktreeRoot)
851+
const pendingRootConflict = useProjectStore((s) => s.pendingRootConflict)
848852
const removeRoot = useProjectStore((s) => s.removeRoot)
849853
const addChannel = useProjectStore((s) => s.addChannel)
850854
const removeChannel = useProjectStore((s) => s.removeChannel)
@@ -1023,6 +1027,47 @@ export function ProjectSettings(): React.ReactNode {
10231027
}}
10241028
/>
10251029
))}
1030+
{pendingRootConflict?.projectId === project.id && (
1031+
<div className="rounded-lg border border-[var(--pear-yellow,#f59e0b)]/30 bg-[var(--pear-yellow,#f59e0b)]/10 p-4 text-sm">
1032+
<p className="mb-1 font-medium text-[var(--pear-text)]">Repo already in another project</p>
1033+
<p className="mb-3 text-[var(--pear-text-dim)]">
1034+
<span className="font-mono text-xs">{pendingRootConflict.path}</span> is already part of{' '}
1035+
<strong>{pendingRootConflict.existingProjectName}</strong>. Create an isolated git worktree
1036+
so both projects can work independently, or go to the existing project.
1037+
</p>
1038+
<div className="flex flex-wrap gap-2">
1039+
<button
1040+
type="button"
1041+
onClick={() =>
1042+
void run(async () => {
1043+
const root = await createWorktreeRoot(pendingRootConflict.path)
1044+
if (root) clearRootConflict()
1045+
})
1046+
}
1047+
className="rounded-md bg-[var(--pear-accent)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--pear-accent-bright)]"
1048+
>
1049+
Create worktree
1050+
</button>
1051+
<button
1052+
type="button"
1053+
onClick={() => {
1054+
clearRootConflict()
1055+
void setActiveProject(pendingRootConflict.existingProjectId)
1056+
}}
1057+
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)]"
1058+
>
1059+
Go to &ldquo;{pendingRootConflict.existingProjectName}&rdquo;
1060+
</button>
1061+
<button
1062+
type="button"
1063+
onClick={clearRootConflict}
1064+
className="rounded-md px-3 py-1.5 text-xs text-[var(--pear-text-faint)] hover:text-[var(--pear-text-dim)]"
1065+
>
1066+
Cancel
1067+
</button>
1068+
</div>
1069+
</div>
1070+
)}
10261071
</div>
10271072
</Section>
10281073

src/renderer/src/components/sidebar/SpawnAgentDialog.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Loader2, X } from 'lucide-react'
44
import { ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
55
import { listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent'
66
import type { WorkforcePersona } from '@/lib/ipc'
7-
import { useProjectStore } from '@/stores/project-store'
7+
import { useProjectStore, type ProjectRoot } from '@/stores/project-store'
88
import { useUIStore } from '@/stores/ui-store'
99

1010
const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
@@ -20,8 +20,12 @@ export function SpawnAgentDialog(): React.ReactNode {
2020
const [selectedPersonaId, setSelectedPersonaId] = useState('')
2121
const [customName, setCustomName] = useState('')
2222
const [error, setError] = useState<string | null>(null)
23+
const [selectedRootId, setSelectedRootId] = useState<string | null>(null)
2324
const project = useProjectStore((s) => s.getActiveProject())
24-
const root = useProjectStore((s) => s.getActiveRoot())
25+
const defaultRoot = useProjectStore((s) => s.getActiveRoot())
26+
const selectedRoot = project?.roots.find((r) => r.id === selectedRootId)
27+
const root: ProjectRoot | undefined = selectedRoot ?? defaultRoot
28+
const safeSelectedRootId = project?.roots.some((r) => r.id === selectedRootId) ? selectedRootId ?? '' : root?.id ?? ''
2529
const closeDialog = useUIStore((s) => s.closeDialog)
2630
const openDialog = useUIStore((s) => s.openDialog)
2731
const dialogRef = useRef<HTMLDivElement>(null)
@@ -35,6 +39,12 @@ export function SpawnAgentDialog(): React.ReactNode {
3539
return () => document.removeEventListener('keydown', handleKeyDown)
3640
}, [closeDialog])
3741

42+
useEffect(() => {
43+
if (selectedRootId && !project?.roots.some((r) => r.id === selectedRootId)) {
44+
setSelectedRootId(root?.id ?? null)
45+
}
46+
}, [project, root?.id, selectedRootId])
47+
3848
useEffect(() => {
3949
let cancelled = false
4050

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

4959
setLoadingPersonas(true)
5060
try {
51-
const discovered = await listProjectPersonas(project)
61+
const discovered = await listProjectPersonas(project, root)
5262
if (cancelled) return
5363
setError(null)
5464
setPersonas(discovered)
@@ -104,7 +114,7 @@ export function SpawnAgentDialog(): React.ReactNode {
104114
setError(null)
105115
setSpawningCli(cli)
106116
try {
107-
await spawnProjectAgent(project, cli, customName)
117+
await spawnProjectAgent(project, cli, customName, root)
108118
closeDialog()
109119
} catch (err) {
110120
setError(err instanceof Error ? err.message : String(err))
@@ -127,7 +137,7 @@ export function SpawnAgentDialog(): React.ReactNode {
127137
setError(null)
128138
setSpawningPersona(true)
129139
try {
130-
await spawnProjectPersona(project, selectedPersonaId)
140+
await spawnProjectPersona(project, selectedPersonaId, root)
131141
closeDialog()
132142
} catch (err) {
133143
setError(err instanceof Error ? err.message : String(err))
@@ -160,6 +170,26 @@ export function SpawnAgentDialog(): React.ReactNode {
160170
<div className="px-5 py-5">
161171
{project ? (
162172
<div className="space-y-3">
173+
{project.roots.length > 1 && (
174+
<div>
175+
<label htmlFor="spawn-root-select" className="mb-1 block text-xs font-medium text-[var(--pear-text-dim)]">
176+
Spawn into
177+
</label>
178+
<select
179+
id="spawn-root-select"
180+
value={safeSelectedRootId}
181+
onChange={(e) => setSelectedRootId(e.target.value)}
182+
disabled={spawning}
183+
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"
184+
>
185+
{project.roots.map((r) => (
186+
<option key={r.id} value={r.id}>
187+
{r.name}
188+
</option>
189+
))}
190+
</select>
191+
</div>
192+
)}
163193
<div className="truncate text-xs text-[var(--pear-text-faint)]">{root?.path || project.rootPath}</div>
164194
<div>
165195
<label htmlFor="spawn-agent-name" className="mb-1 block text-xs font-medium text-[var(--pear-text-dim)]">

src/renderer/src/components/terminal/TerminalInstance.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ interface Props {
1010
active: boolean
1111
mode: TerminalAttachMode
1212
onActivate?: () => void
13+
autoHold?: boolean
14+
onAutoHoldStart?: () => Promise<void> | void
15+
onAutoHoldRelease?: (flush: boolean) => Promise<void> | void
1316
}
1417

15-
export function TerminalInstance({ agentName, projectId, visible, active, mode, onActivate }: Props): React.ReactNode {
18+
export function TerminalInstance({ agentName, projectId, visible, active, mode, onActivate, autoHold, onAutoHoldStart, onAutoHoldRelease }: Props): React.ReactNode {
1619
const containerRef = useRef<HTMLDivElement>(null)
17-
useTerminal(containerRef, agentName, projectId, visible, active, mode)
20+
useTerminal(containerRef, agentName, projectId, visible, active, mode, autoHold, onAutoHoldStart, onAutoHoldRelease)
1821

1922
return (
2023
<div

0 commit comments

Comments
 (0)