diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index dfbb5295c..64069ba0a 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 1297373, - "unpacked": 4453717 + "packed": 1307872, + "unpacked": 4486933 }, "dist": { "total": { - "raw": 4438327, - "gzip": 1520942 + "raw": 4471543, + "gzip": 1534838 }, "js": { - "raw": 1320443, - "gzip": 468638 + "raw": 1331429, + "gzip": 473518 }, "types": { - "raw": 474763, - "gzip": 172113 + "raw": 477100, + "gzip": 173213 }, "maps": { - "raw": 2632326, - "gzip": 876373 + "raw": 2652219, + "gzip": 884289 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 895 + "fileCount": 905 }, "entries": [ { "id": ".", - "gzip": 111090, + "gzip": 111208, "composition": { - "initialGzip": 108517, + "initialGzip": 108635, "lazyGzip": 2573, - "totalGzip": 111090, - "own": 351738, + "totalGzip": 111208, + "own": 352050, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 103847, + "gzip": 103965, "kind": "initial" }, { @@ -64,17 +64,17 @@ }, { "id": "./beta", - "gzip": 105499, + "gzip": 108126, "composition": { - "initialGzip": 105014, + "initialGzip": 107641, "lazyGzip": 485, - "totalGzip": 105499, - "own": 316145, + "totalGzip": 108126, + "own": 323071, "nodeModules": null, "chunks": [ { "label": "beta.js", - "gzip": 84463, + "gzip": 87090, "kind": "initial" }, { @@ -127,12 +127,12 @@ }, { "id": "./testing", - "gzip": 79051, + "gzip": 79171, "composition": { - "initialGzip": 45993, - "lazyGzip": 33058, - "totalGzip": 79051, - "own": 228781, + "initialGzip": 45994, + "lazyGzip": 33177, + "totalGzip": 79171, + "own": 229093, "nodeModules": null, "chunks": [ { @@ -142,7 +142,7 @@ }, { "label": "index.js", - "gzip": 10286, + "gzip": 10287, "kind": "initial" }, { @@ -152,7 +152,7 @@ }, { "label": "index.js", - "gzip": 28625, + "gzip": 28744, "kind": "lazy" }, { @@ -209,25 +209,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 406741, - "unpacked": 1606513 + "packed": 430550, + "unpacked": 1680523 }, "dist": { "total": { - "raw": 1602568, - "gzip": 546534 + "raw": 1676578, + "gzip": 574683 }, "js": { - "raw": 439037, - "gzip": 150094 + "raw": 463670, + "gzip": 159291 }, "types": { - "raw": 273603, - "gzip": 100719 + "raw": 277992, + "gzip": 102934 }, "maps": { - "raw": 873530, - "gzip": 292465 + "raw": 918518, + "gzip": 309202 }, "css": { "raw": 16398, @@ -237,7 +237,7 @@ "raw": 0, "gzip": 0 }, - "fileCount": 554 + "fileCount": 570 }, "entries": [ { @@ -316,17 +316,17 @@ }, { "id": "./react/beta", - "gzip": 4994, + "gzip": 11530, "composition": { - "initialGzip": 4994, + "initialGzip": 21461, "lazyGzip": 0, - "totalGzip": 4994, - "own": 12631, - "nodeModules": 0, + "totalGzip": 21461, + "own": 31792, + "nodeModules": 30230, "chunks": [ { "label": "beta.js", - "gzip": 4994, + "gzip": 21461, "kind": "initial" } ] diff --git a/docs/docs/api/appkit/Variable.design.md b/docs/docs/api/appkit/Variable.design.md new file mode 100644 index 000000000..755e09049 --- /dev/null +++ b/docs/docs/api/appkit/Variable.design.md @@ -0,0 +1,5 @@ +# Variable: design + +```ts +const design: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ee6db8b67..fab63f199 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -190,6 +190,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | ------ | ------ | | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | +| [design](Variable.design.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [SUPERVISOR\_EXTENSION\_KEY](Variable.SUPERVISOR_EXTENSION_KEY.md) | Namespace key under which the adapter reads its hosted-tool payload from [AgentInput.extensions](Interface.AgentInput.md#extensions). Exported so the agents plugin and standalone `runAgent` (the producers) can write under the same key the adapter reads. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index dca9af23b..8f7b03f36 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -847,6 +847,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.aiSearch", label: "aiSearch" }, + { + type: "doc", + id: "api/appkit/Variable.design", + label: "design" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", diff --git a/docs/docs/plugins/design.md b/docs/docs/plugins/design.md new file mode 100644 index 000000000..aaf068031 --- /dev/null +++ b/docs/docs/plugins/design.md @@ -0,0 +1,129 @@ +--- +sidebar_position: 20 +--- + +# Design plugin + + +:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). +::: + + +A dev-only design canvas and element picker for working with a coding agent. It only runs with `NODE_ENV=development`. In any other environment it does nothing, and `/design` returns 404. + +## Basic usage + +```ts +import { createApp, server } from "@databricks/appkit"; +import { design } from "@databricks/appkit/beta"; + +await createApp({ + plugins: [server(), design()], +}); +``` + +## Configuration + +| Option | Type | Description | +| ------ | ---- | ----------- | +| `builderOrigin` | `string \| string[]` | Origin(s) of the app builder that embeds the app, for example `"https://builder.example.com"`. When `/design` runs inside that window, each send is also posted to it (see [Embedding in an app builder](#embedding-in-an-app-builder)). Must be a bare origin; `"*"` is rejected. | + +## The `/design` canvas + +`/design` renders every PascalCase export of every file under `client/src/design/`, with no props, one frame each. Put two kinds of components there: + +- **Drafts**: design-only UI that may later be moved into the real app. +- **Previews**: wrappers that render the app's real components with mock data. + +```tsx +// client/src/design/OrderCard.preview.tsx +import { OrderCard } from "../components/OrderCard"; + +export function OrderCardShipped() { + return ; +} +``` + +A frame that throws shows its error in place. It doesn't break the rest of the canvas. + +## Selecting elements + +Selecting only happens on `/design`; app pages are left untouched. Click **Select** in the canvas header, then click elements to add or remove them (Escape stops selecting). Each selected element gets a numbered outline that matches its row in the send panel. Add an optional note and click **Send to agent**. + +Each element is traced back to its source `file:line:col`: the dev server tags native JSX elements with `data-source`, and the design plugin also tags component call sites (such as `; +} + +function Parent() { + const [count, setCount] = useState(0); + return ( +
+ + +
+ ); +} + +describe("inspectElement", () => { + test("reports the committed props after every update", () => { + render(); + for (let i = 1; i <= 5; i++) { + act(() => screen.getByText("inc").click()); + const info = inspectElement(screen.getByText(`Count: ${i}`)); + expect(info.component).toBe("LiveCard"); + expect(info.props).toEqual({ count: i }); + } + }); + + test("takes the source of a component that never forwards it to the DOM", () => { + // Stand-in for a dependency component the dev transform tags at its call site. + const LibButton = ({ label }: { label: string }) => ( + + ); + const props = { "data-appkit-source": "client/src/App.tsx:7:4" }; + render( +
+ +
, + ); + const info = inspectElement(screen.getByText("Library button")); + expect(info.source).toBe("client/src/App.tsx:7:4"); + expect(info.props).toEqual({ label: "Library button" }); + }); +}); + +describe("DesignCanvas", () => { + test("renders function, memo and forwardRef exports", () => { + const MemoDraft = memo(() =>

memo draft

); + const RefDraft = forwardRef((_, ref) => ( +

ref draft

+ )); + render( +

plain draft

, + MemoDraft, + RefDraft, + helper: () => null, + CONSTANT: 1, + }, + }} + />, + ); + expect(screen.getByText("plain draft")).toBeTruthy(); + expect(screen.getByText("memo draft")).toBeTruthy(); + expect(screen.getByText("ref draft")).toBeTruthy(); + expect(screen.getAllByRole("heading", { level: 2 })).toHaveLength(3); + }); +}); + +function renderCanvas(builderOrigins?: string[]) { + render( + ( + <> + + + + ), + }, + }} + />, + ); +} + +const okResponse = () => + new Response( + JSON.stringify({ + ok: true, + selection: { + id: "sel-1", + elements: [{ screenshot: ".appkit/design-screenshots/sel-1-0.jpeg" }], + }, + }), + { status: 200 }, + ); + +describe("DesignPicker", () => { + afterEach(() => vi.unstubAllGlobals()); + + test("a finished send keeps picks added while it was in flight", async () => { + let release!: () => void; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + release = () => resolve(okResponse()); + }), + ); + vi.stubGlobal("fetch", fetchMock); + renderCanvas(); + + fireEvent.click(screen.getByRole("button", { name: /Select/ })); + fireEvent.click(screen.getByText("First action")); + fireEvent.click(screen.getByText("Send 1 to agent")); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText("Second action")); + expect(screen.getByText("Send 2 to agent")).toBeTruthy(); + + await act(async () => release()); + + const body = JSON.parse( + (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1] + .body as string, + ); + expect(body.elements.map((e: { text: string }) => e.text)).toEqual([ + "First action", + ]); + await waitFor(() => + expect(screen.getByText("Send 1 to agent")).toBeTruthy(), + ); + }); + + test("posts the selection to the embedding builder, only to its origin", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse()), + ); + const postMessage = vi.fn(); + const parent = Object.getOwnPropertyDescriptor(window, "parent"); + Object.defineProperty(window, "parent", { + configurable: true, + value: { postMessage }, + }); + try { + renderCanvas(["https://builder.example.com"]); + fireEvent.click(screen.getByRole("button", { name: /Select/ })); + fireEvent.click(screen.getByText("First action")); + fireEvent.change(screen.getByPlaceholderText(/What should change/), { + target: { value: "make it primary" }, + }); + fireEvent.click(screen.getByText("Send 1 to agent")); + await screen.findByText("Sent to the app builder."); + + expect(postMessage).toHaveBeenCalledTimes(1); + const [message, origin] = postMessage.mock.calls[0]; + expect(origin).toBe("https://builder.example.com"); + expect(message).toMatchObject({ + type: "appkit:design-selection", + version: 1, + id: "sel-1", + note: "make it primary", + selections: [ + { + text: "First action", + screenshotUrl: `${location.origin}/api/design/screenshots/sel-1-0.jpeg`, + }, + ], + }); + } finally { + if (parent) Object.defineProperty(window, "parent", parent); + } + }); + + test("without a builder origin it only saves to the file", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse()), + ); + const postMessage = vi.fn(); + const parent = Object.getOwnPropertyDescriptor(window, "parent"); + Object.defineProperty(window, "parent", { + configurable: true, + value: { postMessage }, + }); + try { + renderCanvas(); + fireEvent.click(screen.getByRole("button", { name: /Select/ })); + fireEvent.click(screen.getByText("First action")); + fireEvent.click(screen.getByText("Send 1 to agent")); + await screen.findByText(/Saved to/); + expect(postMessage).not.toHaveBeenCalled(); + } finally { + if (parent) Object.defineProperty(window, "parent", parent); + } + }); +}); diff --git a/packages/appkit-ui/src/react/design/design-canvas.tsx b/packages/appkit-ui/src/react/design/design-canvas.tsx new file mode 100644 index 000000000..d0d1e7e4f --- /dev/null +++ b/packages/appkit-ui/src/react/design/design-canvas.tsx @@ -0,0 +1,201 @@ +import { LayoutTemplate, MousePointerClick } from "lucide-react"; +import { + Component, + type ComponentType, + type ReactNode, + useCallback, + useState, +} from "react"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; +import { Kbd } from "../ui/kbd"; +import { DesignPicker } from "./design-picker"; + +/** Props for {@link DesignCanvas}. */ +export interface DesignCanvasProps { + /** `import.meta.glob` result: module path → module exports. */ + modules: Record>; + /** Origins an embedding app builder may be on (the plugin's `builderOrigin`). */ + builderOrigins?: string[]; +} + +/** memo / forwardRef components are objects, not functions. */ +const WRAPPED_COMPONENTS = new Set([ + Symbol.for("react.memo"), + Symbol.for("react.forward_ref"), +]); + +function isComponent(value: unknown): boolean { + if (typeof value === "function") return true; + return ( + typeof value === "object" && + value !== null && + WRAPPED_COMPONENTS.has((value as { $$typeof?: symbol }).$$typeof as symbol) + ); +} + +class Boundary extends Component<{ children: ReactNode }, { error?: Error }> { + state: { error?: Error } = {}; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return ( +
+          {this.state.error.message}
+        
+ ); + } + return this.props.children; + } +} + +/** Dotted backdrop so frame content reads as "on the canvas". */ +const CANVAS_BACKDROP = { + backgroundImage: + "radial-gradient(color-mix(in oklch, var(--border) 90%, transparent) 1px, transparent 1px)", + backgroundSize: "16px 16px", +}; + +/** + * Renders every PascalCase export of every module under `client/src/design/` + * with no props, one frame each, plus the element picker. Drafts and previews + * (real components wrapped with mock data) are both just zero-prop components + * in that folder. + * + * Served by the `design()` plugin at `/design` in development. + */ +export function DesignCanvas({ modules, builderOrigins }: DesignCanvasProps) { + const [selecting, setSelecting] = useState(false); + const onActiveChange = useCallback((v: boolean) => setSelecting(v), []); + + const files = Object.entries(modules) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, mod]) => ({ + file: path.replace(/^\/src\/design\//, ""), + frames: Object.entries(mod) + .filter(([name, value]) => /^[A-Z]/.test(name) && isComponent(value)) + .map(([name, value]) => ({ + id: `${path}#${name}`, + name, + Frame: value as ComponentType, + })), + })) + .filter((f) => f.frames.length > 0); + const count = files.reduce((n, f) => n + f.frames.length, 0); + + return ( +
+
+ +

Design canvas

+ + {count} {count === 1 ? "frame" : "frames"} + +

+ Pick the parts you want changed and send them to your agent. +

+
+ {selecting && ( + + Esc to stop + + )} + +
+
+ + {count === 0 ? ( + + + + + + No designs yet + + Export components from files in client/src/design/{" "} + and they show up here. + + + + ) : ( +
+ + +
+ {files.flatMap(({ file, frames }) => + frames.map(({ id, name, Frame }) => ( +
+
+

{name}

+ + {file} + +
+
+ + + +
+
+ )), + )} +
+
+ )} + + +
+ ); +} diff --git a/packages/appkit-ui/src/react/design/design-picker.tsx b/packages/appkit-ui/src/react/design/design-picker.tsx new file mode 100644 index 000000000..7eb4985fd --- /dev/null +++ b/packages/appkit-ui/src/react/design/design-picker.tsx @@ -0,0 +1,374 @@ +import { MousePointerClick, Send, X } from "lucide-react"; +import { + type CSSProperties, + useEffect, + useReducer, + useRef, + useState, +} from "react"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Textarea } from "../ui/textarea"; +import { + captureElement, + type DesignElementInfo, + inspectElement, +} from "./inspect"; +import { + DESIGN_SELECTION_MESSAGE, + type DesignSelectionMessage, + postDesignSelection, +} from "./message"; + +/** Endpoints the `design()` plugin mounts in development. */ +const SELECTION_ENDPOINT = "/api/design/selection"; +const SCREENSHOT_ENDPOINT = "/api/design/screenshots"; +const ROOT_ID = "appkit-design-picker"; + +type Status = "idle" | "sending" | "sent-builder" | "sent-file" | "error"; + +interface Picked { + el: Element; + info: DesignElementInfo; +} + +/** Props for {@link DesignPicker}. */ +export interface DesignPickerProps { + /** Whether clicks select elements. */ + active: boolean; + onActiveChange: (active: boolean) => void; + /** Origins an embedding app builder may be on (the plugin's `builderOrigin`). */ + builderOrigins?: string[]; +} + +function Outline({ + el, + style, + label, +}: { + el: Element; + style: CSSProperties; + label?: number; +}) { + const r = el.getBoundingClientRect(); + return ( +
+ {label !== undefined && ( + + {label} + + )} +
+ ); +} + +const HOVER_STYLE: CSSProperties = { + outline: "2px dashed var(--primary)", + outlineOffset: 2, +}; +const SELECTED_STYLE: CSSProperties = { + outline: "2px solid var(--primary)", + outlineOffset: 2, + background: "color-mix(in oklch, var(--primary) 10%, transparent)", +}; + +/** + * Click-to-select overlay and send panel of the design canvas. While + * `active`, clicks select or deselect elements (Escape stops). Sends are + * persisted by the `design()` plugin and, when the canvas is embedded by an + * allowed app builder, posted to it as a {@link DesignSelectionMessage}. + * + * Rendered by {@link DesignCanvas}; you don't mount it yourself. + */ +export function DesignPicker({ + active, + onActiveChange, + builderOrigins = [], +}: DesignPickerProps) { + const rootRef = useRef(null); + const [hovered, setHovered] = useState(null); + const [picked, setPicked] = useState([]); + const [note, setNote] = useState(""); + const [status, setStatus] = useState("idle"); + // Outlines are positioned from live rects; re-render when the page moves. + const [, rerender] = useReducer((n: number) => n + 1, 0); + + useEffect(() => { + window.addEventListener("scroll", rerender, true); + window.addEventListener("resize", rerender); + return () => { + window.removeEventListener("scroll", rerender, true); + window.removeEventListener("resize", rerender); + }; + }, []); + + // Modal dialogs set `pointer-events: none` on , dismiss on any outside + // press and trap focus. The picker (outside the dialog) must stay usable and + // must not count as "outside". + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const stop = (e: Event) => e.stopPropagation(); + const PRESS = ["pointerdown", "mousedown", "focusin", "touchstart"]; + for (const t of PRESS) root.addEventListener(t, stop); + // Focus traps watch `focusout` on the document and pull focus back when + // it leaves the dialog; let it move into the picker (e.g. the note box). + const onFocusOut = (e: FocusEvent) => { + if (e.relatedTarget instanceof Node && root.contains(e.relatedTarget)) { + e.stopPropagation(); + } + }; + window.addEventListener("focusout", onFocusOut, true); + return () => { + for (const t of PRESS) root.removeEventListener(t, stop); + window.removeEventListener("focusout", onFocusOut, true); + }; + }, []); + + useEffect(() => { + if (!active) return; + const own = (t: EventTarget | null) => + !(t instanceof Element) || + !!t.closest(`#${ROOT_ID}, [data-design-chrome]`); + const onMove = (e: MouseEvent) => + setHovered(own(e.target) ? null : (e.target as Element)); + const onClick = (e: MouseEvent) => { + if (own(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target as Element; + setStatus("idle"); + setPicked((prev) => + prev.some((p) => p.el === el) + ? prev.filter((p) => p.el !== el) + : [...prev, { el, info: inspectElement(el) }], + ); + }; + // A pick mustn't act on the page either: e.g. a pointerdown would let an + // open dialog dismiss itself before the click selects its content. + const swallow = (e: Event) => { + if (own(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + }; + const onKey = (e: KeyboardEvent) => + e.key === "Escape" && onActiveChange(false); + const PRESS = ["pointerdown", "mousedown", "pointerup", "mouseup"]; + document.addEventListener("mousemove", onMove, true); + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKey, true); + for (const t of PRESS) window.addEventListener(t, swallow, true); + return () => { + document.removeEventListener("mousemove", onMove, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKey, true); + for (const t of PRESS) window.removeEventListener(t, swallow, true); + setHovered(null); + }; + }, [active, onActiveChange]); + + async function send() { + setStatus("sending"); + // Picks and the note can change while the request is in flight; only + // what was actually sent is cleared afterwards. + const sending = picked; + const sentNote = note.trim() || undefined; + try { + const elements = await Promise.all( + sending.map(async ({ el, info }) => ({ + ...info, + screenshot: await captureElement(el), + })), + ); + const res = await fetch(SELECTION_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + elements, + note: sentNote, + url: location.pathname, + }), + }); + if (!res.ok) throw new Error(String(res.status)); + const { selection } = (await res.json()) as { + selection?: { id: string; elements: { screenshot?: string }[] }; + }; + + const message: DesignSelectionMessage = { + type: DESIGN_SELECTION_MESSAGE, + version: 1, + id: selection?.id, + url: location.pathname, + ...(sentNote ? { note: sentNote } : {}), + selections: elements.map((el, i) => { + const saved = selection?.elements[i]?.screenshot; + return saved + ? { + ...el, + screenshotUrl: `${location.origin}${SCREENSHOT_ENDPOINT}/${saved.split("/").pop()}`, + } + : el; + }), + }; + const posted = postDesignSelection(message, builderOrigins); + + setPicked((prev) => { + const remaining = prev.filter((p) => !sending.includes(p)); + // Idempotent, so safe in an updater that StrictMode may run twice. + if (remaining.length === 0) onActiveChange(false); + return remaining; + }); + setNote((n) => (n.trim() === (sentNote ?? "") ? "" : n)); + setStatus(posted ? "sent-builder" : "sent-file"); + } catch { + setStatus("error"); + } + } + + const showPanel = active || picked.length > 0 || status !== "idle"; + + return ( +
+ {active && hovered && } + {picked.map((p, i) => ( + + ))} + {showPanel && ( + + + + {picked.length > 0 + ? `${picked.length} selected` + : active + ? "Selecting" + : "Selection"} + + + {active + ? "Click elements to add or remove them. Esc to stop." + : "Turn on Select to pick more elements."} + + {picked.length > 0 && ( + + + + )} + + + {picked.length > 0 && ( + +
    + {picked.map((p, i) => ( +
  • + + {i + 1} + +
    +
    + + {p.info.component ?? `<${p.info.tag}>`} + + + {p.info.tag} + +
    +
    + {p.info.source ?? "no source location"} +
    +
    + +
  • + ))} +
+