diff --git a/web/src/components/plan/AgentSelector.tsx b/web/src/components/plan/AgentSelector.tsx index eb9537d2..82735a3b 100644 --- a/web/src/components/plan/AgentSelector.tsx +++ b/web/src/components/plan/AgentSelector.tsx @@ -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) @@ -20,8 +21,8 @@ export function AgentSelector() { const dropdownRef = useRef(null) useEffect(() => { - fetchAgents() - }, [fetchAgents]) + fetchAgents(currentWorkdir) + }, [fetchAgents, currentWorkdir]) // Close dropdown when clicking outside useClickOutside(dropdownRef, () => setIsOpen(false)) @@ -108,6 +109,7 @@ export function AgentSelector() { setEditId(null) }} initialEditId={editId} + projectDir={currentWorkdir} /> ) diff --git a/web/src/components/settings/AgentsModal.tsx b/web/src/components/settings/AgentsModal.tsx index 49034b10..87502c5e 100644 --- a/web/src/components/settings/AgentsModal.tsx +++ b/web/src/components/settings/AgentsModal.tsx @@ -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 { @@ -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) @@ -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) => { @@ -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) @@ -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) @@ -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) } @@ -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) @@ -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) @@ -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 () => { @@ -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) @@ -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 @@ -340,7 +344,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps setModelModalAgentId(null)} - onSaved={() => fetchAgents()} + onSaved={() => fetchAgents(projectDir)} /> ) @@ -416,7 +420,7 @@ export function AgentsModal({ isOpen, onClose, initialEditId }: AgentsModalProps setModelModalAgentId(null)} - onSaved={() => fetchAgents()} + onSaved={() => fetchAgents(projectDir)} /> ) diff --git a/web/src/stores/agents.test.ts b/web/src/stores/agents.test.ts new file mode 100644 index 00000000..49734971 --- /dev/null +++ b/web/src/stores/agents.test.ts @@ -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') + }) +}) diff --git a/web/src/stores/agents.ts b/web/src/stores/agents.ts index 5833bbc0..3507f325 100644 --- a/web/src/stores/agents.ts +++ b/web/src/stores/agents.ts @@ -37,20 +37,35 @@ interface AgentsState { projectItems: AgentInfo[] modelOverrides: Record loading: boolean - fetchAgents: () => Promise - fetchAgent: (agentId: string) => Promise + fetchAgents: (workdir?: string) => Promise + fetchAgent: (agentId: string, workdir?: string) => Promise fetchDefaultContent: (agentId: string) => Promise - createAgent: (agent: AgentFull, destination?: 'project' | 'user') => Promise<{ success: boolean; error?: string }> - updateAgent: (id: string, agent: Partial) => 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, + 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((set) => { - const fetchAgents = async () => { + const fetchAgents = async (workdir?: string) => { set({ loading: true } as Record) try { - const res = await authFetch('/api/agents') + const res = await authFetch(agentsUrl('/api/agents', workdir)) const data = await res.json() set({ defaults: data.defaults ?? [], @@ -73,9 +88,9 @@ export const useAgentsStore = create((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 { @@ -93,24 +108,28 @@ export const useAgentsStore = create((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) - if (result.success) await fetchAgents() + if (result.success) await fetchAgents(workdir) return result }, - updateAgent: async (id: string, agent: Partial) => { - const result = await saveEntity('PUT', `/api/agents/${id}`, agent as unknown as Record) - if (result.success) await fetchAgents() + updateAgent: async (id: string, agent: Partial, workdir?: string) => { + const result = await saveEntity( + 'PUT', + agentsUrl(`/api/agents/${id}`, workdir), + agent as unknown as Record, + ) + 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) => ({ @@ -125,8 +144,12 @@ export const useAgentsStore = create((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, + ) }, } })