| 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 | 119 | readonly item: ComposerCommandItem; |
| 120 | 120 | readonly onPress: () => void; |
| 121 | 121 | readonly isLast: boolean; |
| 122 | readonly isSlashSkill: boolean; | |
| 122 | 123 | }) { |
| 123 | 124 | const iconName = itemIcon(props.item); |
| 124 | 125 | const iconColor = useThemeColor("--color-icon-subtle"); |
| ... | ... | @@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: { |
| 144 | 145 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> |
| 145 | 146 | ) : null} |
| 146 | 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 | 156 | </Text> |
| 149 | 157 | {props.item.description ? ( |
| 150 | 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 | 189 | item={item} |
| 182 | 190 | onPress={() => props.onSelect(item)} |
| 183 | 191 | isLast={index === props.items.length - 1} |
| 192 | isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"} | |
| 184 | 193 | /> |
| 185 | 194 | ))} |
| 186 | 195 | </ScrollView> |
apps/mobile/src/features/threads/ThreadComposer.tsx+12-1| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 68 | 68 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 69 | 69 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 70 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; | |
| 70 | 71 | import { |
| 71 | 72 | type ExistingThreadSettingsRouteSession, |
| 72 | 73 | useExistingThreadSettingsRoutePresentation, |
| ... | ... | @@ -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 | 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 | 1117 | }), |
| 1118 | 1118 | ); |
| 1119 | 1119 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1120 | const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; | |
| 1121 | if (!query) { | |
| 1122 | return slashCommandItems; | |
| 1123 | } | |
| 1120 | const skillItems = (selectedProviderStatus?.skills ?? []) | |
| 1121 | .filter((skill) => skill.enabled) | |
| 1122 | .map((skill) => ({ | |
| 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 | 1138 | return searchSlashCommandItems(slashCommandItems, query); |
| 1125 | 1139 | } |
| 1126 | 1140 | if (composerTrigger.kind === "skill") { |
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+32| ... | ... | @@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => { |
| 86 | 86 | expect(markup).toContain("<svg"); |
| 87 | 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 | 99 | <ComposerCommandMenuItem |
| 100 | 100 | key={item.id} |
| 101 | 101 | item={item} |
| 102 | triggerKind={props.triggerKind} | |
| 102 | 103 | resolvedTheme={props.resolvedTheme} |
| 103 | 104 | isActive={props.activeItemId === item.id} |
| 104 | 105 | onHighlight={props.onHighlightedItemChange} |
| ... | ... | @@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 130 | 131 | |
| 131 | 132 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 132 | 133 | item: ComposerCommandItem; |
| 134 | triggerKind: ComposerTriggerKind | null; | |
| 133 | 135 | resolvedTheme: "light" | "dark"; |
| 134 | 136 | isActive: boolean; |
| 135 | 137 | onHighlight: (itemId: string | null) => void; |
| ... | ... | @@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 137 | 139 | }) { |
| 138 | 140 | const skillSourceKind = |
| 139 | 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 | 145 | return ( |
| 142 | 146 | <CommandItem |
| ... | ... | @@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 162 | 166 | kind={props.item.pathKind} |
| 163 | 167 | theme={props.resolvedTheme} |
| 164 | 168 | /> |
| 165 | ) : skillSourceKind ? ( | |
| 169 | ) : skillSourceKind && !slashSkill ? ( | |
| 166 | 170 | <SkillSourceIcon kind={skillSourceKind} /> |
| 167 | 171 | ) : null} |
| 168 | 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 | 183 | <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs"> |
| 171 | 184 | {props.item.description} |
| 172 | 185 | </span> |
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+102-2| ... | ... | @@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => { |
| 33 | 33 | description: "Create distinctive, production-grade frontend interfaces", |
| 34 | 34 | }, |
| 35 | 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 | 39 | expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([ |
| ... | ... | @@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => { |
| 61 | 61 | description: "General GitHub help", |
| 62 | 62 | }, |
| 63 | 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 | 67 | expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([ |
| 68 | 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 | 5 | } from "@t3tools/shared/searchRanking"; |
| 6 | 6 | |
| 7 | 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 | 22 | const primaryValue = |
| 14 | 23 | item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); |
| 15 | 24 | const description = item.description.toLowerCase(); |
| ... | ... | @@ -43,18 +52,16 @@ function scoreSlashCommandItem( |
| 43 | 52 | } |
| 44 | 53 | |
| 45 | 54 | export function searchSlashCommandItems( |
| 46 | items: ReadonlyArray< | |
| 47 | Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }> | |
| 48 | >, | |
| 55 | items: ReadonlyArray<SlashSearchItem>, | |
| 49 | 56 | query: string, |
| 50 | ): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> { | |
| 57 | ): SlashSearchItem[] { | |
| 51 | 58 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); |
| 52 | 59 | if (!normalizedQuery) { |
| 53 | 60 | return [...items]; |
| 54 | 61 | } |
| 55 | 62 | |
| 56 | 63 | const ranked: Array<{ |
| 57 | item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>; | |
| 64 | item: SlashSearchItem; | |
| 58 | 65 | score: number; |
| 59 | 66 | tieBreaker: string; |
| 60 | 67 | }> = []; |
| ... | ... | @@ -73,7 +80,9 @@ export function searchSlashCommandItems( |
| 73 | 80 | tieBreaker: |
| 74 | 81 | item.type === "slash-command" |
| 75 | 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 | 87 | Number.POSITIVE_INFINITY, |
| 79 | 88 | ); |
apps/web/src/providerSkillSearch.test.ts+13| ... | ... | @@ -56,4 +56,17 @@ describe("searchProviderSkills", () => { |
| 56 | 56 | |
| 57 | 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 | 6 | scoreQueryMatch, |
| 7 | 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 | 10 | const normalizedName = skill.name.toLowerCase(); |
| 11 | 11 | const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase(); |
| 12 | 12 | const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? ""; |