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
6 changes: 4 additions & 2 deletions web/src/components/plan/AgentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useClickOutside } from '../../hooks/useClickOutside'
import { formatKeybinding } from '../../lib/keybindings'
export function AgentSelector() {
const currentMode = useSessionStore((state) => state.currentSession?.mode)
const currentWorkdir = useSessionStore((state) => state.currentSession?.workdir)
const switchMode = useSessionStore((state) => state.switchMode)
const defaults = useAgentsStore((state) => state.defaults)
const userItems = useAgentsStore((state) => state.userItems)
Expand All @@ -20,8 +21,8 @@ export function AgentSelector() {
const dropdownRef = useRef<HTMLDivElement>(null)

useEffect(() => {
fetchAgents()
}, [fetchAgents])
fetchAgents(currentWorkdir)
}, [fetchAgents, currentWorkdir])

// Close dropdown when clicking outside
useClickOutside(dropdownRef, () => setIsOpen(false))
Expand Down Expand Up @@ -108,6 +109,7 @@ export function AgentSelector() {
setEditId(null)
}}
initialEditId={editId}
projectDir={currentWorkdir}
/>
</div>
)
Expand Down
28 changes: 16 additions & 12 deletions web/src/components/settings/AgentsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface AgentsModalProps {
isOpen: boolean
onClose: () => void
initialEditId?: string | null
/** Project root workdir this modal was opened from — scopes project agents shown and saved. */
projectDir?: string
}

function toSlug(name: string): string {
Expand All @@ -23,7 +25,7 @@ function toSlug(name: string): string {
return slug ? `custom-${slug}` : ''
}

export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps) {
export function AgentsModal({ isOpen, onClose, initialEditId, projectDir }: AgentsModalProps) {
const defaults = useAgentsStore((state) => state.defaults)
const userItems = useAgentsStore((state) => state.userItems)
const projectItems = useAgentsStore((state) => state.projectItems)
Expand Down Expand Up @@ -109,7 +111,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps

useEffect(() => {
if (isOpen) {
fetchAgents()
fetchAgents(projectDir)
authFetch('/api/tools')
.then((r) => r.json())
.then((d) => {
Expand All @@ -131,7 +133,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
applyDuplicateFromContent(content, initialEditId, true)
})
} else {
fetchAgent(initialEditId).then((agent) => {
fetchAgent(initialEditId, projectDir).then((agent) => {
if (!agent) return
populateFormFromAgent(agent)
setEditingId(initialEditId)
Expand All @@ -145,7 +147,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
setIsReadOnly(false)
}
}
}, [isOpen, fetchAgents, fetchAgent, fetchDefaultContent, initialEditId])
}, [isOpen, fetchAgents, fetchAgent, fetchDefaultContent, initialEditId, projectDir])

const handleView = async (agentId: string) => {
const isDefault = defaults.some((d) => d.id === agentId)
Expand All @@ -154,7 +156,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
if (!content) return
applyViewFromContent(content, agentId)
} else {
const agent = await fetchAgent(agentId)
const agent = await fetchAgent(agentId, projectDir)
if (!agent) return
applyViewFromContent(agent, agentId)
}
Expand All @@ -163,7 +165,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
const handleDuplicate = async (agentId: string) => {
let content = await fetchDefaultContent(agentId)
if (!content) {
content = await fetchAgent(agentId)
content = await fetchAgent(agentId, projectDir)
}
if (!content) return
applyDuplicateFromContent(content, agentId, true)
Expand All @@ -185,7 +187,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
}

const handleEdit = async (agentId: string) => {
const agent = await fetchAgent(agentId)
const agent = await fetchAgent(agentId, projectDir)
if (!agent) return
populateFormFromAgent(agent)
setEditingId(agentId)
Expand All @@ -198,7 +200,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
}

const handleDelete = async (agentId: string) => {
await deleteAgentAction(agentId)
await deleteAgentAction(agentId, projectDir)
}

const handleSave = async () => {
Expand All @@ -223,7 +225,9 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
prompt: formPrompt,
}

const result = editingId ? await updateAgent(editingId, agent) : await createAgent(agent, formDestination)
const result = editingId
? await updateAgent(editingId, agent, projectDir)
: await createAgent(agent, formDestination, projectDir)

if (!result.success) {
setSaving(false)
Expand All @@ -235,7 +239,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
await saveAgentModelOverride(editingId ?? formId, formModel)

// Re-fetch agents so the list reflects the updated model override badge
await fetchAgents()
await fetchAgents(projectDir)

// Propagate to current session if this agent is active
const agentId = editingId ?? formId
Expand Down Expand Up @@ -340,7 +344,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
<BuiltInModelModal
agentId={modelModalAgentId}
onClose={() => setModelModalAgentId(null)}
onSaved={() => fetchAgents()}
onSaved={() => fetchAgents(projectDir)}
/>
</>
)
Expand Down Expand Up @@ -416,7 +420,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps
<BuiltInModelModal
agentId={modelModalAgentId}
onClose={() => setModelModalAgentId(null)}
onSaved={() => fetchAgents()}
onSaved={() => fetchAgents(projectDir)}
/>
</>
)
Expand Down
69 changes: 69 additions & 0 deletions web/src/stores/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { authFetch } from '../lib/api'
import { useAgentsStore, type AgentFull } from './agents'

vi.mock('../lib/api', () => ({
authFetch: vi.fn(),
}))

const agent: AgentFull = {
metadata: {
id: 'custom-reviewer',
name: 'Reviewer',
description: 'Reviews changes',
subagent: true,
allowedTools: ['read_file'],
},
prompt: 'Review the proposed changes.',
}

function jsonResponse(data: unknown = {}): Response {
return {
ok: true,
json: () => Promise.resolve(data),
} as Response
}

describe('AgentsStore project scoping', () => {
beforeEach(() => {
vi.clearAllMocks()
useAgentsStore.setState({
defaults: [],
userItems: [],
projectItems: [],
modelOverrides: {},
loading: false,
})
vi.mocked(authFetch).mockResolvedValue(jsonResponse())
})

it('sends the project workdir when creating an agent and refreshing the list', async () => {
await useAgentsStore.getState().createAgent(agent, 'project', '/projects/client app')

expect(authFetch).toHaveBeenNthCalledWith(
1,
'/api/agents?workdir=%2Fprojects%2Fclient%20app',
expect.objectContaining({ method: 'POST' }),
)
expect(authFetch).toHaveBeenNthCalledWith(2, '/api/agents?workdir=%2Fprojects%2Fclient%20app')
})

it('sends the project workdir when updating an agent and refreshing the list', async () => {
await useAgentsStore.getState().updateAgent(agent.metadata.id, agent, 'C:\\projects\\client')

expect(authFetch).toHaveBeenNthCalledWith(
1,
'/api/agents/custom-reviewer?workdir=C%3A%5Cprojects%5Cclient',
expect.objectContaining({ method: 'PUT' }),
)
expect(authFetch).toHaveBeenNthCalledWith(2, '/api/agents?workdir=C%3A%5Cprojects%5Cclient')
})

it('keeps global requests unchanged when no project workdir is available', async () => {
await useAgentsStore.getState().createAgent(agent, 'user')

expect(authFetch).toHaveBeenNthCalledWith(1, '/api/agents', expect.objectContaining({ method: 'POST' }))
expect(authFetch).toHaveBeenNthCalledWith(2, '/api/agents')
})
})
63 changes: 43 additions & 20 deletions web/src/stores/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,35 @@ interface AgentsState {
projectItems: AgentInfo[]
modelOverrides: Record<string, string>
loading: boolean
fetchAgents: () => Promise<void>
fetchAgent: (agentId: string) => Promise<AgentFull | null>
fetchAgents: (workdir?: string) => Promise<void>
fetchAgent: (agentId: string, workdir?: string) => Promise<AgentFull | null>
fetchDefaultContent: (agentId: string) => Promise<AgentFull | null>
createAgent: (agent: AgentFull, destination?: 'project' | 'user') => Promise<{ success: boolean; error?: string }>
updateAgent: (id: string, agent: Partial<AgentFull>) => Promise<{ success: boolean; error?: string }>
deleteAgent: (agentId: string) => Promise<{ success: boolean; error?: string; reason?: string }>
duplicateAgent: (agentId: string, destination?: 'project' | 'user') => Promise<{ success: boolean; error?: string }>
createAgent: (
agent: AgentFull,
destination?: 'project' | 'user',
workdir?: string,
) => Promise<{ success: boolean; error?: string }>
updateAgent: (
id: string,
agent: Partial<AgentFull>,
workdir?: string,
) => Promise<{ success: boolean; error?: string }>
deleteAgent: (agentId: string, workdir?: string) => Promise<{ success: boolean; error?: string; reason?: string }>
duplicateAgent: (
agentId: string,
destination?: 'project' | 'user',
workdir?: string,
) => Promise<{ success: boolean; error?: string }>
}

const agentsUrl = (path: string, workdir?: string): string =>
workdir ? `${path}?workdir=${encodeURIComponent(workdir)}` : path

export const useAgentsStore = create<AgentsState>((set) => {
const fetchAgents = async () => {
const fetchAgents = async (workdir?: string) => {
set({ loading: true } as Record<string, unknown>)
try {
const res = await authFetch('/api/agents')
const res = await authFetch(agentsUrl('/api/agents', workdir))
const data = await res.json()
set({
defaults: data.defaults ?? [],
Expand All @@ -73,9 +88,9 @@ export const useAgentsStore = create<AgentsState>((set) => {

fetchAgents,

fetchAgent: async (agentId: string) => {
fetchAgent: async (agentId: string, workdir?: string) => {
try {
const res = await authFetch(`/api/agents/${agentId}`)
const res = await authFetch(agentsUrl(`/api/agents/${agentId}`, workdir))
if (!res.ok) return null
return (await res.json()) as AgentFull
} catch {
Expand All @@ -93,24 +108,28 @@ export const useAgentsStore = create<AgentsState>((set) => {
}
},

createAgent: async (agent: AgentFull, destination?: 'project' | 'user') => {
const result = await saveEntity('POST', '/api/agents', {
createAgent: async (agent: AgentFull, destination?: 'project' | 'user', workdir?: string) => {
const result = await saveEntity('POST', agentsUrl('/api/agents', workdir), {
...agent,
destination,
} as unknown as Record<string, unknown>)
if (result.success) await fetchAgents()
if (result.success) await fetchAgents(workdir)
return result
},

updateAgent: async (id: string, agent: Partial<AgentFull>) => {
const result = await saveEntity('PUT', `/api/agents/${id}`, agent as unknown as Record<string, unknown>)
if (result.success) await fetchAgents()
updateAgent: async (id: string, agent: Partial<AgentFull>, workdir?: string) => {
const result = await saveEntity(
'PUT',
agentsUrl(`/api/agents/${id}`, workdir),
agent as unknown as Record<string, unknown>,
)
if (result.success) await fetchAgents(workdir)
return result
},

deleteAgent: async (agentId: string) => {
deleteAgent: async (agentId: string, workdir?: string) => {
try {
const res = await authFetch(`/api/agents/${agentId}`, { method: 'DELETE' })
const res = await authFetch(agentsUrl(`/api/agents/${agentId}`, workdir), { method: 'DELETE' })
const data = await res.json()
if (res.ok) {
set((state) => ({
Expand All @@ -125,8 +144,12 @@ export const useAgentsStore = create<AgentsState>((set) => {
}
},

duplicateAgent: async (agentId: string, destination?: 'project' | 'user') => {
return duplicateEntity(`/api/agents/${agentId}/duplicate`, fetchAgents, destination)
duplicateAgent: async (agentId: string, destination?: 'project' | 'user', workdir?: string) => {
return duplicateEntity(
agentsUrl(`/api/agents/${agentId}/duplicate`, workdir),
() => fetchAgents(workdir),
destination,
)
},
}
})
Loading