diff --git a/src/main/broker.ts b/src/main/broker.ts index 51db7ebe..62e199ea 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1339,7 +1339,8 @@ export class BrokerManager { cwd: string, name: string, win: BrowserWindow, - channels: string[] = [] + channels: string[] = [], + workspaceKey?: string ): Promise { const normalizedProjectId = projectId.trim() if (!normalizedProjectId) { @@ -1380,7 +1381,7 @@ export class BrokerManager { } const startBroker = async (): Promise => { - const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined + const explicitWorkspaceKey = workspaceKey?.trim() || process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined const existingClient = await this.connectExistingBroker(normalizedProjectId, cwd, explicitWorkspaceKey) if (existingClient) { const eventStreamGeneration = this.nextEventStreamGeneration() @@ -1617,6 +1618,37 @@ export class BrokerManager { return { removed } } + /** + * Tear down this project's current broker session(s) and start a fresh + * local broker pinned to an existing workspace key, so this Pear instance + * joins another instance's workspace instead of creating its own. + */ + async joinWorkspace( + projectId: string, + cwd: string, + name: string, + win: BrowserWindow, + channels: string[] = [], + workspaceKey: string + ): Promise { + const normalizedProjectId = projectId.trim() + if (!normalizedProjectId) { + throw new Error('Project id is required') + } + const trimmedKey = workspaceKey.trim() + if (!trimmedKey) { + throw new Error('Workspace key is required') + } + assertDirectory(cwd, 'Project path') + // A start already in flight for this project (e.g. the initial project + // open) would otherwise race start()'s in-flight-reuse path below and + // silently keep the broker on the old/first-requested key. + const inFlightStart = this.startPromises.get(normalizedProjectId) + if (inFlightStart) await inFlightStart.catch(() => undefined) + await this.shutdown(normalizedProjectId) + return this.start(normalizedProjectId, cwd, name, win, channels, trimmedKey) + } + /** * The local broker creates the project's relay workspace; its workspace_key * is what cloud sandbox brokers must join so local and cloud agents share diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index ed4f80ff..876ce7f5 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -650,6 +650,27 @@ export function registerIpcHandlers(): void { await brokerManager.syncChannels(projectId, channels) }) + ipcMain.handle('broker:join-workspace', async ( + event, + projectId: string, + cwd: string, + name: string, + channels: string[] | undefined, + workspaceKey: string + ) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) throw new Error('No window') + if (!isDirectory(cwd)) { + throw new Error(`Project path no longer exists: ${cwd}`) + } + const started = await brokerManager.joinWorkspace(projectId, cwd, name, win, channels, workspaceKey) + if (started) { + void integrationsManager.notifyAgentState(projectId).catch((error) => { + console.warn('[integrations] Failed to notify agents after workspace join:', error instanceof Error ? error.message : String(error)) + }) + } + }) + ipcMain.handle('broker:auto-fix-runtime', async ( event, projectId: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index 46b0906e..cbf4f52d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -225,6 +225,8 @@ const api = { invoke('broker:start', projectId, cwd, name, channels), syncChannels: (projectId: string, channels: string[]) => invoke('broker:sync-channels', projectId, channels), + joinWorkspace: (projectId: string, cwd: string, name: string, channels: string[] | undefined, workspaceKey: string) => + invoke('broker:join-workspace', projectId, cwd, name, channels, workspaceKey), autoFixRuntime: ( projectId: string, cwd: string, diff --git a/src/renderer/src/components/broker/BrokerDetailsPage.tsx b/src/renderer/src/components/broker/BrokerDetailsPage.tsx index 3d2dfbd3..dbf58070 100644 --- a/src/renderer/src/components/broker/BrokerDetailsPage.tsx +++ b/src/renderer/src/components/broker/BrokerDetailsPage.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Activity, AlertCircle, @@ -9,6 +9,7 @@ import { Copy, KeyRound, List, + LogOut, MessageSquare, RefreshCw, Server, @@ -840,6 +841,108 @@ function BrokerEventFeed({ ) } +function JoinWorkspaceControl({ + project, + joining, + joinError, + onJoin, + onDismissError +}: { + project?: Project + joining?: boolean + joinError?: string + onJoin: (project: Project, workspaceKey: string) => void + onDismissError?: (project: Project) => void +}): React.ReactNode { + const [open, setOpen] = useState(false) + const [key, setKey] = useState('') + const wasJoining = useRef(false) + + useEffect(() => { + // Auto-close and reset once a join finishes without leaving an error — + // otherwise the form stays open with the submitted key and no feedback + // that the join actually succeeded. + if (wasJoining.current && !joining && !joinError) { + setOpen(false) + setKey('') + } + wasJoining.current = Boolean(joining) + }, [joining, joinError]) + + if (!project) return null + + const submit = (): void => { + const trimmed = key.trim() + if (!trimmed) return + onJoin(project, trimmed) + } + + const close = (): void => { + setOpen(false) + setKey('') + onDismissError?.(project) + } + + if (!open) { + return ( + + ) + } + + return ( +
{ + event.preventDefault() + submit() + }} + > +

+ Paste the workspace key from another Pear instance's Agent Relay Status page. This closes the + current broker session for this project and reconnects it to that workspace. +

+
+ setKey(event.target.value)} + placeholder="Workspace key" + disabled={joining} + autoFocus + className="min-w-0 flex-1 rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-2 py-1.5 font-mono text-[12px] text-[var(--pear-text)] outline-none focus:border-[var(--pear-accent-dim)]" + /> + + +
+ {joinError &&

{joinError}

} +
+ ) +} + function BrokerCard({ broker, projectName, @@ -848,7 +951,11 @@ function BrokerCard({ project, fixing, fixError, - onAutoFix + onAutoFix, + joining, + joinError, + onJoinWorkspace, + onDismissJoinError }: { broker: BrokerDetails projectName: string @@ -858,6 +965,10 @@ function BrokerCard({ fixing?: boolean fixError?: string onAutoFix?: (project: Project, entry: BrokerErrorEntry) => void + joining?: boolean + joinError?: string + onJoinWorkspace?: (project: Project, workspaceKey: string) => void + onDismissJoinError?: (project: Project) => void }): React.ReactNode { const Icon = broker.kind === 'cloud' ? Cloud : Server @@ -904,6 +1015,16 @@ function BrokerCard({
+ {project && onJoinWorkspace && ( + + )} +
@@ -975,6 +1096,8 @@ export function BrokerDetailsPage(): React.ReactNode { const [detailsError, setDetailsError] = useState(null) const [autoFixingProjectId, setAutoFixingProjectId] = useState(null) const [autoFixErrors, setAutoFixErrors] = useState>({}) + const [joiningProjectId, setJoiningProjectId] = useState(null) + const [joinErrors, setJoinErrors] = useState>({}) const pageProjectId = activeBrokerTabProjectId || activeProjectId || undefined const pageProjectName = pageProjectId ? getProjectName(projects, pageProjectId) : 'All projects' @@ -1087,6 +1210,47 @@ export function BrokerDetailsPage(): React.ReactNode { } }, [loadBrokerDetails]) + const handleJoinWorkspace = useCallback(async (project: Project, workspaceKey: string): Promise => { + const root = getDefaultProjectRoot(project) + if (!root?.pathExists) { + setJoinErrors((errors) => ({ + ...errors, + [project.id]: `Project path no longer exists: ${root?.path || project.rootPath}` + })) + return + } + + setJoiningProjectId(project.id) + setJoinErrors((errors) => { + const { [project.id]: _removed, ...remaining } = errors + return remaining + }) + try { + await pear.broker.joinWorkspace( + project.id, + root.path, + getProjectBrokerName(project), + project.channels, + workspaceKey + ) + await loadBrokerDetails() + } catch (err) { + setJoinErrors((errors) => ({ + ...errors, + [project.id]: err instanceof Error ? err.message : String(err) + })) + } finally { + setJoiningProjectId(null) + } + }, [loadBrokerDetails]) + + const handleDismissJoinError = useCallback((project: Project): void => { + setJoinErrors((errors) => { + const { [project.id]: _removed, ...remaining } = errors + return remaining + }) + }, []) + useEffect(() => { void loadBrokerDetails() }, [loadBrokerDetails]) @@ -1145,6 +1309,10 @@ export function BrokerDetailsPage(): React.ReactNode { fixing={autoFixingProjectId === broker.projectId} fixError={autoFixErrors[broker.projectId]} onAutoFix={(project, entry) => void handleAutoFix(project, entry)} + joining={joiningProjectId === broker.projectId} + joinError={joinErrors[broker.projectId]} + onJoinWorkspace={(project, workspaceKey) => void handleJoinWorkspace(project, workspaceKey)} + onDismissJoinError={handleDismissJoinError} /> ))} {unmatchedProjectErrorGroups.map(([projectId, entries]) => ( diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index ee7d6ad0..162ac205 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -875,6 +875,19 @@ export const pearMock: PearAPI = { const project = state.projects.find((entry) => entry.id === projectId) if (project) project.channels = Array.from(new Set(channels)) }, + joinWorkspace: async ( + projectId: string, + _cwd: string, + _name: string, + _channels: string[] | undefined, + workspaceKey: string + ) => { + if (!workspaceKey.trim()) { + throw new Error('Workspace key is required') + } + emitBrokerStatus({ projectId, status: 'connected' }) + return true + }, autoFixRuntime: async () => ({ removed: [] }), connectCloud: async () => 'mock-cloud', spawnAgent: async (projectId: string, input: BrokerSpawnAgentInput): Promise => { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index f4711a20..cb082d48 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -912,6 +912,13 @@ export interface PearAPI { broker: { start: (projectId: string, cwd: string, name: string, channels?: string[]) => Promise syncChannels: (projectId: string, channels: string[]) => Promise + joinWorkspace: ( + projectId: string, + cwd: string, + name: string, + channels: string[] | undefined, + workspaceKey: string + ) => Promise autoFixRuntime: ( projectId: string, cwd: string,