From f1debfd6738d6ae3db9090761e6e7a69a8188554 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 29 Jun 2026 14:32:10 +0100 Subject: [PATCH 1/4] feat(home): searchable skill picker in quick-action editor Replace the plain skill ` patch({ skillId: e.target.value })} - className="rounded-md border border-(--gray-6) bg-(--gray-1) px-2 py-1.5 text-[12px] text-gray-12" + onValueChange={(v) => patch({ skillId: v })} + size="2" > - - {skills.map((s) => ( - - ))} - + + {selectedSkill?.name} + + `${s.name} ${s.description}`} + className="w-(--radix-popover-trigger-width)" + > + {({ filtered, hasMore, moreCount }) => ( + <> + + No matching skills + {filtered.map((s) => ( + + {s.name} + + ))} + {hasMore && ( +
+ {moreCount} more; type to filter +
+ )} + + )} +
+ {selectedSkill ? ( {selectedSkill.description} diff --git a/packages/ui/src/primitives/combobox/Combobox.css b/packages/ui/src/primitives/combobox/Combobox.css index 960e957215..51b7377e1d 100644 --- a/packages/ui/src/primitives/combobox/Combobox.css +++ b/packages/ui/src/primitives/combobox/Combobox.css @@ -196,6 +196,31 @@ white-space: nowrap; } +.combobox-item-text-group { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + overflow: hidden; +} + +.combobox-item-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85em; + line-height: 1.3; + color: var(--gray-10); +} + +/* Two-line items grow to fit the description and top-align their content. */ +.combobox-content [cmdk-item]:has(.combobox-item-description) { + height: auto; + align-items: flex-start; + padding-top: var(--combobox-content-padding); + padding-bottom: var(--combobox-content-padding); +} + .combobox-content.size-1 [cmdk-item] { font-size: 13px; line-height: 20px; @@ -252,6 +277,12 @@ justify-content: center; } +/* Keep the check aligned to the first line on two-line items. */ +.combobox-content [cmdk-item]:has(.combobox-item-description) + .combobox-item-indicator { + align-self: flex-start; +} + .combobox-content [cmdk-empty] { display: flex; align-items: center; diff --git a/packages/ui/src/primitives/combobox/Combobox.stories.tsx b/packages/ui/src/primitives/combobox/Combobox.stories.tsx index bc1fd86352..c9c0415b11 100644 --- a/packages/ui/src/primitives/combobox/Combobox.stories.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.stories.tsx @@ -369,6 +369,68 @@ export const FilteredContent: Story = { }, }; +const skills = [ + { + value: "investigate-metric", + label: "investigate-metric", + description: "Diagnose why a metric moved and surface the drivers.", + }, + { + value: "querying-posthog-data", + label: "querying-posthog-data", + description: "Write and run HogQL against the project's event data.", + }, + { + value: "creating-experiments", + label: "creating-experiments", + description: + "Define a hypothesis, configure rollout, and set up analytics for an A/B test.", + }, + { + value: "investigating-replay", + label: "investigating-replay", + description: "Find and watch session replays relevant to an issue.", + }, +]; + +export const WithDescriptions: Story = { + render: () => { + const [value, setValue] = useState(""); + + return ( + + + `${s.label} ${s.description}`} + > + {({ filtered, hasMore, moreCount }) => ( + <> + + No matching skills + {filtered.map((skill) => ( + + {skill.label} + + ))} + {hasMore && ( +
+ {moreCount} more; type to filter +
+ )} + + )} +
+
+ ); + }, +}; + export const ControlledSearch: Story = { render: () => { const [value, setValue] = useState(""); diff --git a/packages/ui/src/primitives/combobox/Combobox.tsx b/packages/ui/src/primitives/combobox/Combobox.tsx index 278ee30719..eaa5ce5e9d 100644 --- a/packages/ui/src/primitives/combobox/Combobox.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.tsx @@ -358,91 +358,103 @@ interface ComboboxItemProps { className?: string; textValue?: string; icon?: React.ReactNode; + /** Optional muted second line, truncated to one line. */ + description?: React.ReactNode; } const ComboboxItem = React.forwardRef< React.ElementRef, ComboboxItemProps ->(({ children, value, disabled, className, textValue, icon }, ref) => { - const { - value: selectedValue, - onValueChange, - onOpenChange, - registerItem, - unregisterItem, - } = useComboboxContext(); - - const textRef = useRef(null); - const itemRef = useRef(null); - const [showTooltip, setShowTooltip] = useState(false); - - useEffect(() => { - const label = - textValue || (typeof children === "string" ? children : value); - registerItem(value, label); - return () => unregisterItem(value); - }, [value, children, textValue, registerItem, unregisterItem]); - - useEffect(() => { - if (!showTooltip) return; - const scrollParent = itemRef.current?.closest("[cmdk-list]"); - if (!scrollParent) return; - const dismiss = () => setShowTooltip(false); - scrollParent.addEventListener("scroll", dismiss, { passive: true }); - return () => scrollParent.removeEventListener("scroll", dismiss); - }, [showTooltip]); - - const isSelected = selectedValue === value; - - const handleSelect = useCallback(() => { - if (!disabled) { - onValueChange(value); - onOpenChange(false); - } - }, [disabled, value, onValueChange, onOpenChange]); - - const handleMouseEnter = useCallback(() => { - const el = textRef.current; - if (el && el.scrollWidth > el.clientWidth) { - setShowTooltip(true); - } - }, []); +>( + ( + { children, value, disabled, className, textValue, icon, description }, + ref, + ) => { + const { + value: selectedValue, + onValueChange, + onOpenChange, + registerItem, + unregisterItem, + } = useComboboxContext(); + + const textRef = useRef(null); + const itemRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + const label = + textValue || (typeof children === "string" ? children : value); + registerItem(value, label); + return () => unregisterItem(value); + }, [value, children, textValue, registerItem, unregisterItem]); + + useEffect(() => { + if (!showTooltip) return; + const scrollParent = itemRef.current?.closest("[cmdk-list]"); + if (!scrollParent) return; + const dismiss = () => setShowTooltip(false); + scrollParent.addEventListener("scroll", dismiss, { passive: true }); + return () => scrollParent.removeEventListener("scroll", dismiss); + }, [showTooltip]); + + const isSelected = selectedValue === value; + + const handleSelect = useCallback(() => { + if (!disabled) { + onValueChange(value); + onOpenChange(false); + } + }, [disabled, value, onValueChange, onOpenChange]); - const handleMouseLeave = useCallback(() => { - setShowTooltip(false); - }, []); + const handleMouseEnter = useCallback(() => { + const el = textRef.current; + if (el && el.scrollWidth > el.clientWidth) { + setShowTooltip(true); + } + }, []); - const tooltipContent = - textValue || (typeof children === "string" ? children : value); + const handleMouseLeave = useCallback(() => { + setShowTooltip(false); + }, []); - return ( - - { - itemRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }} - value={value} - disabled={disabled} - onSelect={handleSelect} - className={className} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - > - - {icon && {icon}} - - {children} + const tooltipContent = + textValue || (typeof children === "string" ? children : value); + + return ( + + { + itemRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} + value={value} + disabled={disabled} + onSelect={handleSelect} + className={className} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + + {icon && {icon}} + + + {children} + + {description != null && ( + {description} + )} + - - - {isSelected && } - - - - ); -}); + + {isSelected && } + + + + ); + }, +); ComboboxItem.displayName = "ComboboxItem"; From df57e94a00c273f1fa2eaed92f9fbde3fabc3f16 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 29 Jun 2026 15:17:09 +0100 Subject: [PATCH 2/4] fix(home): address skill picker review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show "Loading skills…" in the combobox empty state while skills load - Use Combobox.Label instead of raw combobox-label divs (panel + story) Generated-By: PostHog Code Task-Id: 57b6c685-e5ae-4ce3-b8dc-4e7dd96051b1 --- .../ui/src/features/home/config/ActionEditorPanel.tsx | 8 +++++--- packages/ui/src/primitives/combobox/Combobox.stories.tsx | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/home/config/ActionEditorPanel.tsx b/packages/ui/src/features/home/config/ActionEditorPanel.tsx index 4d49cf5f1a..e730485ea1 100644 --- a/packages/ui/src/features/home/config/ActionEditorPanel.tsx +++ b/packages/ui/src/features/home/config/ActionEditorPanel.tsx @@ -119,7 +119,9 @@ export function ActionEditorPanel({ {({ filtered, hasMore, moreCount }) => ( <> - No matching skills + + {isLoading ? "Loading skills…" : "No matching skills"} + {filtered.map((s) => ( ))} {hasMore && ( -
+ {moreCount} more; type to filter -
+ )} )} diff --git a/packages/ui/src/primitives/combobox/Combobox.stories.tsx b/packages/ui/src/primitives/combobox/Combobox.stories.tsx index c9c0415b11..170fba2aac 100644 --- a/packages/ui/src/primitives/combobox/Combobox.stories.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.stories.tsx @@ -419,9 +419,9 @@ export const WithDescriptions: Story = {
))} {hasMore && ( -
+ {moreCount} more; type to filter -
+ )} )} From 571bc600596a046416c005399e705454938698ba Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 29 Jun 2026 15:33:04 +0100 Subject: [PATCH 3/4] refactor(ui): simplify combobox description rendering - Only wrap items in the text-group flex column when a description is present, restoring the flat single-span DOM for the common single-line case; use a truthy guard consistent with the icon check. - Hoist the skill search-key function to module scope so its reference is stable, avoiding needless re-runs of the combobox fuzzy filter on unrelated panel re-renders. Generated-By: PostHog Code Task-Id: 57b6c685-e5ae-4ce3-b8dc-4e7dd96051b1 --- .../src/features/home/config/ActionEditorPanel.tsx | 6 +++++- packages/ui/src/primitives/combobox/Combobox.tsx | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/home/config/ActionEditorPanel.tsx b/packages/ui/src/features/home/config/ActionEditorPanel.tsx index e730485ea1..7b5467baa3 100644 --- a/packages/ui/src/features/home/config/ActionEditorPanel.tsx +++ b/packages/ui/src/features/home/config/ActionEditorPanel.tsx @@ -22,6 +22,10 @@ interface Props { indexInSituation: number; } +// Search across both the skill name and its description. +const skillSearchValue = (s: { name: string; description: string }) => + `${s.name} ${s.description}`; + export function ActionEditorPanel({ situationId, action, @@ -113,7 +117,7 @@ export function ActionEditorPanel({ `${s.name} ${s.description}`} + getValue={skillSearchValue} className="w-(--radix-popover-trigger-width)" > {({ filtered, hasMore, moreCount }) => ( diff --git a/packages/ui/src/primitives/combobox/Combobox.tsx b/packages/ui/src/primitives/combobox/Combobox.tsx index eaa5ce5e9d..23b32cfbd3 100644 --- a/packages/ui/src/primitives/combobox/Combobox.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.tsx @@ -438,14 +438,18 @@ const ComboboxItem = React.forwardRef< > {icon && {icon}} - + {description ? ( + + + {children} + + {description} + + ) : ( {children} - {description != null && ( - {description} - )} - + )} {isSelected && } From 4e8140b7d05a8b1b7c7cd5d1005ec7b0536bcd78 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 29 Jun 2026 18:00:21 +0100 Subject: [PATCH 4/4] feat(ui): weighted fuzzy skill search in combobox Address Adam's review: skill-picker filtering scored a single concatenated "name description" string with cmdk's defaultFilter, so a hit deep in a long description ranked as highly as a name match. Add an opt-in `searchKeys` path to the Combobox filter, backed by fuse.js (already a dependency, used in core/suggestions and PromptHistoryDialog): - weighted multi-key matching (skill name 0.7, description 0.3) - prefix matches on `getValue` promoted for exact-match priority - falls back to the existing cmdk single-string path when `searchKeys` is omitted, so other combobox consumers are unaffected Wire it into the action-editor skill picker and cover the new path with tests (name match outranks description-only match; prefix promotion). Generated-By: PostHog Code Task-Id: 57b6c685-e5ae-4ce3-b8dc-4e7dd96051b1 --- .../home/config/ActionEditorPanel.tsx | 15 ++++- .../ui/src/primitives/combobox/Combobox.tsx | 14 ++++- .../combobox/useComboboxFilter.test.ts | 55 +++++++++++++++++ .../primitives/combobox/useComboboxFilter.ts | 59 ++++++++++++++++--- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/home/config/ActionEditorPanel.tsx b/packages/ui/src/features/home/config/ActionEditorPanel.tsx index 7b5467baa3..a9b91f1581 100644 --- a/packages/ui/src/features/home/config/ActionEditorPanel.tsx +++ b/packages/ui/src/features/home/config/ActionEditorPanel.tsx @@ -11,6 +11,7 @@ import { UnifiedModelSelector } from "@posthog/ui/features/sessions/components/U import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { usePreviewConfig } from "@posthog/ui/features/task-detail/hooks/usePreviewConfig"; import { Combobox } from "@posthog/ui/primitives/combobox/Combobox"; +import type { ComboboxSearchKeys } from "@posthog/ui/primitives/combobox/useComboboxFilter"; import { Card, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; import { useMemo } from "react"; import { SITUATION_TONE } from "./workflowMapLayout"; @@ -22,9 +23,16 @@ interface Props { indexInSituation: number; } -// Search across both the skill name and its description. -const skillSearchValue = (s: { name: string; description: string }) => - `${s.name} ${s.description}`; +type SkillOption = ReturnType["skills"][number]; + +// Name-first so a prefix match on the name wins the exact-match promotion. +const skillSearchValue = (s: SkillOption) => `${s.name} ${s.description}`; + +// Weight the skill name above its description so a name hit ranks first. +const SKILL_SEARCH_KEYS: ComboboxSearchKeys = [ + { name: "name", weight: 0.7 }, + { name: "description", weight: 0.3 }, +]; export function ActionEditorPanel({ situationId, @@ -118,6 +126,7 @@ export function ActionEditorPanel({ {({ filtered, hasMore, moreCount }) => ( diff --git a/packages/ui/src/primitives/combobox/Combobox.tsx b/packages/ui/src/primitives/combobox/Combobox.tsx index 23b32cfbd3..cb7083dde3 100644 --- a/packages/ui/src/primitives/combobox/Combobox.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.tsx @@ -14,7 +14,10 @@ import React, { } from "react"; import { Tooltip } from "../Tooltip"; import "./Combobox.css"; -import { useComboboxFilter } from "./useComboboxFilter"; +import { + type ComboboxSearchKeys, + useComboboxFilter, +} from "./useComboboxFilter"; type ComboboxSize = "1" | "2" | "3"; type ComboboxContentVariant = "solid" | "soft"; @@ -212,6 +215,12 @@ interface ComboboxContentFilteredProps extends ComboboxContentBaseProps { items: T[]; /** Extract the searchable string from each item. Defaults to `String(item)`. */ getValue?: (item: T) => string; + /** + * Opt-in weighted fuzzy search across multiple fields (fuse.js). Each key + * carries a weight (e.g. name above description) and prefix matches on + * `getValue` are promoted. Pass a stable reference (a module constant). + */ + searchKeys?: ComboboxSearchKeys; /** Maximum items to render. Defaults to 50. */ limit?: number; /** Values pinned to the top regardless of score. */ @@ -238,6 +247,7 @@ function ComboboxContent({ const hasItems = "items" in rest && rest.items !== undefined; const filterItems = hasItems ? rest.items : ([] as T[]); const getValue = hasItems ? rest.getValue : undefined; + const searchKeys = hasItems ? rest.searchKeys : undefined; const limit = hasItems ? rest.limit : undefined; const pinned = hasItems ? rest.pinned : undefined; const shouldFilter = hasItems @@ -248,7 +258,7 @@ function ComboboxContent({ const filter = useComboboxFilter( filterItems, - { limit, pinned, open }, + { limit, pinned, open, keys: searchKeys }, getValue, ); diff --git a/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts b/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts index 222b00c4b6..e41deb26c6 100644 --- a/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts +++ b/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts @@ -72,4 +72,59 @@ describe("useComboboxFilter", () => { ); expect(result.current.filtered[0]).toBe("gamma"); }); + + describe("weighted multi-key search (keys option)", () => { + interface Skill { + name: string; + description: string; + } + const skills: Skill[] = [ + { name: "deploy-app", description: "Ship code to production" }, + { name: "run-tests", description: "Deploy a test runner and report" }, + { name: "lint", description: "Check formatting" }, + ]; + const keys = [ + { name: "name" as const, weight: 0.7 }, + { name: "description" as const, weight: 0.3 }, + ]; + const getValue = (s: Skill) => `${s.name} ${s.description}`; + + const runSearch = (query: string) => { + const { result } = renderHook(() => + useComboboxFilter(skills, { open: true, keys }, getValue), + ); + act(() => { + result.current.onSearchChange(query); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + return result.current.filtered; + }; + + it("ranks a name match above a description-only match", () => { + // "deploy" hits deploy-app's name (weight 0.7) and run-tests' description + // (weight 0.3); the name match must win. + const filtered = runSearch("deploy"); + expect(filtered.map((s) => s.name)).toEqual(["deploy-app", "run-tests"]); + }); + + it("promotes prefix matches for exact-match priority", () => { + const prefixFirst: Skill[] = [ + { name: "unit-test", description: "Runs the unit suite" }, + { name: "test-runner", description: "Generic harness" }, + ]; + const { result } = renderHook(() => + useComboboxFilter(prefixFirst, { open: true, keys }, getValue), + ); + act(() => { + result.current.onSearchChange("test"); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + // Both names contain "test", but only "test-runner" starts with it. + expect(result.current.filtered[0]?.name).toBe("test-runner"); + }); + }); }); diff --git a/packages/ui/src/primitives/combobox/useComboboxFilter.ts b/packages/ui/src/primitives/combobox/useComboboxFilter.ts index 4e813a5360..2d8f7c3424 100644 --- a/packages/ui/src/primitives/combobox/useComboboxFilter.ts +++ b/packages/ui/src/primitives/combobox/useComboboxFilter.ts @@ -1,18 +1,34 @@ import { defaultFilter } from "cmdk"; +import Fuse, { type IFuseOptions } from "fuse.js"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce } from "../hooks/useDebounce"; const DEFAULT_LIMIT = 50; const MIN_FUZZY_SCORE = 0.1; const DEBOUNCE_MS = 150; +// fuse.js scores run 0 (perfect) → 1 (worst); only keep reasonably close matches. +const FUSE_THRESHOLD = 0.4; -interface UseComboboxFilterOptions { +/** Weighted fields for the opt-in fuse.js search path. */ +export type ComboboxSearchKeys = NonNullable["keys"]>; + +interface UseComboboxFilterOptions { /** Maximum number of items to render. Defaults to 50. */ limit?: number; /** Values pinned to the top regardless of score. */ pinned?: string[]; /** Popover open state. Search resets when this becomes false. */ open?: boolean; + /** + * Opt-in weighted fuzzy search across multiple fields, via fuse.js. Each key + * carries a weight (e.g. name above description), and items whose `getValue` + * starts with the query are promoted for exact-match priority. When omitted, + * scoring falls back to cmdk single-string matching over `getValue`. + * + * Pass a stable reference (a module constant) — a fresh array every render + * rebuilds the fuse index each time. + */ + keys?: ComboboxSearchKeys; } interface UseComboboxFilterResult { @@ -31,12 +47,13 @@ interface UseComboboxFilterResult { */ export function useComboboxFilter( items: T[], - options?: UseComboboxFilterOptions, + options?: UseComboboxFilterOptions, getValue?: (item: T) => string, ): UseComboboxFilterResult { const limit = options?.limit ?? DEFAULT_LIMIT; const pinned = options?.pinned; const open = options?.open; + const keys = options?.keys; const [inputValue, setInputValue] = useState(""); // delay=0 while closed so the next open starts on fresh empty-query results, // not a flash of the previous filtered set. @@ -51,15 +68,41 @@ export function useComboboxFilter( [getValue], ); + // Build the fuse index only on the opt-in weighted path. `keys` must be a + // stable reference or this rebuilds every render. + const fuse = useMemo( + () => + keys + ? new Fuse(items, { + keys, + threshold: FUSE_THRESHOLD, + ignoreLocation: true, + includeScore: true, + }) + : null, + [items, keys], + ); + const { filtered, totalMatches } = useMemo(() => { const query = search.trim(); - // Score and filter items. cmdk's fuzzy matcher can produce very low scores - // for scattered single-character matches (e.g. "vojta" matching v-o-j-t-a - // across "chore-remoVe-cOhort-Join-aTtempt"), so we require a minimum score - // to avoid noisy results. + // Scores below are normalised so higher always means a better match, + // letting the sort logic below stay identical across both paths. let scored: Array<{ item: T; score: number }>; - if (query) { + if (query && fuse) { + // Weighted multi-key fuzzy search. fuse scores 0 (best) → 1 (worst), so + // invert to higher-is-better, and promote prefix matches (+1) so an + // exact-ish hit on the leading field always outranks a fuzzy one. + const lowerQuery = query.toLowerCase(); + scored = fuse.search(query).map(({ item, score }) => { + const prefix = resolve(item).toLowerCase().startsWith(lowerQuery); + return { item, score: (prefix ? 1 : 0) + (1 - (score ?? 1)) }; + }); + } else if (query) { + // cmdk's fuzzy matcher can produce very low scores for scattered + // single-character matches (e.g. "vojta" matching v-o-j-t-a across + // "chore-remoVe-cOhort-Join-aTtempt"), so we require a minimum score to + // avoid noisy results. scored = []; for (const item of items) { const score = defaultFilter(resolve(item), query); @@ -94,7 +137,7 @@ export function useComboboxFilter( filtered: scored.slice(0, limit).map((s) => s.item), totalMatches: total, }; - }, [items, search, limit, pinned, resolve]); + }, [items, search, limit, pinned, resolve, fuse]); return { filtered,