diff --git a/apps/portal/src/components/SkillPicker.stories.tsx b/apps/portal/src/components/SkillPicker.stories.tsx new file mode 100644 index 00000000..c42a44bd --- /dev/null +++ b/apps/portal/src/components/SkillPicker.stories.tsx @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { http, HttpResponse } from "msw"; +import type { SkillDocument } from "@/types"; +import { getSelectedProjectId, setSelectedProjectIdHolder } from "@/lib/project-scope"; +import { SkillPicker } from "./SkillPicker"; + +const skills = Array.from({ length: 13 }, (_, index) => ({ + _id: `azure/cosmos-kit/cosmos-${String(index + 1).padStart(2, "0")}`, + name: `Cosmos DB skill ${index + 1}`, + description: "Azure Cosmos DB agent guidance", +})) as SkillDocument[]; + +function SkillPickerHarness() { + const [selected, setSelected] = useState([]); + const [scopeReady, setScopeReady] = useState(false); + + useEffect(() => { + const previousProjectId = getSelectedProjectId(); + setSelectedProjectIdHolder("demo-project"); + setScopeReady(true); + return () => setSelectedProjectIdHolder(previousProjectId); + }, []); + + if (!scopeReady) return null; + + return ( +
+ +
+ ); +} + +const meta = { + component: SkillPicker, + render: () => , + args: { + selected: [], + onChange: () => {}, + }, + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/skills", () => HttpResponse.json(skills)), + http.get("*/api/v1/skills/search/external", () => HttpResponse.json([])), + http.get("*/api/v1/skills/*/revisions", () => HttpResponse.json([])), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MultiSelectSearch: Story = { + play: async ({ canvas, canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const input = await canvas.findByPlaceholderText("Search for skills…"); + await userEvent.type(input, "cosmos"); + await userEvent.click(await page.findByRole("button", { name: /cosmos-01/i })); + + await expect(input).toHaveValue("cosmos"); + await expect(page.getByRole("button", { name: /cosmos-02/i })).toBeVisible(); + }, +}; diff --git a/apps/portal/src/components/SkillPicker.test.tsx b/apps/portal/src/components/SkillPicker.test.tsx new file mode 100644 index 00000000..f3adf3fd --- /dev/null +++ b/apps/portal/src/components/SkillPicker.test.tsx @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { SkillDocument } from "@/types"; + +const skills = [ + { + _id: "azure/cosmos-kit/cosmos-query", + name: "Cosmos query", + description: "Query Azure Cosmos DB", + }, + { + _id: "azure/cosmos-kit/cosmos-index", + name: "Cosmos index", + description: "Tune Azure Cosmos DB indexes", + }, +] as SkillDocument[]; + +const listSkills = vi.fn(async () => skills); +const searchExternalSkills = vi.fn(async (_query: string, _limit?: number) => []); +const listSkillRevisions = vi.fn(async (_slug: string) => []); + +vi.mock("@/lib/api", () => ({ + api: { + listSkills: () => listSkills(), + searchExternalSkills: (query: string, limit?: number) => searchExternalSkills(query, limit), + listSkillRevisions: (slug: string) => listSkillRevisions(slug), + }, +})); + +import { SkillPicker } from "./SkillPicker"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function PickerHarness() { + const [selected, setSelected] = useState([]); + return ; +} + +function renderPicker() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("SkillPicker", () => { + it("keeps the search and matching results open while selecting several skills", async () => { + renderPicker(); + const input = await screen.findByPlaceholderText("Search for skills…"); + + fireEvent.change(input, { target: { value: "cosmos" } }); + fireEvent.click(await screen.findByRole("button", { name: /cosmos-query/i })); + + expect((input as HTMLInputElement).value).toBe("cosmos"); + expect(screen.getByRole("button", { name: /cosmos-index/i })).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: /cosmos-index/i })); + expect((input as HTMLInputElement).value).toBe("cosmos"); + expect(screen.getAllByText("azure/cosmos-kit/cosmos-query").length).toBeGreaterThan(1); + expect(screen.getAllByText("azure/cosmos-kit/cosmos-index").length).toBeGreaterThan(1); + }); + + it("opens the scrollable result list above an input near the viewport bottom", async () => { + const { container } = renderPicker(); + const input = await screen.findByPlaceholderText("Search for skills…"); + vi.spyOn(input, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 440, + top: 440, + right: 400, + bottom: 476, + left: 0, + width: 400, + height: 36, + toJSON: () => ({}), + }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 }); + + fireEvent.focus(input); + + const result = await screen.findByRole("button", { name: /cosmos-query/i }); + await waitFor(() => { + const dropdown = document.querySelector("[data-skill-picker-portal]") as HTMLElement | null; + expect(dropdown?.parentElement).toBe(document.body); + expect(container.contains(dropdown)).toBe(false); + expect(dropdown?.style.position).toBe("fixed"); + expect(dropdown?.style.bottom).toBe("64px"); + expect(dropdown?.style.top).toBe(""); + expect(result.parentElement?.style.maxHeight).toBe("256px"); + }); + }); + + it("constrains the portaled result list to the space below the input", async () => { + renderPicker(); + const input = await screen.findByPlaceholderText("Search for skills…"); + vi.spyOn(input, "getBoundingClientRect").mockReturnValue({ + x: 20, + y: 8, + top: 8, + right: 420, + bottom: 44, + left: 20, + width: 400, + height: 36, + toJSON: () => ({}), + }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 180 }); + + fireEvent.focus(input); + + const result = await screen.findByRole("button", { name: /cosmos-query/i }); + await waitFor(() => { + const dropdown = document.querySelector("[data-skill-picker-portal]") as HTMLElement | null; + expect(dropdown?.style.top).toBe("48px"); + expect(dropdown?.style.bottom).toBe(""); + expect(result.parentElement?.style.maxHeight).toBe("128px"); + }); + }); +}); diff --git a/apps/portal/src/components/SkillPicker.tsx b/apps/portal/src/components/SkillPicker.tsx index 9d9a232f..af560467 100644 --- a/apps/portal/src/components/SkillPicker.tsx +++ b/apps/portal/src/components/SkillPicker.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; import { Badge } from "@/components/ui/badge"; @@ -77,18 +78,35 @@ interface SkillPickerProps { disabled?: boolean; } +type DropdownLayout = { + side: "above" | "below"; + left: number; + width: number; + top?: number; + bottom?: number; + maxHeight: number; +}; + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function SkillPicker({ selected, onChange, importOnly = false, disabled = false }: SkillPickerProps) { const queryClient = useQueryClient(); const containerRef = useRef(null); + const dropdownRef = useRef(null); const inputRef = useRef(null); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [highlightIdx, setHighlightIdx] = useState(0); const [manualOpen, setManualOpen] = useState(false); + const [dropdownLayout, setDropdownLayout] = useState({ + side: "below", + left: 0, + width: 0, + top: 0, + maxHeight: 256, + }); const debouncedQuery = useDebounce(query, 300); @@ -159,7 +177,12 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled = // ─── Outside click ────────────────────────────────────────────────── useEffect(() => { const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + containerRef.current && + !containerRef.current.contains(target) && + !dropdownRef.current?.contains(target) + ) { setOpen(false); } }; @@ -167,6 +190,63 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled = return () => document.removeEventListener("mousedown", handler); }, []); + // Keep the result list within the viewport. The fixed, portaled layer avoids + // clipping by scrollable profile forms and dialogs. + const updateDropdownLayout = useCallback(() => { + const rect = inputRef.current?.getBoundingClientRect(); + if (!rect) return; + + const viewportGap = 8; + const preferredHeight = 256; + const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - viewportGap); + const spaceAbove = Math.max(0, rect.top - viewportGap); + const side = spaceBelow >= Math.min(preferredHeight, spaceAbove) ? "below" : "above"; + const availableSpace = side === "below" ? spaceBelow : spaceAbove; + + const nextLayout: DropdownLayout = { + side, + left: rect.left, + width: rect.width, + ...(side === "below" + ? { top: rect.bottom + 4 } + : { bottom: window.innerHeight - rect.top + 4 }), + maxHeight: Math.min(preferredHeight, availableSpace), + }; + + setDropdownLayout((current) => + current.side === nextLayout.side && + current.left === nextLayout.left && + current.width === nextLayout.width && + current.top === nextLayout.top && + current.bottom === nextLayout.bottom && + current.maxHeight === nextLayout.maxHeight + ? current + : nextLayout, + ); + }, []); + + useEffect(() => { + if (!open) return; + let rafId: number | undefined; + + const scheduleDropdownLayout = () => { + if (rafId !== undefined) return; + rafId = window.requestAnimationFrame(() => { + rafId = undefined; + updateDropdownLayout(); + }); + }; + + updateDropdownLayout(); + window.addEventListener("resize", scheduleDropdownLayout); + window.addEventListener("scroll", scheduleDropdownLayout, true); + return () => { + if (rafId !== undefined) window.cancelAnimationFrame(rafId); + window.removeEventListener("resize", scheduleDropdownLayout); + window.removeEventListener("scroll", scheduleDropdownLayout, true); + }; + }, [open, selected.length, updateDropdownLayout]); + // ─── Import mutation (for external skills) ────────────────────────── const importMutation = useMutation({ mutationFn: (result: SkillSearchResult) => @@ -207,7 +287,6 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled = } else { onChange([...selected, id]); // Add without hash (latest) } - setQuery(""); inputRef.current?.focus(); }, [selected, selectedMap, onChange, importOnly], @@ -314,123 +393,137 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled = {fetchingExternal && debouncedQuery.length >= 2 && ( )} - - )} - {/* Dropdown */} - {showDropdown && ( -
-
- {/* Internal results */} - {internalMatches.length > 0 && ( - <> - {debouncedQuery.length >= 2 && filteredExternal.length > 0 && ( -
- Imported -
- )} - {internalMatches.map((skill, idx) => { - const isSelected = selectedMap.has(skill._id); - return ( - - ); - })} - - )} - - {/* External results */} - {debouncedQuery.length >= 2 && filteredExternal.length > 0 && ( - <> -
- - External Registry -
- {filteredExternal.map((result, i) => { - const globalIdx = internalMatches.length + i; - return ( -
setHighlightIdx(globalIdx)} - className={`flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm transition-colors ${ - globalIdx === highlightIdx ? "bg-accent text-accent-foreground" : "" - }`} - > - -
-
- {result.id} - {result.installs != null && ( - - {result.installs.toLocaleString()} installs - - )} -
-

- {result.name}{result.description ? ` — ${result.description}` : ""} -

-
- -
- ); - })} - - )} - - {/* Loading indicator for external */} - {debouncedQuery.length >= 2 && fetchingExternal && filteredExternal.length === 0 && ( -
- - Searching external registry… -
- )} + + {skill._id} + + {skill.name}{skill.description ? ` — ${skill.description}` : ""} + + + ); + })} + + )} + + {/* External results */} + {debouncedQuery.length >= 2 && filteredExternal.length > 0 && ( + <> +
+ + External Registry +
+ {filteredExternal.map((result, i) => { + const globalIdx = internalMatches.length + i; + return ( +
setHighlightIdx(globalIdx)} + className={`flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm transition-colors ${ + globalIdx === highlightIdx ? "bg-accent text-accent-foreground" : "" + }`} + > + +
+
+ {result.id} + {result.installs != null && ( + + {result.installs.toLocaleString()} installs + + )} +
+

+ {result.name}{result.description ? ` — ${result.description}` : ""} +

+
+ +
+ ); + })} + + )} + + {/* Loading indicator for external */} + {debouncedQuery.length >= 2 && fetchingExternal && filteredExternal.length === 0 && ( +
+ + Searching external registry… +
+ )} - {/* Empty state */} - {items.length === 0 && !fetchingExternal && query.trim() && ( -
- No matching skills found -
- )} -
-
+ {/* Empty state */} + {items.length === 0 && !fetchingExternal && query.trim() && ( +
+ No matching skills found +
+ )} + + , + document.body, + )} + )} {/* Manual add section */} diff --git a/docs/architecture/skills.md b/docs/architecture/skills.md index 561f6f49..87579fe9 100644 --- a/docs/architecture/skills.md +++ b/docs/architecture/skills.md @@ -122,6 +122,12 @@ Used when the user doesn't know which repo contains the skill they want. 3. User selects a result from skills.sh 4. API imports it the same way as Path 1 — registers the skill and resolves content from GitHub +When selecting skills for a profile, the Portal keeps the current search term +and result list open after each selection so related skills can be added in one +pass. The result list is rendered in a fixed portal so scrollable forms cannot +clip it; it stays within the available viewport space and opens above the +search field when there is not enough room below it. + The skill's `origin` is set to `"skills-sh"` to indicate it was discovered through that registry. ### Key distinction