Skip to content
Open
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
114 changes: 114 additions & 0 deletions src/components/settings/agents/add-custom-agent-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import '@testing-library/jest-dom'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, mock } from 'bun:test'
import type { Agent } from '@/types/acp'
import {
AddCustomAgentDialog,
inferTransport,
Expand Down Expand Up @@ -295,3 +296,116 @@ describe('AddCustomAgentDialog — connection status', () => {
expect(screen.queryByText(/connection successful/i)).not.toBeInTheDocument()
})
})

describe('AddCustomAgentDialog — edit mode', () => {
const notIos = () => false

const existingAgent: Agent = {
id: 'custom-1',
name: 'Existing Agent',
type: 'remote-acp',
transport: 'websocket',
url: 'wss://existing.example/ws',
description: 'Existing description',
icon: null,
isSystem: 0,
enabled: 1,
deletedAt: null,
userId: 'user-42',
}

it('renders the Edit title and Save Changes button when editingAgent is set', () => {
const onSubmit = mock(async () => {})
render(
<AddCustomAgentDialog
open={true}
onOpenChange={() => {}}
onSubmit={onSubmit}
editingAgent={existingAgent}
isIos={notIos}
testAcpConnection={async () => ({ success: true })}
/>,
)

expect(screen.getByText(/edit custom agent/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument()
// Add Agent label must not appear in edit mode.
expect(screen.queryByRole('button', { name: /^add agent$/i })).not.toBeInTheDocument()
})

it('seeds the form with the existing agent values', () => {
render(
<AddCustomAgentDialog
open={true}
onOpenChange={() => {}}
onSubmit={async () => {}}
editingAgent={existingAgent}
isIos={notIos}
testAcpConnection={async () => ({ success: true })}
/>,
)

expect(screen.getByLabelText(/name/i)).toHaveValue('Existing Agent')
expect(screen.getByLabelText(/url/i)).toHaveValue('wss://existing.example/ws')
expect(screen.getByLabelText(/description/i)).toHaveValue('Existing description')
})

it('keeps Save Changes gated until the seeded URL is re-tested', async () => {
render(
<AddCustomAgentDialog
open={true}
onOpenChange={() => {}}
onSubmit={async () => {}}
editingAgent={existingAgent}
isIos={notIos}
testAcpConnection={async () => ({ success: true })}
/>,
)

const save = screen.getByRole('button', { name: /save changes/i })
// Form is prefilled but connection has not been re-verified yet.
expect(save).toBeDisabled()

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
})

expect(save).not.toBeDisabled()
})

it('invokes onSubmit with the edited values after a successful test', async () => {
const onSubmit = mock(async (_: AddCustomAgentPayload) => {})
const onOpenChange = mock(() => {})
render(
<AddCustomAgentDialog
open={true}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
editingAgent={existingAgent}
isIos={notIos}
testAcpConnection={async () => ({ success: true })}
/>,
)

fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Renamed Agent' } })
fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'wss://new.example/ws' } })
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: '' } })

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))
})

expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith({
name: 'Renamed Agent',
url: 'wss://new.example/ws',
// Empty description is normalized to null, matching the create path.
description: null,
transport: 'websocket',
})
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
54 changes: 41 additions & 13 deletions src/components/settings/agents/add-custom-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Dialog } from '@/components/ui/dialog'
import { StatusCard } from '@/components/ui/status-card'
import { getPlatform, isTauri } from '@/lib/platform'
import { testAcpConnection as defaultTestAcpConnection } from '@/acp'
import type { Agent } from '@/types/acp'

/** Maps a user-entered URL to the ACP transport flavor we support, or `null`
* when the scheme is unsupported (or the URL is malformed). WebSocket is the
Expand Down Expand Up @@ -72,6 +73,11 @@ type AddCustomAgentDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: AddCustomAgentPayload) => Promise<void> | void
/** When provided, the dialog renders in edit mode: title and submit label
* switch, and initial state is seeded from this agent. Pass `null`/omit for
* the create flow. The parent should also vary the dialog's React `key` on
* the agent id so switching between agents resets the reducer cleanly. */
editingAgent?: Agent | null
/** Test/DI override for the iOS guard. Production callers omit this. */
isIos?: () => boolean
/** Test/DI override for the connection probe. Production callers omit this. */
Expand All @@ -97,9 +103,9 @@ type AgentDialogAction =
| { type: 'START_CONNECTION_TEST' }
| { type: 'CONNECTION_TEST_SUCCESS' }
| { type: 'CONNECTION_TEST_FAILURE'; error: string }
| { type: 'RESET' }
| { type: 'RESET'; next: AgentDialogState }

const initialState: AgentDialogState = {
const emptyState: AgentDialogState = {
name: '',
url: '',
description: '',
Expand All @@ -109,13 +115,26 @@ const initialState: AgentDialogState = {
connectionError: null,
}

/** Builds the initial reducer state. With an agent, the form is seeded with its
* current values (a connection test is still required before save). Without,
* the form starts blank for the create flow. */
const buildInitialState = (agent: Agent | null): AgentDialogState =>
agent
? {
...emptyState,
name: agent.name,
url: agent.url ?? '',
description: agent.description ?? '',
}
: emptyState

const agentDialogReducer = (state: AgentDialogState, action: AgentDialogAction): AgentDialogState => {
switch (action.type) {
case 'SET_NAME':
return { ...state, name: action.value }
case 'SET_URL':
// Editing the URL invalidates any prior connection result — the user is
// targeting a (potentially) different endpoint, so Add must be re-gated.
// targeting a (potentially) different endpoint, so submit must be re-gated.
return { ...state, url: action.value, connectionStatus: 'idle', connectionError: null }
case 'SET_DESCRIPTION':
return { ...state, description: action.value }
Expand All @@ -130,7 +149,7 @@ const agentDialogReducer = (state: AgentDialogState, action: AgentDialogAction):
case 'CONNECTION_TEST_FAILURE':
return { ...state, isTestingConnection: false, connectionStatus: 'error', connectionError: action.error }
case 'RESET':
return initialState
return action.next
default:
return state
}
Expand All @@ -140,28 +159,35 @@ export const AddCustomAgentDialog = ({
open,
onOpenChange,
onSubmit,
editingAgent,
isIos,
testAcpConnection = defaultTestAcpConnection,
}: AddCustomAgentDialogProps) => {
const [state, dispatch] = useReducer(agentDialogReducer, initialState)
const isEditing = !!editingAgent
// Lazy init seeds the form from the agent on first mount. The parent varies
// the React `key` on agent id to remount when switching between editing
// targets, so this initializer fires fresh each time.
const [state, dispatch] = useReducer(agentDialogReducer, editingAgent ?? null, buildInitialState)

const trimmedName = state.name.trim()
const trimmedUrl = state.url.trim()
const trimmedDescription = state.description.trim()
const validation = validateAgentUrl(trimmedUrl, isIos)
// Surface an invalid-URL error at render time (once the field is non-empty)
// so the user sees why Test Connection is unavailable and Add stays gated.
// so the user sees why Test Connection is unavailable and submit stays gated.
const urlError = trimmedUrl.length > 0 && 'error' in validation ? validation.error : null
// Add is gated behind a successful Test Connection — a valid name, URL, and a
// confirmed connection are all required before the agent can be created.
// Submit is gated behind a successful Test Connection — a valid name, URL,
// and a confirmed connection are all required before saving.
const canSubmit =
trimmedName.length > 0 && trimmedUrl.length > 0 && state.connectionStatus === 'success' && !state.submitting
// The probe is only meaningful once the URL is a valid WebSocket endpoint.
const canTestConnection = trimmedUrl.length > 0 && !urlError

const handleOpenChange = (next: boolean) => {
if (!next) {
dispatch({ type: 'RESET' })
// On close, reset back to the seeded state (empty for create, the agent's
// values for edit) so a reopen without remount lands in a predictable shape.
dispatch({ type: 'RESET', next: buildInitialState(editingAgent ?? null) })
}
onOpenChange(next)
}
Expand Down Expand Up @@ -190,17 +216,19 @@ export const AddCustomAgentDialog = ({
transport: validation.transport,
})
dispatch({ type: 'END_SUBMIT' })
dispatch({ type: 'RESET' })
dispatch({ type: 'RESET', next: buildInitialState(editingAgent ?? null) })
onOpenChange(false)
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<ResponsiveModalContentComposable className="sm:max-w-[500px]">
<ResponsiveModalHeader>
<ResponsiveModalTitle>Add Custom Agent</ResponsiveModalTitle>
<ResponsiveModalTitle>{isEditing ? 'Edit Custom Agent' : 'Add Custom Agent'}</ResponsiveModalTitle>
<ResponsiveModalDescription>
Connect a remote agent that speaks the Agent Client Protocol.
{isEditing
? 'Update the connection details for this remote agent.'
: 'Connect a remote agent that speaks the Agent Client Protocol.'}
</ResponsiveModalDescription>
</ResponsiveModalHeader>
<div className="grid gap-4 pt-4 pb-2">
Expand Down Expand Up @@ -290,7 +318,7 @@ export const AddCustomAgentDialog = ({
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
Add Agent
{isEditing ? 'Save Changes' : 'Add Agent'}
</Button>
</div>
</ResponsiveModalContentComposable>
Expand Down
Loading
Loading