authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-28 16:02:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 16:02:58-07:00
loge898008959247befda6b5e6c40079cd6dee33cad
tree1e20cf837d74d033c6fc822489495608c487fd29
parentb654911f8a1d07f8d74fb6dd58ab4f9884326ea2
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show composer menus when starting a task (#8587)

Co-authored-by: Julius Marminge <julius@mac.lan>

6 files changed, 339 insertions(+), 263 deletions(-)

apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1
......@@ -127,6 +127,7 @@ const CommandRow = memo(function CommandRow(props: {
127127
128128 return (
129129 <Pressable
130 accessibilityRole="button"
130131 onPress={props.onPress}
131132 className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
132133 style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+28-1
......@@ -34,6 +34,8 @@ import { ProviderIcon } from "../../components/ProviderIcon";
3434import { SymbolView } from "../../components/AppSymbol";
3535import { AppText as Text } from "../../components/AppText";
3636import { ComposerSurface } from "./ThreadComposer";
37import { ComposerCommandPopover } from "./ComposerCommandPopover";
38import { useComposerCommandMenu } from "./use-composer-command-menu";
3739import {
3840 useThreadSettingsSheetPresentation,
3941 type NavigationWithFinishTransitioning,
......@@ -216,6 +218,19 @@ export function NewTaskDraftScreen(props: {
216218 const isIncomingShareTransferPending = Boolean(
217219 incomingShare && cancelledIncomingShareId !== props.incomingShareId,
218220 );
221 const composerMenu = useComposerCommandMenu({
222 draftMessage: flow.prompt,
223 environmentId: selectedProject?.environmentId ?? null,
224 projectCwd:
225 (flow.workspaceMode === "worktree"
226 ? selectedProject?.workspaceRoot
227 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
228 selectedProviderStatus: flow.selectedProviderStatus,
229 hasThread: false,
230 enabled: isComposerFocused && !isIncomingShareTransferPending,
231 onChangeDraftMessage: flow.setPrompt,
232 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
233 });
219234 usePreventRemove(
220235 (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport,
221236 () => undefined,
......@@ -828,8 +843,10 @@ export function NewTaskDraftScreen(props: {
828843 multiline
829844 scrollEnabled
830845 value={flow.prompt}
831 skills={flow.selectedProviderSkills}
846 skills={flow.selectedProviderStatus?.skills ?? []}
847 selection={composerMenu.selection}
832848 onChangeText={flow.setPrompt}
849 onSelectionChange={composerMenu.onSelectionChange}
833850 onFocus={() => setIsComposerFocused(true)}
834851 onBlur={() => setIsComposerFocused(false)}
835852 onPasteImages={(uris) => void handleNativePasteImages(uris)}
......@@ -964,6 +981,16 @@ export function NewTaskDraftScreen(props: {
964981
965982 const composerDock = (
966983 <View className="bg-sheet px-4 pt-1" style={{ paddingBottom: controlsBottomPadding }}>
984 {composerMenu.trigger && composerMenu.items.length > 0 ? (
985 <View className="mb-2">
986 <ComposerCommandPopover
987 items={composerMenu.items}
988 triggerKind={composerMenu.trigger.kind}
989 isLoading={composerMenu.isLoading}
990 onSelect={composerMenu.onSelect}
991 />
992 </View>
993 ) : null}
967994 <View className="pb-1">{workspaceControls}</View>
968995
969996 <ComposerSurface
apps/mobile/src/features/threads/ThreadComposer.tsx+18-256
......@@ -7,12 +7,6 @@ import type {
77 RuntimeMode,
88 ServerConfig as T3ServerConfig,
99} from "@t3tools/contracts";
10import {
11 detectComposerTrigger,
12 replaceTextRange,
13 serializeComposerFileLink,
14 type ComposerTrigger,
15} from "@t3tools/shared/composerTrigger";
1610import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
1711import type { ReactNode } from "react";
1812import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
......@@ -32,11 +26,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
3226import { AppText as Text } from "../../components/AppText";
3327import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
3428import { GlassSurface } from "../../components/GlassSurface";
35import {
36 ComposerEditor,
37 type ComposerEditorHandle,
38 type ComposerEditorSelection,
39} from "../../components/ComposerEditor";
29import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
4030import {
4131 ComposerInlineControl,
4232 ComposerToolbarButton,
......@@ -49,15 +39,9 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages";
4939import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5040import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5141import type { RemoteClientConnectionState } from "../../lib/connection";
52import {
53 insertRankedSearchResult,
54 normalizeSearchQuery,
55 scoreQueryMatch,
56} from "@t3tools/shared/searchRanking";
5742import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
58import { useComposerPathSearch } from "../../state/use-composer-path-search";
59import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
60import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
43import { ComposerCommandPopover } from "./ComposerCommandPopover";
44import { useComposerCommandMenu } from "./use-composer-command-menu";
6145import {
6246 type ExistingThreadSettingsRouteSession,
6347 useExistingThreadSettingsRoutePresentation,
......@@ -329,196 +313,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
329313 );
330314 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
331315
332 // ── Trigger detection ────────────────────────────────────
333 const [composerSelection, setComposerSelection] = useState(() => ({
334 start: props.draftMessage.length,
335 end: props.draftMessage.length,
336 }));
337
338 const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => {
339 setComposerSelection(selection);
340 }, []);
341 useEffect(() => {
342 const end = props.draftMessage.length;
343 setComposerSelection((selection) => {
344 const start = Math.min(selection.start, end);
345 const selectionEnd = Math.min(selection.end, end);
346 if (start === selection.start && selectionEnd === selection.end) {
347 return selection;
348 }
349 return { start, end: selectionEnd };
350 });
351 }, [props.draftMessage.length]);
352
353 const composerTrigger = useMemo<ComposerTrigger | null>(() => {
354 if (composerSelection.start !== composerSelection.end) {
355 return null;
356 }
357 return detectComposerTrigger(props.draftMessage, composerSelection.end);
358 }, [composerSelection, props.draftMessage]);
359 const pathSearch = useComposerPathSearch({
316 const composerMenu = useComposerCommandMenu({
317 draftMessage: props.draftMessage,
360318 environmentId: props.environmentId,
361 cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
362 query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
319 projectCwd: props.projectCwd,
320 selectedProviderStatus,
321 hasThread: true,
322 onChangeDraftMessage: props.onChangeDraftMessage,
323 onUpdateInteractionMode: props.onUpdateInteractionMode,
363324 });
364
365 const composerMenuItems: ComposerCommandItem[] = useMemo(() => {
366 if (!composerTrigger) return [];
367
368 if (composerTrigger.kind === "slash-command") {
369 const q = composerTrigger.query.toLowerCase();
370 const allBuiltIn = [
371 {
372 id: "cmd:model",
373 type: "slash-command" as const,
374 command: "model",
375 label: "/model",
376 description: "Switch model",
377 },
378 {
379 id: "cmd:plan",
380 type: "slash-command" as const,
381 command: "plan",
382 label: "/plan",
383 description: "Switch to plan mode",
384 },
385 {
386 id: "cmd:default",
387 type: "slash-command" as const,
388 command: "default",
389 label: "/default",
390 description: "Switch to default mode",
391 },
392 ];
393 const builtIn = allBuiltIn.filter((item) => item.command.includes(q));
394
395 const providerCommands: ComposerCommandItem[] = [];
396 for (const cmd of selectedProviderStatus?.slashCommands ?? []) {
397 if (!cmd.name.toLowerCase().includes(q)) continue;
398 providerCommands.push({
399 id: `pcmd:${cmd.name}`,
400 type: "provider-slash-command" as const,
401 command: cmd,
402 label: `/${cmd.name}`,
403 description: cmd.description ?? "",
404 });
405 }
406
407 const skillItems = (selectedProviderStatus?.skills ?? [])
408 .filter((skill) => matchesSlashSkillQuery(skill, q))
409 .map((skill) => ({
410 id: `skill:${skill.name}`,
411 type: "skill" as const,
412 skill,
413 label: `skill:${skill.name}`,
414 description: skill.shortDescription ?? skill.description ?? "",
415 }));
416
417 return [...builtIn, ...providerCommands, ...skillItems];
418 }
419
420 if (composerTrigger.kind === "skill") {
421 const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
422 const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
423 trimLeadingPattern: /^\$+/,
424 });
425
426 if (!normalizedQuery) {
427 return enabledSkills.slice(0, 20).map((skill) => ({
428 id: `skill:${skill.name}`,
429 type: "skill" as const,
430 skill,
431 label: skill.displayName ?? skill.name,
432 description: skill.shortDescription ?? skill.description ?? "",
433 }));
434 }
435
436 const ranked: Array<{
437 item: (typeof enabledSkills)[number];
438 score: number;
439 tieBreaker: string;
440 }> = [];
441 for (const skill of enabledSkills) {
442 const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
443 const scores = [
444 scoreQueryMatch({
445 value: skill.name.toLowerCase(),
446 query: normalizedQuery,
447 exactBase: 0,
448 prefixBase: 2,
449 boundaryBase: 4,
450 includesBase: 6,
451 fuzzyBase: 100,
452 boundaryMarkers: ["-", "_", "/"],
453 }),
454 scoreQueryMatch({
455 value: displayLabel,
456 query: normalizedQuery,
457 exactBase: 1,
458 prefixBase: 3,
459 boundaryBase: 5,
460 includesBase: 7,
461 fuzzyBase: 110,
462 }),
463 scoreQueryMatch({
464 value: skill.shortDescription?.toLowerCase() ?? "",
465 query: normalizedQuery,
466 exactBase: 20,
467 prefixBase: 22,
468 boundaryBase: 24,
469 includesBase: 26,
470 }),
471 scoreQueryMatch({
472 value: skill.description?.toLowerCase() ?? "",
473 query: normalizedQuery,
474 exactBase: 30,
475 prefixBase: 32,
476 boundaryBase: 34,
477 includesBase: 36,
478 }),
479 ].filter((s): s is number => s !== null);
480
481 if (scores.length > 0) {
482 insertRankedSearchResult(
483 ranked,
484 {
485 item: skill,
486 score: Math.min(...scores),
487 tieBreaker: `${displayLabel}\u0000${skill.name}`,
488 },
489 20,
490 );
491 }
492 }
493
494 return ranked.map(({ item: skill }) => ({
495 id: `skill:${skill.name}`,
496 type: "skill" as const,
497 skill,
498 label: skill.displayName ?? skill.name,
499 description: skill.shortDescription ?? skill.description ?? "",
500 }));
501 }
502
503 if (composerTrigger.kind === "path") {
504 return pathSearch.entries.map((entry) => {
505 const parts = entry.path.split("/");
506 return {
507 id: `path:${entry.path}`,
508 type: "path" as const,
509 path: entry.path,
510 kind: entry.kind,
511 label: parts[parts.length - 1] ?? entry.path,
512 description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
513 };
514 });
515 }
516
517 return [];
518 }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
519
520 // ── Handle command selection ──────────────────────────────
521 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
325 const { onSendMessage } = props;
522326
523327 const handleSend = useCallback(async () => {
524328 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
......@@ -548,48 +352,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
548352 props.selectedThread.id,
549353 props.selectedThread.title,
550354 ]);
551 const handleCommandSelect = useCallback(
552 (item: ComposerCommandItem) => {
553 if (!composerTrigger) return;
554
555 if (
556 item.type === "slash-command" &&
557 (item.command === "plan" || item.command === "default")
558 ) {
559 const result = replaceTextRange(
560 draftMessage,
561 composerTrigger.rangeStart,
562 composerTrigger.rangeEnd,
563 "",
564 );
565 setComposerSelection({ start: result.cursor, end: result.cursor });
566 onChangeDraftMessage(result.text);
567 onUpdateInteractionMode(item.command);
568 return;
569 }
570
571 let replacement = "";
572 if (item.type === "path") {
573 replacement = `${serializeComposerFileLink(item.path)} `;
574 } else if (item.type === "skill") {
575 replacement = `$${item.skill.name} `;
576 } else if (item.type === "slash-command") {
577 replacement = `/${item.command} `;
578 } else if (item.type === "provider-slash-command") {
579 replacement = `/${item.command.name} `;
580 }
581
582 const result = replaceTextRange(
583 draftMessage,
584 composerTrigger.rangeStart,
585 composerTrigger.rangeEnd,
586 replacement,
587 );
588 setComposerSelection({ start: result.cursor, end: result.cursor });
589 onChangeDraftMessage(result.text);
590 },
591 [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],
592 );
593355
594356 // ── Model menu ───────────────────────────────────────────
595357 const modelOptions = useMemo(
......@@ -705,13 +467,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
705467 layout={COMPOSER_LAYOUT_TRANSITION}
706468 style={{ maxWidth: props.contentMaxWidth }}
707469 >
708 {composerTrigger && composerMenuItems.length > 0 ? (
470 {composerMenu.trigger && composerMenu.items.length > 0 ? (
709471 <View className="absolute inset-x-0 bottom-full z-10 mb-2">
710472 <ComposerCommandPopover
711 items={composerMenuItems}
712 triggerKind={composerTrigger.kind}
713 isLoading={pathSearch.isPending}
714 onSelect={handleCommandSelect}
473 items={composerMenu.items}
474 triggerKind={composerMenu.trigger.kind}
475 isLoading={composerMenu.isLoading}
476 onSelect={composerMenu.onSelect}
715477 />
716478 </View>
717479 ) : null}
......@@ -766,9 +528,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
766528 multiline
767529 value={props.draftMessage}
768530 skills={selectedProviderStatus?.skills ?? []}
769 selection={composerSelection}
531 selection={composerMenu.selection}
770532 onChangeText={props.onChangeDraftMessage}
771 onSelectionChange={handleSelectionChange}
533 onSelectionChange={composerMenu.onSelectionChange}
772534 onPasteImages={(uris) => void props.onNativePasteImages(uris)}
773535 placeholder={props.placeholder}
774536 onFocus={handleFocus}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+6-6
......@@ -7,7 +7,7 @@ import type {
77 ProviderInteractionMode,
88 ProviderOptionSelection,
99 RuntimeMode,
10 ServerProviderSkill,
10 ServerProvider,
1111} from "@t3tools/contracts";
1212import {
1313 CommandId,
......@@ -156,7 +156,7 @@ type NewTaskFlowContextValue = {
156156 readonly modelOptions: ReadonlyArray<ModelOption>;
157157 readonly selectedModel: ModelSelection | null;
158158 readonly selectedModelOption: ModelOption | null;
159 readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>;
159 readonly selectedProviderStatus: ServerProvider | null;
160160 readonly providerGroups: ReadonlyArray<ProviderGroup>;
161161 readonly filteredBranches: ReadonlyArray<VcsRef>;
162162 readonly reset: () => void;
......@@ -459,11 +459,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
459459 option.selection.instanceId === selectedModel.instanceId &&
460460 option.selection.model === selectedModel.model,
461461 ) ?? null;
462 const selectedProviderSkills = useMemo(
462 const selectedProviderStatus = useMemo(
463463 () =>
464464 selectedEnvironmentServerConfig?.providers.find(
465465 (provider) => provider.instanceId === selectedModel?.instanceId,
466 )?.skills ?? [],
466 ) ?? null,
467467 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
468468 );
469469 const setSelectedModelKey = useCallback(
......@@ -1044,7 +1044,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
10441044 modelOptions,
10451045 selectedModel,
10461046 selectedModelOption,
1047 selectedProviderSkills,
1047 selectedProviderStatus,
10481048 providerGroups,
10491049 filteredBranches,
10501050 reset,
......@@ -1106,7 +1106,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
11061106 selectedModelKey,
11071107 selectedModelOption,
11081108 selectedProjectDraftKey,
1109 selectedProviderSkills,
1109 selectedProviderStatus,
11101110 setSelectedModelOptions,
11111111 selectedProject,
11121112 selectedProjectKey,
apps/mobile/src/features/threads/use-composer-command-menu.ts created+283
......@@ -0,0 +1,283 @@
1import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";
2import {
3 detectComposerTrigger,
4 replaceTextRange,
5 serializeComposerFileLink,
6} from "@t3tools/shared/composerTrigger";
7import {
8 insertRankedSearchResult,
9 normalizeSearchQuery,
10 scoreQueryMatch,
11} from "@t3tools/shared/searchRanking";
12import { useCallback, useEffect, useMemo, useState } from "react";
13
14import type { ComposerEditorSelection } from "../../components/ComposerEditor";
15import { useComposerPathSearch } from "../../state/use-composer-path-search";
16import type { ComposerCommandItem } from "./ComposerCommandPopover";
17import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
18
19/** Shared autocomplete for thread composers and unsent new-task drafts. */
20export function useComposerCommandMenu({
21 draftMessage,
22 environmentId,
23 projectCwd,
24 selectedProviderStatus,
25 hasThread,
26 enabled = true,
27 onChangeDraftMessage,
28 onUpdateInteractionMode,
29}: {
30 readonly draftMessage: string;
31 readonly environmentId: EnvironmentId | null;
32 readonly projectCwd: string | null;
33 readonly selectedProviderStatus: ServerProvider | null;
34 readonly hasThread: boolean;
35 readonly enabled?: boolean;
36 readonly onChangeDraftMessage: (value: string) => void;
37 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
38}) {
39 const [selection, setSelection] = useState(() => ({
40 start: draftMessage.length,
41 end: draftMessage.length,
42 }));
43 const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => {
44 setSelection(nextSelection);
45 }, []);
46 useEffect(() => {
47 const end = draftMessage.length;
48 setSelection((current) => {
49 const start = Math.min(current.start, end);
50 const selectionEnd = Math.min(current.end, end);
51 if (start === current.start && selectionEnd === current.end) {
52 return current;
53 }
54 return { start, end: selectionEnd };
55 });
56 }, [draftMessage.length]);
57
58 const trigger = useMemo(() => {
59 if (!enabled || selection.start !== selection.end) {
60 return null;
61 }
62 return detectComposerTrigger(draftMessage, selection.end);
63 }, [draftMessage, enabled, selection]);
64 const pathSearch = useComposerPathSearch({
65 environmentId,
66 cwd: trigger?.kind === "path" ? projectCwd : null,
67 query: trigger?.kind === "path" ? trigger.query : null,
68 });
69
70 const items = useMemo<ComposerCommandItem[]>(() => {
71 if (!trigger) return [];
72
73 if (trigger.kind === "slash-command") {
74 const q = trigger.query.toLowerCase();
75 const allBuiltIn = [
76 {
77 id: "cmd:model",
78 type: "slash-command" as const,
79 command: "model",
80 label: "/model",
81 description: "Switch model",
82 },
83 {
84 id: "cmd:plan",
85 type: "slash-command" as const,
86 command: "plan",
87 label: "/plan",
88 description: "Switch to plan mode",
89 },
90 {
91 id: "cmd:default",
92 type: "slash-command" as const,
93 command: "default",
94 label: "/default",
95 description: "Switch to default mode",
96 },
97 ];
98 const builtIn = allBuiltIn.filter(
99 (item) =>
100 item.command.includes(q) &&
101 (item.command === "model" || onUpdateInteractionMode !== undefined),
102 );
103
104 const providerCommands: ComposerCommandItem[] = [];
105 for (const command of selectedProviderStatus?.slashCommands ?? []) {
106 if (!command.name.toLowerCase().includes(q)) continue;
107 // Codex feedback uploads an existing thread's session and logs.
108 if (
109 !hasThread &&
110 selectedProviderStatus?.driver === "codex" &&
111 command.name === "feedback"
112 ) {
113 continue;
114 }
115 providerCommands.push({
116 id: `pcmd:${command.name}`,
117 type: "provider-slash-command",
118 command,
119 label: `/${command.name}`,
120 description: command.description ?? "",
121 });
122 }
123
124 const skillItems = (selectedProviderStatus?.skills ?? [])
125 .filter((skill) => matchesSlashSkillQuery(skill, q))
126 .map((skill) => ({
127 id: `skill:${skill.name}`,
128 type: "skill" as const,
129 skill,
130 label: `skill:${skill.name}`,
131 description: skill.shortDescription ?? skill.description ?? "",
132 }));
133
134 return [...builtIn, ...providerCommands, ...skillItems];
135 }
136
137 if (trigger.kind === "skill") {
138 const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled);
139 const normalizedQuery = normalizeSearchQuery(trigger.query, {
140 trimLeadingPattern: /^\$+/,
141 });
142
143 if (!normalizedQuery) {
144 return enabledSkills.slice(0, 20).map((skill) => ({
145 id: `skill:${skill.name}`,
146 type: "skill" as const,
147 skill,
148 label: skill.displayName ?? skill.name,
149 description: skill.shortDescription ?? skill.description ?? "",
150 }));
151 }
152
153 const ranked: Array<{
154 item: (typeof enabledSkills)[number];
155 score: number;
156 tieBreaker: string;
157 }> = [];
158 for (const skill of enabledSkills) {
159 const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
160 const scores = [
161 scoreQueryMatch({
162 value: skill.name.toLowerCase(),
163 query: normalizedQuery,
164 exactBase: 0,
165 prefixBase: 2,
166 boundaryBase: 4,
167 includesBase: 6,
168 fuzzyBase: 100,
169 boundaryMarkers: ["-", "_", "/"],
170 }),
171 scoreQueryMatch({
172 value: displayLabel,
173 query: normalizedQuery,
174 exactBase: 1,
175 prefixBase: 3,
176 boundaryBase: 5,
177 includesBase: 7,
178 fuzzyBase: 110,
179 }),
180 scoreQueryMatch({
181 value: skill.shortDescription?.toLowerCase() ?? "",
182 query: normalizedQuery,
183 exactBase: 20,
184 prefixBase: 22,
185 boundaryBase: 24,
186 includesBase: 26,
187 }),
188 scoreQueryMatch({
189 value: skill.description?.toLowerCase() ?? "",
190 query: normalizedQuery,
191 exactBase: 30,
192 prefixBase: 32,
193 boundaryBase: 34,
194 includesBase: 36,
195 }),
196 ].filter((score): score is number => score !== null);
197
198 if (scores.length > 0) {
199 insertRankedSearchResult(
200 ranked,
201 {
202 item: skill,
203 score: Math.min(...scores),
204 tieBreaker: `${displayLabel}\u0000${skill.name}`,
205 },
206 20,
207 );
208 }
209 }
210
211 return ranked.map(({ item: skill }) => ({
212 id: `skill:${skill.name}`,
213 type: "skill" as const,
214 skill,
215 label: skill.displayName ?? skill.name,
216 description: skill.shortDescription ?? skill.description ?? "",
217 }));
218 }
219
220 if (trigger.kind === "path") {
221 return pathSearch.entries.map((entry) => {
222 const parts = entry.path.split("/");
223 return {
224 id: `path:${entry.path}`,
225 type: "path" as const,
226 path: entry.path,
227 kind: entry.kind,
228 label: parts[parts.length - 1] ?? entry.path,
229 description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
230 };
231 });
232 }
233
234 return [];
235 }, [hasThread, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, trigger]);
236
237 const onSelect = useCallback(
238 (item: ComposerCommandItem) => {
239 if (!trigger) return;
240
241 if (
242 item.type === "slash-command" &&
243 (item.command === "plan" || item.command === "default")
244 ) {
245 const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
246 setSelection({ start: result.cursor, end: result.cursor });
247 onChangeDraftMessage(result.text);
248 onUpdateInteractionMode?.(item.command);
249 return;
250 }
251
252 let replacement = "";
253 if (item.type === "path") {
254 replacement = `${serializeComposerFileLink(item.path)} `;
255 } else if (item.type === "skill") {
256 replacement = `$${item.skill.name} `;
257 } else if (item.type === "slash-command") {
258 replacement = `/${item.command} `;
259 } else if (item.type === "provider-slash-command") {
260 replacement = `/${item.command.name} `;
261 }
262
263 const result = replaceTextRange(
264 draftMessage,
265 trigger.rangeStart,
266 trigger.rangeEnd,
267 replacement,
268 );
269 setSelection({ start: result.cursor, end: result.cursor });
270 onChangeDraftMessage(result.text);
271 },
272 [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger],
273 );
274
275 return {
276 selection,
277 onSelectionChange,
278 trigger,
279 items,
280 isLoading: pathSearch.isPending,
281 onSelect,
282 };
283}
docs/user/composer.md+3
......@@ -19,6 +19,9 @@ when starting a thread or changing an existing thread's model.
1919Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,
2020such as System, Personal, Project, or App.
2121
22On mobile, these menus are available on the **New task** screen before you start a thread. They
23use the skills and commands from the selected environment and provider.
24
2225By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
2326slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
2427same `$name` skill token to your message. The original skill name remains searchable. If the provider