| author | |
| committer | |
| log | e723501227b7d1d0b960eb8fcfd9c8d6099d805e |
| tree | 85fc282ffef0555b8a96dfbf7d98b3d3ccc4e2c4 |
| parent | 68966c1e6603d6f5d52a4ee227e2b0ee03b7109f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>11 files changed, 255 insertions(+), 21 deletions(-)
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+10-1| ... | @@ -119,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -119,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: { |
| 119 | readonly item: ComposerCommandItem; | 119 | readonly item: ComposerCommandItem; |
| 120 | readonly onPress: () => void; | 120 | readonly onPress: () => void; |
| 121 | readonly isLast: boolean; | 121 | readonly isLast: boolean; |
| 122 | readonly isSlashSkill: boolean; | ||
| 122 | }) { | 123 | }) { |
| 123 | const iconName = itemIcon(props.item); | 124 | const iconName = itemIcon(props.item); |
| 124 | const iconColor = useThemeColor("--color-icon-subtle"); | 125 | const iconColor = useThemeColor("--color-icon-subtle"); |
| ... | @@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: { |
| 144 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | 145 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> |
| 145 | ) : null} | 146 | ) : null} |
| 146 | <Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}> | 147 | <Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}> |
| 147 | {props.item.label} | 148 | {props.isSlashSkill && props.item.type === "skill" ? ( |
| 149 | <> | ||
| 150 | <Text className="text-foreground-muted">skill:</Text> | ||
| 151 | {props.item.skill.name} | ||
| 152 | </> | ||
| 153 | ) : ( | ||
| 154 | props.item.label | ||
| 155 | )} | ||
| 148 | </Text> | 156 | </Text> |
| 149 | {props.item.description ? ( | 157 | {props.item.description ? ( |
| 150 | <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}> | 158 | <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}> |
| ... | @@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( | ... | @@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( |
| 181 | item={item} | 189 | item={item} |
| 182 | onPress={() => props.onSelect(item)} | 190 | onPress={() => props.onSelect(item)} |
| 183 | isLast={index === props.items.length - 1} | 191 | isLast={index === props.items.length - 1} |
| 192 | isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"} | ||
| 184 | /> | 193 | /> |
| 185 | ))} | 194 | ))} |
| 186 | </ScrollView> | 195 | </ScrollView> |
apps/mobile/src/features/threads/ThreadComposer.tsx+12-1| ... | @@ -67,6 +67,7 @@ import { | ... | @@ -67,6 +67,7 @@ import { |
| 67 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | 67 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 68 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | 68 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 69 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 69 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 70 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; | ||
| 70 | import { | 71 | import { |
| 71 | type ExistingThreadSettingsRouteSession, | 72 | type ExistingThreadSettingsRouteSession, |
| 72 | useExistingThreadSettingsRoutePresentation, | 73 | useExistingThreadSettingsRoutePresentation, |
| ... | @@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 430 | }); | 431 | }); |
| 431 | } | 432 | } |
| 432 | 433 | ||
| 433 | return [...builtIn, ...providerCommands]; | 434 | const skillItems = (selectedProviderStatus?.skills ?? []) |
| 435 | .filter((skill) => matchesSlashSkillQuery(skill, q)) | ||
| 436 | .map((skill) => ({ | ||
| 437 | id: `skill:${skill.name}`, | ||
| 438 | type: "skill" as const, | ||
| 439 | skill, | ||
| 440 | label: `skill:${skill.name}`, | ||
| 441 | description: skill.shortDescription ?? skill.description ?? "", | ||
| 442 | })); | ||
| 443 | |||
| 444 | return [...builtIn, ...providerCommands, ...skillItems]; | ||
| 434 | } | 445 | } |
| 435 | 446 | ||
| 436 | if (composerTrigger.kind === "skill") { | 447 | if (composerTrigger.kind === "skill") { |
apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; | ||
| 4 | |||
| 5 | const browserSkill = { | ||
| 6 | name: "browser", | ||
| 7 | path: "/skills/browser/SKILL.md", | ||
| 8 | enabled: true, | ||
| 9 | shortDescription: "Open and control the in-app browser", | ||
| 10 | }; | ||
| 11 | |||
| 12 | describe("matchesSlashSkillQuery", () => { | ||
| 13 | it("matches the rendered skill prefix", () => { | ||
| 14 | expect(matchesSlashSkillQuery(browserSkill, "skill")).toBe(true); | ||
| 15 | expect(matchesSlashSkillQuery(browserSkill, "skill:brow")).toBe(true); | ||
| 16 | }); | ||
| 17 | }); | ||
apps/mobile/src/features/threads/composerSlashSkillSearch.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function matchesSlashSkillQuery(skill: ServerProviderSkill, query: string): boolean { | ||
| 4 | if (!skill.enabled) return false; | ||
| 5 | const normalizedQuery = query.toLowerCase(); | ||
| 6 | const skillQuery = | ||
| 7 | normalizedQuery === "skill" | ||
| 8 | ? "" | ||
| 9 | : normalizedQuery.startsWith("skill:") | ||
| 10 | ? normalizedQuery.slice("skill:".length) | ||
| 11 | : normalizedQuery; | ||
| 12 | if (!skillQuery) return true; | ||
| 13 | return [skill.name, skill.displayName, skill.shortDescription, skill.description].some((value) => | ||
| 14 | value?.toLowerCase().includes(skillQuery), | ||
| 15 | ); | ||
| 16 | } | ||
apps/web/src/components/chat/ChatComposer.tsx+18-4| ... | @@ -1117,10 +1117,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1117,10 +1117,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1117 | }), | 1117 | }), |
| 1118 | ); | 1118 | ); |
| 1119 | const query = composerTrigger.query.trim().toLowerCase(); | 1119 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1120 | const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; | 1120 | const skillItems = (selectedProviderStatus?.skills ?? []) |
| 1121 | if (!query) { | 1121 | .filter((skill) => skill.enabled) |
| 1122 | return slashCommandItems; | 1122 | .map((skill) => ({ |
| 1123 | } | 1123 | id: `skill:${selectedProvider}:${skill.name}`, |
| 1124 | type: "skill" as const, | ||
| 1125 | provider: selectedProvider, | ||
| 1126 | skill, | ||
| 1127 | label: `skill:${skill.name}`, | ||
| 1128 | description: | ||
| 1129 | skill.shortDescription ?? | ||
| 1130 | skill.description ?? | ||
| 1131 | (skill.scope ? `${skill.scope} skill` : ""), | ||
| 1132 | })); | ||
| 1133 | const slashCommandItems = [ | ||
| 1134 | ...builtInSlashCommandItems, | ||
| 1135 | ...providerSlashCommandItems, | ||
| 1136 | ...skillItems, | ||
| 1137 | ]; | ||
| 1124 | return searchSlashCommandItems(slashCommandItems, query); | 1138 | return searchSlashCommandItems(slashCommandItems, query); |
| 1125 | } | 1139 | } |
| 1126 | if (composerTrigger.kind === "skill") { | 1140 | if (composerTrigger.kind === "skill") { |
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+32| ... | @@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => { | ... | @@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => { |
| 86 | expect(markup).toContain("<svg"); | 86 | expect(markup).toContain("<svg"); |
| 87 | expect(markup).toContain("text-icon-muted"); | 87 | expect(markup).toContain("text-icon-muted"); |
| 88 | }); | 88 | }); |
| 89 | |||
| 90 | it("renders slash skill results with only the skill prefix dimmed", () => { | ||
| 91 | const markup = renderToStaticMarkup( | ||
| 92 | <ComposerCommandMenu | ||
| 93 | items={[ | ||
| 94 | { | ||
| 95 | id: "skill:codex:browser", | ||
| 96 | type: "skill", | ||
| 97 | provider: ProviderDriverKind.make("codex"), | ||
| 98 | skill: { | ||
| 99 | name: "browser", | ||
| 100 | path: "/skills/browser/SKILL.md", | ||
| 101 | enabled: true, | ||
| 102 | }, | ||
| 103 | label: "skill:browser", | ||
| 104 | description: "Open and control the in-app browser", | ||
| 105 | }, | ||
| 106 | ]} | ||
| 107 | resolvedTheme="dark" | ||
| 108 | isLoading={false} | ||
| 109 | triggerKind="slash-command" | ||
| 110 | activeItemId="skill:codex:browser" | ||
| 111 | onHighlightedItemChange={() => {}} | ||
| 112 | onSelect={() => {}} | ||
| 113 | />, | ||
| 114 | ); | ||
| 115 | |||
| 116 | expect(markup).toContain('<span class="text-secondary-label">skill:</span>browser'); | ||
| 117 | expect(markup).toContain("Open and control the in-app browser"); | ||
| 118 | expect(markup).not.toContain("font-medium text-secondary-label"); | ||
| 119 | expect(markup).not.toContain("<svg"); | ||
| 120 | }); | ||
| 89 | }); | 121 | }); |
apps/web/src/components/chat/ComposerCommandMenu.tsx+15-2| ... | @@ -99,6 +99,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -99,6 +99,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 99 | <ComposerCommandMenuItem | 99 | <ComposerCommandMenuItem |
| 100 | key={item.id} | 100 | key={item.id} |
| 101 | item={item} | 101 | item={item} |
| 102 | triggerKind={props.triggerKind} | ||
| 102 | resolvedTheme={props.resolvedTheme} | 103 | resolvedTheme={props.resolvedTheme} |
| 103 | isActive={props.activeItemId === item.id} | 104 | isActive={props.activeItemId === item.id} |
| 104 | onHighlight={props.onHighlightedItemChange} | 105 | onHighlight={props.onHighlightedItemChange} |
| ... | @@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 130 | 131 | ||
| 131 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | 132 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 132 | item: ComposerCommandItem; | 133 | item: ComposerCommandItem; |
| 134 | triggerKind: ComposerTriggerKind | null; | ||
| 133 | resolvedTheme: "light" | "dark"; | 135 | resolvedTheme: "light" | "dark"; |
| 134 | isActive: boolean; | 136 | isActive: boolean; |
| 135 | onHighlight: (itemId: string | null) => void; | 137 | onHighlight: (itemId: string | null) => void; |
| ... | @@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | ... | @@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 137 | }) { | 139 | }) { |
| 138 | const skillSourceKind = | 140 | const skillSourceKind = |
| 139 | props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null; | 141 | props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null; |
| 142 | const slashSkill = | ||
| 143 | props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null; | ||
| 140 | 144 | ||
| 141 | return ( | 145 | return ( |
| 142 | <CommandItem | 146 | <CommandItem |
| ... | @@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | ... | @@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 162 | kind={props.item.pathKind} | 166 | kind={props.item.pathKind} |
| 163 | theme={props.resolvedTheme} | 167 | theme={props.resolvedTheme} |
| 164 | /> | 168 | /> |
| 165 | ) : skillSourceKind ? ( | 169 | ) : skillSourceKind && !slashSkill ? ( |
| 166 | <SkillSourceIcon kind={skillSourceKind} /> | 170 | <SkillSourceIcon kind={skillSourceKind} /> |
| 167 | ) : null} | 171 | ) : null} |
| 168 | <span className="flex min-w-0 flex-1 items-baseline gap-3"> | 172 | <span className="flex min-w-0 flex-1 items-baseline gap-3"> |
| 169 | <span className="shrink-0 font-sans text-xs font-medium">{props.item.label}</span> | 173 | <span className="shrink-0 font-sans text-xs font-medium"> |
| 174 | {slashSkill ? ( | ||
| 175 | <> | ||
| 176 | <span className="text-secondary-label">skill:</span> | ||
| 177 | {slashSkill.name} | ||
| 178 | </> | ||
| 179 | ) : ( | ||
| 180 | props.item.label | ||
| 181 | )} | ||
| 182 | </span> | ||
| 170 | <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs"> | 183 | <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs"> |
| 171 | {props.item.description} | 184 | {props.item.description} |
| 172 | </span> | 185 | </span> |
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+102-2| ... | @@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => { | ... | @@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => { |
| 33 | description: "Create distinctive, production-grade frontend interfaces", | 33 | description: "Create distinctive, production-grade frontend interfaces", |
| 34 | }, | 34 | }, |
| 35 | ] satisfies Array< | 35 | ] satisfies Array< |
| 36 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }> | 36 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }> |
| 37 | >; | 37 | >; |
| 38 | 38 | ||
| 39 | expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ | 39 | expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ |
| ... | @@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => { | ... | @@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => { |
| 61 | description: "General GitHub help", | 61 | description: "General GitHub help", |
| 62 | }, | 62 | }, |
| 63 | ] satisfies Array< | 63 | ] satisfies Array< |
| 64 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }> | 64 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }> |
| 65 | >; | 65 | >; |
| 66 | 66 | ||
| 67 | expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ | 67 | expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ |
| 68 | "provider-slash-command:claudeAgent:gh-fix-ci", | 68 | "provider-slash-command:claudeAgent:gh-fix-ci", |
| 69 | ]); | 69 | ]); |
| 70 | }); | 70 | }); |
| 71 | |||
| 72 | it("includes skills by name and description", () => { | ||
| 73 | const items = [ | ||
| 74 | { | ||
| 75 | id: "skill:claudeAgent:browser", | ||
| 76 | type: "skill", | ||
| 77 | provider: claudeDriver, | ||
| 78 | skill: { | ||
| 79 | name: "browser", | ||
| 80 | path: "/skills/browser/SKILL.md", | ||
| 81 | enabled: true, | ||
| 82 | shortDescription: "Open and control the in-app browser", | ||
| 83 | }, | ||
| 84 | label: "skill:browser", | ||
| 85 | description: "Open and control the in-app browser", | ||
| 86 | }, | ||
| 87 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; | ||
| 88 | |||
| 89 | expect(searchSlashCommandItems(items, "browser").map((item) => item.id)).toEqual([ | ||
| 90 | "skill:claudeAgent:browser", | ||
| 91 | ]); | ||
| 92 | expect(searchSlashCommandItems(items, "control").map((item) => item.id)).toEqual([ | ||
| 93 | "skill:claudeAgent:browser", | ||
| 94 | ]); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("matches skills by display name", () => { | ||
| 98 | const items = [ | ||
| 99 | { | ||
| 100 | id: "skill:claudeAgent:browser", | ||
| 101 | type: "skill", | ||
| 102 | provider: claudeDriver, | ||
| 103 | skill: { | ||
| 104 | name: "browser", | ||
| 105 | displayName: "Web Navigator", | ||
| 106 | path: "/skills/browser/SKILL.md", | ||
| 107 | enabled: true, | ||
| 108 | shortDescription: "Open and control the in-app browser", | ||
| 109 | }, | ||
| 110 | label: "skill:browser", | ||
| 111 | description: "Open and control the in-app browser", | ||
| 112 | }, | ||
| 113 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; | ||
| 114 | |||
| 115 | expect(searchSlashCommandItems(items, "navigator").map((item) => item.id)).toEqual([ | ||
| 116 | "skill:claudeAgent:browser", | ||
| 117 | ]); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("matches skills by their rendered prefix", () => { | ||
| 121 | const items = [ | ||
| 122 | { | ||
| 123 | id: "skill:claudeAgent:browser", | ||
| 124 | type: "skill", | ||
| 125 | provider: claudeDriver, | ||
| 126 | skill: { | ||
| 127 | name: "browser", | ||
| 128 | path: "/skills/browser/SKILL.md", | ||
| 129 | enabled: true, | ||
| 130 | }, | ||
| 131 | label: "skill:browser", | ||
| 132 | description: "Open and control the in-app browser", | ||
| 133 | }, | ||
| 134 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; | ||
| 135 | |||
| 136 | expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([ | ||
| 137 | "skill:claudeAgent:browser", | ||
| 138 | ]); | ||
| 139 | expect(searchSlashCommandItems(items, "/sk")).toEqual([]); | ||
| 140 | expect(searchSlashCommandItems(items, "/ill")).toEqual([]); | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("keeps skills alongside commands for an empty slash query", () => { | ||
| 144 | const items = [ | ||
| 145 | { | ||
| 146 | id: "slash:model", | ||
| 147 | type: "slash-command", | ||
| 148 | command: "model", | ||
| 149 | label: "/model", | ||
| 150 | description: "Switch model", | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | id: "skill:claudeAgent:unslop", | ||
| 154 | type: "skill", | ||
| 155 | provider: claudeDriver, | ||
| 156 | skill: { | ||
| 157 | name: "unslop", | ||
| 158 | path: "/skills/unslop/SKILL.md", | ||
| 159 | enabled: true, | ||
| 160 | }, | ||
| 161 | label: "skill:unslop", | ||
| 162 | description: "Cut AI tells from writing", | ||
| 163 | }, | ||
| 164 | ] satisfies Array<Extract<ComposerCommandItem, { type: "slash-command" | "skill" }>>; | ||
| 165 | |||
| 166 | expect(searchSlashCommandItems(items, "").map((item) => item.id)).toEqual([ | ||
| 167 | "slash:model", | ||
| 168 | "skill:claudeAgent:unslop", | ||
| 169 | ]); | ||
| 170 | }); | ||
| 71 | }); | 171 | }); |
apps/web/src/components/chat/composerSlashCommandSearch.ts+19-10| ... | @@ -5,11 +5,20 @@ import { | ... | @@ -5,11 +5,20 @@ import { |
| 5 | } from "@t3tools/shared/searchRanking"; | 5 | } from "@t3tools/shared/searchRanking"; |
| 6 | 6 | ||
| 7 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; | 7 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; |
| 8 | import { scoreProviderSkill } from "../../providerSkillSearch"; | ||
| 9 | |||
| 10 | type SlashSearchItem = Extract< | ||
| 11 | ComposerCommandItem, | ||
| 12 | { type: "slash-command" | "provider-slash-command" | "skill" } | ||
| 13 | >; | ||
| 14 | |||
| 15 | function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { | ||
| 16 | if (item.type === "skill") { | ||
| 17 | const skillQuery = | ||
| 18 | query === "skill" ? "" : query.startsWith("skill:") ? query.slice("skill:".length) : query; | ||
| 19 | return skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0; | ||
| 20 | } | ||
| 8 | 21 | ||
| 9 | function scoreSlashCommandItem( | ||
| 10 | item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>, | ||
| 11 | query: string, | ||
| 12 | ): number | null { | ||
| 13 | const primaryValue = | 22 | const primaryValue = |
| 14 | item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); | 23 | item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); |
| 15 | const description = item.description.toLowerCase(); | 24 | const description = item.description.toLowerCase(); |
| ... | @@ -43,18 +52,16 @@ function scoreSlashCommandItem( | ... | @@ -43,18 +52,16 @@ function scoreSlashCommandItem( |
| 43 | } | 52 | } |
| 44 | 53 | ||
| 45 | export function searchSlashCommandItems( | 54 | export function searchSlashCommandItems( |
| 46 | items: ReadonlyArray< | 55 | items: ReadonlyArray<SlashSearchItem>, |
| 47 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }> | ||
| 48 | >, | ||
| 49 | query: string, | 56 | query: string, |
| 50 | ): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> { | 57 | ): SlashSearchItem[] { |
| 51 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); | 58 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); |
| 52 | if (!normalizedQuery) { | 59 | if (!normalizedQuery) { |
| 53 | return [...items]; | 60 | return [...items]; |
| 54 | } | 61 | } |
| 55 | 62 | ||
| 56 | const ranked: Array<{ | 63 | const ranked: Array<{ |
| 57 | item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>; | 64 | item: SlashSearchItem; |
| 58 | score: number; | 65 | score: number; |
| 59 | tieBreaker: string; | 66 | tieBreaker: string; |
| 60 | }> = []; | 67 | }> = []; |
| ... | @@ -73,7 +80,9 @@ export function searchSlashCommandItems( | ... | @@ -73,7 +80,9 @@ export function searchSlashCommandItems( |
| 73 | tieBreaker: | 80 | tieBreaker: |
| 74 | item.type === "slash-command" | 81 | item.type === "slash-command" |
| 75 | ? `0\u0000${item.command}` | 82 | ? `0\u0000${item.command}` |
| 76 | : `1\u0000${item.command.name}\u0000${item.provider}`, | 83 | : item.type === "provider-slash-command" |
| 84 | ? `1\u0000${item.command.name}\u0000${item.provider}` | ||
| 85 | : `2\u0000${item.skill.name}\u0000${item.provider}`, | ||
| 77 | }, | 86 | }, |
| 78 | Number.POSITIVE_INFINITY, | 87 | Number.POSITIVE_INFINITY, |
| 79 | ); | 88 | ); |
apps/web/src/providerSkillSearch.test.ts+13| ... | @@ -56,4 +56,17 @@ describe("searchProviderSkills", () => { | ... | @@ -56,4 +56,17 @@ describe("searchProviderSkills", () => { |
| 56 | 56 | ||
| 57 | expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]); | 57 | expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]); |
| 58 | }); | 58 | }); |
| 59 | |||
| 60 | it("returns every enabled skill for an empty query", () => { | ||
| 61 | const skills = [ | ||
| 62 | makeSkill({ name: "unslop" }), | ||
| 63 | makeSkill({ name: "browser" }), | ||
| 64 | makeSkill({ name: "disabled", enabled: false }), | ||
| 65 | ]; | ||
| 66 | |||
| 67 | expect(searchProviderSkills(skills, "").map((skill) => skill.name)).toEqual([ | ||
| 68 | "unslop", | ||
| 69 | "browser", | ||
| 70 | ]); | ||
| 71 | }); | ||
| 59 | }); | 72 | }); |
apps/web/src/providerSkillSearch.ts+1-1| ... | @@ -6,7 +6,7 @@ import { | ... | @@ -6,7 +6,7 @@ import { |
| 6 | scoreQueryMatch, | 6 | scoreQueryMatch, |
| 7 | } from "@t3tools/shared/searchRanking"; | 7 | } from "@t3tools/shared/searchRanking"; |
| 8 | 8 | ||
| 9 | function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null { | 9 | export function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null { |
| 10 | const normalizedName = skill.name.toLowerCase(); | 10 | const normalizedName = skill.name.toLowerCase(); |
| 11 | const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); | 11 | const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); |
| 12 | const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; | 12 | const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; |