diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index dadff5853..3fa391f0e 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -454,6 +454,65 @@ describe("buildStartConversationRequest", () => { expect(payload.agent.agent_context?.secrets).toBeUndefined(); }); + describe("project skills injection", () => { + const projectSkills = [ + { + name: "custom-codereview-guide", + content: "review carefully", + source: "/workspace/project/agent-canvas/.agents/skills/x.md", + description: "repo guide", + is_agentskills_format: false, + disable_model_invocation: false, + }, + ]; + + it("injects pre-loaded project skills into agent_context.skills for the OpenHands agent", () => { + // Project skills (.agents/skills/) are not auto-loaded by the + // AgentContext, so they must be threaded in as explicit skills. + const payload = buildStartConversationRequest({ + settings: DEFAULT_SETTINGS, + projectSkills, + }) as { agent: { agent_context?: Record } }; + + expect(payload.agent.agent_context).toEqual({ + load_public_skills: true, + load_user_skills: true, + skills: projectSkills, + }); + }); + + it("injects project skills into agent_context.skills for ACP agents too", () => { + // ``skills`` is acp_compatible in the SDK, so the ACP path gets the + // same repo skills as the OpenHands path. + const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + agent_kind: "acp", + acp_server: "claude-code", + acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], + }, + }, + projectSkills, + }) as { agent: { agent_context?: { skills?: unknown } } }; + + expect(payload.agent.agent_context?.skills).toEqual(projectSkills); + }); + + it("omits the skills key when there are no project skills", () => { + const payload = buildStartConversationRequest({ + settings: DEFAULT_SETTINGS, + projectSkills: [], + }) as { agent: { agent_context?: Record } }; + + expect(payload.agent.agent_context).toEqual({ + load_public_skills: true, + load_user_skills: true, + }); + }); + }); + describe("canvas_ui tool injection", () => { it("always registers canvas_ui_tool in tool_module_qualnames, even when no user settings supply qualnames", () => { const payload = buildStartConversationRequest({ @@ -647,8 +706,7 @@ describe("buildRuntimeServicesSystemSuffix", () => { url_from_agent: "http://localhost:18001", api_prefix: "/api/automation", docs_url: "http://localhost:18001/api/automation/docs", - openapi_url: - "http://localhost:18001/api/automation/openapi.json", + openapi_url: "http://localhost:18001/api/automation/openapi.json", auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", }, }, @@ -660,9 +718,7 @@ describe("buildRuntimeServicesSystemSuffix", () => { expect(suffix).toContain("dev:automation"); expect(suffix).toContain("http://localhost:18000"); expect(suffix).toContain("http://localhost:18001"); - expect(suffix).toContain( - "http://localhost:18001/api/automation/docs", - ); + expect(suffix).toContain("http://localhost:18001/api/automation/docs"); expect(suffix).toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY"); expect(suffix).toContain(""); // The "don't guess" line should reference the actual agent-server URL diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts index 14b460a3a..ea93cab3f 100644 --- a/__tests__/api/agent-server-conversation-service.test.ts +++ b/__tests__/api/agent-server-conversation-service.test.ts @@ -71,15 +71,21 @@ vi.mock("@openhands/typescript-client/clients", async () => { }; }); -vi.mock("@openhands/typescript-client/client/http-client", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - HttpClient: vi.fn(function HttpClientMock() { - return { post: mockSdkHttpPost }; - }), - }; -}); +vi.mock( + "@openhands/typescript-client/client/http-client", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@openhands/typescript-client/client/http-client") + >(); + return { + ...actual, + HttpClient: vi.fn(function HttpClientMock() { + return { post: mockSdkHttpPost }; + }), + }; + }, +); vi.mock("#/api/agent-server-config", () => ({ DEFAULT_WORKING_DIR: "workspace/project", @@ -101,6 +107,14 @@ vi.mock("#/api/settings-service/settings-service.api", () => ({ }, })); +const localBackend: Backend = { + id: "local", + name: "Local", + host: "http://localhost:54928", + apiKey: "test-api-key", + kind: "local", +}; + describe("AgentServerConversationService", () => { beforeEach(() => { vi.clearAllMocks(); @@ -291,6 +305,75 @@ describe("AgentServerConversationService", () => { `/state/workspaces/${secondHex}`, ); }); + + it("loads project skills from the workspace root, not the per-conversation working_dir", async () => { + // Regression guard: project skills (.agents/skills/) live at the + // workspace root. The conversation's working_dir is a per-conversation + // worktree subdir (/state/workspaces/) that has no .agents/skills/, + // so the /api/skills request must use getAgentServerWorkingDir(), not it. + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + mockGetSettings.mockResolvedValue({ + agent_settings: { llm: { model: "gpt-4o" } }, + conversation_settings: {}, + }); + mockGetSettingsForConversation.mockResolvedValue({ + agentSettings: { llm: { model: "gpt-4o" } }, + conversationSettings: {}, + secretsEncrypted: true, + }); + mockHttpPost.mockResolvedValue({ + data: { id: "x", created_at: "2024-01-01", updated_at: "2024-01-01" }, + }); + mockSdkHttpPost.mockResolvedValue({ data: { skills: [] } }); + + await AgentServerConversationService.createConversation(); + + const skillsCall = mockSdkHttpPost.mock.calls.find( + ([url]) => url === "/api/skills", + ); + expect(skillsCall?.[1]).toMatchObject({ + load_project: true, + load_public: false, + load_user: false, + project_dir: "/workspace/project/agent-canvas", + }); + }); + + it("loads project skills from an explicitly attached workspace override", async () => { + // When the user attaches a workspace, its path is the project root — + // skills must come from there, not the default workspace dir. + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + mockGetSettings.mockResolvedValue({ + agent_settings: { llm: { model: "gpt-4o" } }, + conversation_settings: {}, + }); + mockGetSettingsForConversation.mockResolvedValue({ + agentSettings: { llm: { model: "gpt-4o" } }, + conversationSettings: {}, + secretsEncrypted: true, + }); + mockHttpPost.mockResolvedValue({ + data: { id: "x", created_at: "2024-01-01", updated_at: "2024-01-01" }, + }); + mockSdkHttpPost.mockResolvedValue({ data: { skills: [] } }); + + await AgentServerConversationService.createConversation( + undefined, + undefined, + undefined, + null, + "/home/user/my-repo", + ); + + const skillsCall = mockSdkHttpPost.mock.calls.find( + ([url]) => url === "/api/skills", + ); + expect(skillsCall?.[1]).toMatchObject({ + project_dir: "/home/user/my-repo", + }); + }); }); describe("downloadConversation local branch", () => { diff --git a/__tests__/api/skills-service.test.ts b/__tests__/api/skills-service.test.ts index d778b577c..e446b918c 100644 --- a/__tests__/api/skills-service.test.ts +++ b/__tests__/api/skills-service.test.ts @@ -74,4 +74,130 @@ describe("SkillsService.getSkills against the agent-server backend", () => { }); expect(skills.map((s) => s.name)).toEqual(["alpha"]); }); + + it("forwards an explicit project_dir so conversation views can scope the catalog to their own workspace", async () => { + mockGetSkills.mockResolvedValue({ skills: [], sources: {} }); + + await SkillsService.getSkills("/home/user/my-repo"); + + expect(mockGetSkills.mock.calls[0]?.[0]).toMatchObject({ + load_public: true, + load_user: true, + load_project: true, + project_dir: "/home/user/my-repo", + }); + }); +}); + +describe("SkillsService.getProjectSkills", () => { + it("requests only project skills and rebuilds Skill payloads from SkillInfo", async () => { + // Two project skills: a repo skill (no triggers → always active) and a + // legacy knowledge skill (triggers → KeywordTrigger). + mockGetSkills.mockResolvedValue({ + skills: [ + { + name: "custom-codereview-guide", + type: "repo", + content: "review carefully", + triggers: [], + source: "/workspace/project/agent-canvas/.agents/skills/x.md", + description: "repo guide", + is_agentskills_format: false, + disable_model_invocation: false, + }, + { + name: "knowledge-skill", + type: "knowledge", + content: "knowledge body", + triggers: ["deploy", "release"], + source: "user", + is_agentskills_format: false, + }, + ], + sources: { sandbox: 0, sdk_base: 0, org: 0, project: 2 }, + }); + + const skills = await SkillsService.getProjectSkills( + "/workspace/project/agent-canvas", + ); + + // Only project skills are requested — public/user are already auto-loaded + // server-side via load_*_skills on the agent_context. The conversation's + // working dir is threaded through as project_dir. + expect(mockGetSkills.mock.calls[0]?.[0]).toMatchObject({ + load_public: false, + load_user: false, + load_project: true, + load_org: false, + project_dir: "/workspace/project/agent-canvas", + }); + + expect(skills).toEqual([ + { + name: "custom-codereview-guide", + content: "review carefully", + source: "/workspace/project/agent-canvas/.agents/skills/x.md", + description: "repo guide", + is_agentskills_format: false, + disable_model_invocation: false, + }, + { + name: "knowledge-skill", + content: "knowledge body", + source: "user", + description: null, + is_agentskills_format: false, + disable_model_invocation: false, + trigger: { type: "keyword", keywords: ["deploy", "release"] }, + }, + ]); + }); + + it("rebuilds a TaskTrigger for AgentSkills-format skills with triggers", async () => { + mockGetSkills.mockResolvedValue({ + skills: [ + { + name: "task-skill", + type: "agentskills", + content: "task body", + triggers: ["/task-skill"], + source: "project", + is_agentskills_format: true, + }, + ], + sources: { project: 1 }, + }); + + const [skill] = await SkillsService.getProjectSkills(); + + expect(skill.trigger).toEqual({ type: "task", triggers: ["/task-skill"] }); + expect(skill.is_agentskills_format).toBe(true); + }); + + it("returns [] for cloud backends without calling the skills client", async () => { + setRegisteredBackends([ + { + id: "cloud", + name: "Cloud", + host: "https://app", + apiKey: "", + kind: "cloud", + }, + ]); + setActiveSelection({ backendId: "cloud" }); + + const skills = await SkillsService.getProjectSkills(); + + expect(skills).toEqual([]); + expect(mockGetSkills).not.toHaveBeenCalled(); + }); + + it("returns [] when loading project skills fails so conversation start is not blocked", async () => { + mockGetSkills.mockRejectedValue(new Error("boom")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const skills = await SkillsService.getProjectSkills(); + + expect(skills).toEqual([]); + }); }); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index 5feb1d611..1ccacd6bf 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -614,7 +614,7 @@ function buildConfiguredAcpAgentSettings(settings: Settings): SettingsRecord { function createAgentFromSettings( agentSettings: SettingsRecord, - options: { acp?: boolean } = {}, + options: { acp?: boolean; projectSkills?: Record[] } = {}, ) { const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix(); // ``load_public_skills``, ``load_user_skills``, and @@ -646,6 +646,15 @@ function createAgentFromSettings( const agentContext: Record = { load_public_skills: true, load_user_skills: true, + // Project skills (``.agents/skills/`` in the workspace) are not + // auto-loaded by the AgentContext (it only auto-loads user/public + // skills, and agent-server 1.23.0 has no wire field to request project + // skills for a conversation). They are pre-loaded by the caller and + // injected here as explicit ``skills`` so repo skills reach the agent. + // ``skills`` is marked ``acp_compatible: true`` in the SDK, so this is + // valid on both the Agent and ACPAgent paths. ``_load_auto_skills`` + // dedupes auto-loaded user/public skills against these by name. + ...(options.projectSkills?.length ? { skills: options.projectSkills } : {}), // When the dev launcher provided ``VITE_RUNTIME_SERVICES_INFO``, // append a block to the system prompt so the // agent knows which services exist in this dev stack (e.g. @@ -754,6 +763,13 @@ export interface StartConversationOptions { * The actual values are fetched at runtime via LookupSecret. */ customSecrets?: Array<{ name: string; description?: string }>; + /** + * Project skills (from ``.agents/skills/`` in the workspace) to seed the + * agent context with, as SDK ``Skill`` wire objects. Pre-loaded by the + * caller because the fetch is async and this builder is synchronous; see + * ``buildStartConversationRequestWithEncryptedSettings``. + */ + projectSkills?: Record[]; } export function buildStartConversationRequest( @@ -768,7 +784,10 @@ export function buildStartConversationRequest( const agentSettings = acpMode ? buildConfiguredAcpAgentSettings(sourceAgentSettings) : buildConfiguredAgentSettings(sourceAgentSettings); - const agent = createAgentFromSettings(agentSettings, { acp: acpMode }); + const agent = createAgentFromSettings(agentSettings, { + acp: acpMode, + projectSkills: options.projectSkills, + }); const acpServerTag = acpMode ? getAcpServerTag(sourceAgentSettings) : undefined; @@ -931,14 +950,26 @@ export async function buildStartConversationRequestWithEncryptedSettings(options plugins?: PluginSpec[]; conversationId?: string; workingDir?: string; + /** + * Workspace root to load project skills (`.agents/skills/`) from. This is + * the workspace root, NOT `workingDir` — the latter is the per-conversation + * worktree subdir (`/`), which has no + * `.agents/skills/` and may not exist yet at request-build time. Defaults to + * the configured workspace dir inside `getProjectSkills`. + */ + skillsProjectDir?: string; }): Promise> { - // Import SecretsService dynamically to avoid circular dependencies + // Import services dynamically to avoid circular dependencies const { SecretsService } = await import("./secrets-service"); + const { default: SkillsService } = await import("./skills-service"); - // Fetch settings with encrypted secrets and custom secrets list in parallel - const [settingsResult, customSecrets] = await Promise.all([ + // Fetch settings, custom secrets, and project skills in parallel. + // Project skills are loaded here (an async call) so the synchronous + // buildStartConversationRequest can inject them into the agent context. + const [settingsResult, customSecrets, projectSkills] = await Promise.all([ SettingsService.getSettingsForConversation(), SecretsService.getSecrets(), + SkillsService.getProjectSkills(options.skillsProjectDir), ]); const { agentSettings, conversationSettings, secretsEncrypted } = @@ -950,6 +981,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options encryptedConversationSettings: conversationSettings, secretsEncrypted, customSecrets, + projectSkills, }); } diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index fe463c99f..406c957d8 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -332,6 +332,11 @@ class AgentServerConversationService { const conversationId = uuidv4(); const workingDir = workingDirOverride ?? buildConversationWorkingDir(conversationId); + // Project skills (.agents/skills/) live at the workspace ROOT, not in the + // per-conversation worktree subdir that `workingDir` points at by default. + // When the user explicitly attached a workspace, that override IS the root; + // otherwise fall back to the configured default workspace dir. + const skillsProjectDir = workingDirOverride ?? getAgentServerWorkingDir(); // Use encrypted settings to avoid exposing secrets in the browser const payload = await buildStartConversationRequestWithEncryptedSettings({ @@ -341,6 +346,7 @@ class AgentServerConversationService { plugins, conversationId, workingDir, + skillsProjectDir, }); const data = await new ConversationClient( diff --git a/src/api/skills-service.ts b/src/api/skills-service.ts index 0f91511c1..7588db700 100644 --- a/src/api/skills-service.ts +++ b/src/api/skills-service.ts @@ -5,8 +5,64 @@ import { getActiveBackend } from "./backend-registry/active-store"; import { fetchCloudSkills } from "./cloud/skills-service.api"; import { getAgentServerClientOptions } from "./agent-server-client-options"; +/** + * SDK ``Skill`` wire shape (a subset of the fields on + * ``openhands.sdk.skills.skill.Skill``). The agent-server accepts this under + * ``agent.agent_context.skills`` to seed a conversation with explicit skills. + * It is intentionally not the same as ``SkillInfo`` — ``SkillInfo`` is the + * flattened catalog shape returned by the skills endpoint. + */ +type SkillWire = { + name: string; + content: string; + source: string | null; + description: string | null; + is_agentskills_format: boolean; + disable_model_invocation: boolean; + trigger?: + | { type: "keyword"; keywords: string[] } + | { type: "task"; triggers: string[] }; +}; + +/** + * Reverse the agent-server's ``Skill.to_skill_info()``: rebuild a ``Skill`` + * payload from the flattened ``SkillInfo`` returned by the skills endpoint. + * + * ``SkillInfo`` collapses the trigger into a plain ``triggers`` string list, + * dropping the ``KeywordTrigger`` vs ``TaskTrigger`` distinction, so we + * reconstruct it the way the SDK's own loaders do: AgentSkills-format skills + * with triggers use a ``TaskTrigger``, legacy/knowledge skills use a + * ``KeywordTrigger``, and skills without triggers stay always-active + * (``trigger: None`` → injected as repo context). + */ +function skillInfoToSkill(info: SkillInfo): SkillWire { + const triggers = info.triggers ?? []; + const trigger = + triggers.length === 0 + ? undefined + : info.is_agentskills_format + ? ({ type: "task", triggers } as const) + : ({ type: "keyword", keywords: triggers } as const); + + return { + name: info.name, + content: info.content ?? "", + source: info.source ?? null, + description: info.description ?? null, + is_agentskills_format: info.is_agentskills_format ?? false, + disable_model_invocation: info.disable_model_invocation ?? false, + ...(trigger ? { trigger } : {}), + }; +} + class SkillsService { - static async getSkills(): Promise { + /** + * @param projectDir Workspace root to load project skills from. Defaults to + * the configured global workspace dir. Conversation-scoped callers pass the + * conversation's own workspace so the catalog (and the slash-command menu) + * matches the project skills actually loaded into that conversation. + */ + static async getSkills(projectDir?: string): Promise { if (getActiveBackend().backend.kind === "cloud") { return fetchCloudSkills(); } @@ -22,11 +78,51 @@ class SkillsService { load_user: true, load_project: true, load_org: false, - project_dir: getAgentServerWorkingDir(), + project_dir: projectDir ?? getAgentServerWorkingDir(), }); return (response.skills ?? []) as SkillInfo[]; } + + /** + * Load project skills (``.agents/skills/`` in the workspace) as ``Skill`` + * wire objects to inject into a new conversation's ``agent_context``. + * + * Project skills are visible on the Skills settings page but are *not* + * auto-loaded into conversations: an ``AgentContext`` only auto-loads user + * and public skills (``load_user_skills`` / ``load_public_skills``), and + * agent-server 1.23.0 exposes no wire field to request project skills for a + * conversation (``AgentContext._load_auto_skills`` hardcodes + * ``include_project=False``). Loading them here and seeding + * ``agent_context.skills`` is the only way to honor repo skills against the + * pinned SDK — see ``createAgentFromSettings``. + * + * Returns ``[]`` for cloud backends (project skills don't apply there) and + * on any load failure, so a skills hiccup never blocks starting a + * conversation. + */ + static async getProjectSkills(projectDir?: string): Promise { + if (getActiveBackend().backend.kind === "cloud") { + return []; + } + + try { + const response = await new SkillsClient( + getAgentServerClientOptions(), + ).getSkills({ + load_public: false, + load_user: false, + load_project: true, + load_org: false, + project_dir: projectDir ?? getAgentServerWorkingDir(), + }); + + return ((response.skills ?? []) as SkillInfo[]).map(skillInfoToSkill); + } catch (error) { + console.warn("Failed to load project skills for conversation:", error); + return []; + } + } } export default SkillsService; diff --git a/src/components/features/conversation-panel/skills-modal.tsx b/src/components/features/conversation-panel/skills-modal.tsx index b0f3b92b0..6d2a74c09 100644 --- a/src/components/features/conversation-panel/skills-modal.tsx +++ b/src/components/features/conversation-panel/skills-modal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { I18nKey } from "#/i18n/declaration"; -import { useSkills } from "#/hooks/query/use-skills"; +import { useConversationSkills } from "#/hooks/query/use-conversation-skills"; import { AgentState } from "#/types/agent-state"; import { Typography } from "#/ui/typography"; import { SkillsModalHeader } from "./skills-modal-header"; @@ -22,13 +22,15 @@ export function SkillsModal({ onClose }: SkillsModalProps) { const [expandedAgents, setExpandedAgents] = useState>( {}, ); + // Scope the catalog to this conversation's attached workspace so the listed + // skills match the project skills actually loaded into the conversation. const { data: skills, isLoading, isError, refetch, isRefetching, - } = useSkills(); + } = useConversationSkills(); const toggleAgent = (agentName: string) => { setExpandedAgents((prev) => ({ diff --git a/src/hooks/chat/use-slash-command.ts b/src/hooks/chat/use-slash-command.ts index a072cacdd..a787b6d87 100644 --- a/src/hooks/chat/use-slash-command.ts +++ b/src/hooks/chat/use-slash-command.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { useSkills } from "#/hooks/query/use-skills"; +import { useConversationSkills } from "#/hooks/query/use-conversation-skills"; import { SkillInfo } from "#/types/settings"; import { Microagent } from "#/api/open-hands.types"; import { BUILT_IN_COMMANDS, MODEL_COMMAND } from "#/utils/constants"; @@ -35,7 +35,9 @@ function getCursorOffset(element: HTMLElement): number { export const useSlashCommand = ( chatInputRef: React.RefObject, ) => { - const { data: skills, isLoading: isSkillsLoading } = useSkills(); + // Scope the skill catalog to this conversation's attached workspace so the + // slash menu lists the same project skills that were loaded into it. + const { data: skills, isLoading: isSkillsLoading } = useConversationSkills(); const isCloud = useActiveBackend().backend.kind === "cloud"; const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles({ enabled: !isCloud, diff --git a/src/hooks/query/use-conversation-skills.ts b/src/hooks/query/use-conversation-skills.ts new file mode 100644 index 000000000..609e1db45 --- /dev/null +++ b/src/hooks/query/use-conversation-skills.ts @@ -0,0 +1,13 @@ +import { useActiveConversation } from "./use-active-conversation"; +import { useSkills } from "./use-skills"; + +/** + * Skills catalog scoped to the active conversation's attached workspace, so + * the slash-command menu and skills modal list the same project skills that + * were loaded into the conversation. Falls back to the global workspace dir + * for "No workspace" conversations (``selected_workspace`` is null). + */ +export const useConversationSkills = () => { + const conversation = useActiveConversation(); + return useSkills(conversation.data?.selected_workspace ?? undefined); +}; diff --git a/src/hooks/query/use-skills.ts b/src/hooks/query/use-skills.ts index af8ba5f4f..95d634d7e 100644 --- a/src/hooks/query/use-skills.ts +++ b/src/hooks/query/use-skills.ts @@ -2,10 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import SkillsService from "#/api/skills-service"; import { SkillInfo } from "#/types/settings"; -export const useSkills = () => +/** + * @param projectDir Workspace root to load project skills from. Conversation + * views pass the conversation's own workspace so the catalog matches the + * skills loaded into that conversation; the global Skills page omits it. + */ +export const useSkills = (projectDir?: string) => useQuery({ - queryKey: ["skills"], - queryFn: SkillsService.getSkills, + queryKey: ["skills", projectDir ?? null], + queryFn: () => SkillsService.getSkills(projectDir), staleTime: 1000 * 60 * 10, // 10 minutes – skill list rarely changes refetchOnWindowFocus: false, });