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
36 changes: 34 additions & 2 deletions src/main/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,8 @@ export class BrokerManager {
cwd: string,
name: string,
win: BrowserWindow,
channels: string[] = []
channels: string[] = [],
workspaceKey?: string
): Promise<boolean> {
const normalizedProjectId = projectId.trim()
if (!normalizedProjectId) {
Expand Down Expand Up @@ -1380,7 +1381,7 @@ export class BrokerManager {
}

const startBroker = async (): Promise<boolean> => {
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()
Expand Down Expand Up @@ -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<boolean> {
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
Expand Down
21 changes: 21 additions & 0 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ const api = {
invoke<boolean>('broker:start', projectId, cwd, name, channels),
syncChannels: (projectId: string, channels: string[]) =>
invoke<void>('broker:sync-channels', projectId, channels),
joinWorkspace: (projectId: string, cwd: string, name: string, channels: string[] | undefined, workspaceKey: string) =>
invoke<boolean>('broker:join-workspace', projectId, cwd, name, channels, workspaceKey),
autoFixRuntime: (
projectId: string,
cwd: string,
Expand Down
172 changes: 170 additions & 2 deletions src/renderer/src/components/broker/BrokerDetailsPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +9,7 @@ import {
Copy,
KeyRound,
List,
LogOut,
MessageSquare,
RefreshCw,
Server,
Expand Down Expand Up @@ -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 (
<button
type="button"
onClick={() => {
onDismissError?.(project)
setOpen(true)
}}
className="inline-flex items-center gap-2 rounded-md border border-[var(--pear-border-subtle)] px-3 py-1.5 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:bg-[var(--pear-bg-surface-hover)] hover:text-[var(--pear-text)]"
>
<LogOut size={14} />
<span>Close this workspace and join new one</span>
</button>
)
}

return (
<form
className="rounded-lg border border-[var(--pear-border-subtle)] bg-[var(--pear-bg-raised)] p-3"
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<p className="mb-2 text-[11px] text-[var(--pear-text-faint)]">
Paste the workspace key from another Pear instance&apos;s Agent Relay Status page. This closes the
current broker session for this project and reconnects it to that workspace.
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
value={key}
onChange={(event) => 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)]"
/>
<button
type="submit"
disabled={joining || !key.trim()}
className="inline-flex items-center gap-2 rounded-md border border-[var(--pear-accent-dim)] bg-[var(--pear-accent)]/10 px-3 py-1.5 text-sm text-[var(--pear-accent-bright)] hover:bg-[var(--pear-accent)]/15 disabled:cursor-wait disabled:opacity-60"
>
{joining ? <RefreshCw size={14} className="animate-spin" /> : null}
<span>{joining ? 'Joining' : 'Join workspace'}</span>
</button>
<button
type="button"
onClick={close}
disabled={joining}
className="rounded-md border border-[var(--pear-border-subtle)] px-3 py-1.5 text-sm text-[var(--pear-text-dim)] hover:bg-[var(--pear-bg-surface-hover)]"
>
Cancel
</button>
</div>
{joinError && <p className="mt-2 text-sm text-[var(--pear-red)]">{joinError}</p>}
</form>
)
Comment on lines +857 to +943

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

UX Improvement: Auto-close Dialog and Clear Stale Errors

Currently, the JoinWorkspaceControl has two usability issues:

  1. Dialog remains open on success: When a workspace is successfully joined, the dialog stays open with the typed key and the "Join workspace" button enabled, which can confuse users into thinking the action failed or is still pending.
  2. Stale errors persist: If a join attempt fails and the user cancels/closes the dialog, reopening it later will still display the old error message.

We can resolve both issues cleanly by introducing a local localError state and synchronizing the dialog's open/closed state with the joining and joinError props using useEffect hooks.

  const [open, setOpen] = useState(false)
  const [key, setKey] = useState('')
  const [localError, setLocalError] = useState<string | undefined>(undefined)

  useEffect(() => {
    setLocalError(joinError)
  }, [joinError])

  useEffect(() => {
    if (!joining && !joinError) {
      setOpen(false)
      setKey('')
    }
  }, [joining, joinError])

  if (!project) return null

  const submit = (): void => {
    const trimmed = key.trim()
    if (!trimmed) return
    onJoin(project, trimmed)
  }

  if (!open) {
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="inline-flex items-center gap-2 rounded-md border border-[var(--pear-border-subtle)] px-3 py-1.5 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:bg-[var(--pear-bg-surface-hover)] hover:text-[var(--pear-text)]"
      >
        <LogOut size={14} />
        <span>Close this workspace and join new one</span>
      </button>
    )
  }

  return (
    <div className="rounded-lg border border-[var(--pear-border-subtle)] bg-[var(--pear-bg-raised)] p-3">
      <p className="mb-2 text-[11px] text-[var(--pear-text-faint)]">
        Paste the workspace key from another Pear instance&apos;s Agent Relay Status page. This closes the
        current broker session for this project and reconnects it to that workspace.
      </p>
      <div className="flex flex-wrap items-center gap-2">
        <input
          type="text"
          value={key}
          onChange={(event) => {
            setKey(event.target.value)
            setLocalError(undefined)
          }}
          placeholder="Workspace key"
          disabled={joining}
          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)]"
        />
        <button
          type="button"
          onClick={submit}
          disabled={joining || !key.trim()}
          className="inline-flex items-center gap-2 rounded-md border border-[var(--pear-accent-dim)] bg-[var(--pear-accent)]/10 px-3 py-1.5 text-sm text-[var(--pear-accent-bright)] hover:bg-[var(--pear-accent)]/15 disabled:cursor-wait disabled:opacity-60"
        >
          {joining ? <RefreshCw size={14} className="animate-spin" /> : null}
          <span>{joining ? 'Joining' : 'Join workspace'}</span>
        </button>
        <button
          type="button"
          onClick={() => {
            setOpen(false)
            setKey('')
            setLocalError(undefined)
          }}
          disabled={joining}
          className="rounded-md border border-[var(--pear-border-subtle)] px-3 py-1.5 text-sm text-[var(--pear-text-dim)] hover:bg-[var(--pear-bg-surface-hover)]"
        >
          Cancel
        </button>
      </div>
      {localError && <p className="mt-2 text-sm text-[var(--pear-red)]">{localError}</p>}
    </div>
  )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Went with the awaited-in-flight-start fix on the main.ts side plus a simpler ref-based auto-close/reset on the UI side (dd0d805) rather than the localError-mirroring approach — avoids a second source of truth for the error string while getting the same close-on-success and clear-on-reopen behavior.

}

function BrokerCard({
broker,
projectName,
Expand All @@ -848,7 +951,11 @@ function BrokerCard({
project,
fixing,
fixError,
onAutoFix
onAutoFix,
joining,
joinError,
onJoinWorkspace,
onDismissJoinError
}: {
broker: BrokerDetails
projectName: string
Expand All @@ -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

Expand Down Expand Up @@ -904,6 +1015,16 @@ function BrokerCard({
<div className="space-y-5 p-5">
<BrokerMetadataSummary broker={broker} />

{project && onJoinWorkspace && (
<JoinWorkspaceControl
project={project}
joining={joining}
joinError={joinError}
onJoin={onJoinWorkspace}
onDismissError={onDismissJoinError}
/>
)}

<div>
<div className="mb-3 flex items-center gap-2">
<KeyRound size={15} className="text-[var(--pear-accent)]" />
Expand Down Expand Up @@ -975,6 +1096,8 @@ export function BrokerDetailsPage(): React.ReactNode {
const [detailsError, setDetailsError] = useState<string | null>(null)
const [autoFixingProjectId, setAutoFixingProjectId] = useState<string | null>(null)
const [autoFixErrors, setAutoFixErrors] = useState<Record<string, string>>({})
const [joiningProjectId, setJoiningProjectId] = useState<string | null>(null)
const [joinErrors, setJoinErrors] = useState<Record<string, string>>({})

const pageProjectId = activeBrokerTabProjectId || activeProjectId || undefined
const pageProjectName = pageProjectId ? getProjectName(projects, pageProjectId) : 'All projects'
Expand Down Expand Up @@ -1087,6 +1210,47 @@ export function BrokerDetailsPage(): React.ReactNode {
}
}, [loadBrokerDetails])

const handleJoinWorkspace = useCallback(async (project: Project, workspaceKey: string): Promise<void> => {
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(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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])
Expand Down Expand Up @@ -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]) => (
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/lib/ipc-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrokerSpawnAgentResult> => {
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,13 @@ export interface PearAPI {
broker: {
start: (projectId: string, cwd: string, name: string, channels?: string[]) => Promise<boolean>
syncChannels: (projectId: string, channels: string[]) => Promise<void>
joinWorkspace: (
projectId: string,
cwd: string,
name: string,
channels: string[] | undefined,
workspaceKey: string
) => Promise<boolean>
autoFixRuntime: (
projectId: string,
cwd: string,
Expand Down
Loading