| author | |
| committer | |
| log | e898008959247befda6b5e6c40079cd6dee33cad |
| tree | 1e20cf837d74d033c6fc822489495608c487fd29 |
| parent | b654911f8a1d07f8d74fb6dd58ab4f9884326ea2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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: { |
| 127 | 127 | |
| 128 | 128 | return ( |
| 129 | 129 | <Pressable |
| 130 | accessibilityRole="button" | |
| 130 | 131 | onPress={props.onPress} |
| 131 | 132 | className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60" |
| 132 | 133 | 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"; |
| 34 | 34 | import { SymbolView } from "../../components/AppSymbol"; |
| 35 | 35 | import { AppText as Text } from "../../components/AppText"; |
| 36 | 36 | import { ComposerSurface } from "./ThreadComposer"; |
| 37 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; | |
| 38 | import { useComposerCommandMenu } from "./use-composer-command-menu"; | |
| 37 | 39 | import { |
| 38 | 40 | useThreadSettingsSheetPresentation, |
| 39 | 41 | type NavigationWithFinishTransitioning, |
| ... | ... | @@ -216,6 +218,19 @@ export function NewTaskDraftScreen(props: { |
| 216 | 218 | const isIncomingShareTransferPending = Boolean( |
| 217 | 219 | incomingShare && cancelledIncomingShareId !== props.incomingShareId, |
| 218 | 220 | ); |
| 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 | }); | |
| 219 | 234 | usePreventRemove( |
| 220 | 235 | (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, |
| 221 | 236 | () => undefined, |
| ... | ... | @@ -828,8 +843,10 @@ export function NewTaskDraftScreen(props: { |
| 828 | 843 | multiline |
| 829 | 844 | scrollEnabled |
| 830 | 845 | value={flow.prompt} |
| 831 | skills={flow.selectedProviderSkills} | |
| 846 | skills={flow.selectedProviderStatus?.skills ?? []} | |
| 847 | selection={composerMenu.selection} | |
| 832 | 848 | onChangeText={flow.setPrompt} |
| 849 | onSelectionChange={composerMenu.onSelectionChange} | |
| 833 | 850 | onFocus={() => setIsComposerFocused(true)} |
| 834 | 851 | onBlur={() => setIsComposerFocused(false)} |
| 835 | 852 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| ... | ... | @@ -964,6 +981,16 @@ export function NewTaskDraftScreen(props: { |
| 964 | 981 | |
| 965 | 982 | const composerDock = ( |
| 966 | 983 | <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} | |
| 967 | 994 | <View className="pb-1">{workspaceControls}</View> |
| 968 | 995 | |
| 969 | 996 | <ComposerSurface |
apps/mobile/src/features/threads/ThreadComposer.tsx+18-256| ... | ... | @@ -7,12 +7,6 @@ import type { |
| 7 | 7 | RuntimeMode, |
| 8 | 8 | ServerConfig as T3ServerConfig, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | import { | |
| 11 | detectComposerTrigger, | |
| 12 | replaceTextRange, | |
| 13 | serializeComposerFileLink, | |
| 14 | type ComposerTrigger, | |
| 15 | } from "@t3tools/shared/composerTrigger"; | |
| 16 | 10 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 17 | 11 | import type { ReactNode } from "react"; |
| 18 | 12 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| ... | ... | @@ -32,11 +26,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 32 | 26 | import { AppText as Text } from "../../components/AppText"; |
| 33 | 27 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 34 | 28 | import { GlassSurface } from "../../components/GlassSurface"; |
| 35 | import { | |
| 36 | ComposerEditor, | |
| 37 | type ComposerEditorHandle, | |
| 38 | type ComposerEditorSelection, | |
| 39 | } from "../../components/ComposerEditor"; | |
| 29 | import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; | |
| 40 | 30 | import { |
| 41 | 31 | ComposerInlineControl, |
| 42 | 32 | ComposerToolbarButton, |
| ... | ... | @@ -49,15 +39,9 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 49 | 39 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 50 | 40 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 51 | 41 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 52 | import { | |
| 53 | insertRankedSearchResult, | |
| 54 | normalizeSearchQuery, | |
| 55 | scoreQueryMatch, | |
| 56 | } from "@t3tools/shared/searchRanking"; | |
| 57 | 42 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 58 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | |
| 59 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | |
| 60 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; | |
| 43 | import { ComposerCommandPopover } from "./ComposerCommandPopover"; | |
| 44 | import { useComposerCommandMenu } from "./use-composer-command-menu"; | |
| 61 | 45 | import { |
| 62 | 46 | type ExistingThreadSettingsRouteSession, |
| 63 | 47 | useExistingThreadSettingsRoutePresentation, |
| ... | ... | @@ -329,196 +313,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 329 | 313 | ); |
| 330 | 314 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); |
| 331 | 315 | |
| 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, | |
| 360 | 318 | 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, | |
| 363 | 324 | }); |
| 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; | |
| 522 | 326 | |
| 523 | 327 | const handleSend = useCallback(async () => { |
| 524 | 328 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| ... | ... | @@ -548,48 +352,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 548 | 352 | props.selectedThread.id, |
| 549 | 353 | props.selectedThread.title, |
| 550 | 354 | ]); |
| 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 | ); | |
| 593 | 355 | |
| 594 | 356 | // ── Model menu ─────────────────────────────────────────── |
| 595 | 357 | const modelOptions = useMemo( |
| ... | ... | @@ -705,13 +467,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 705 | 467 | layout={COMPOSER_LAYOUT_TRANSITION} |
| 706 | 468 | style={{ maxWidth: props.contentMaxWidth }} |
| 707 | 469 | > |
| 708 | {composerTrigger && composerMenuItems.length > 0 ? ( | |
| 470 | {composerMenu.trigger && composerMenu.items.length > 0 ? ( | |
| 709 | 471 | <View className="absolute inset-x-0 bottom-full z-10 mb-2"> |
| 710 | 472 | <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} | |
| 715 | 477 | /> |
| 716 | 478 | </View> |
| 717 | 479 | ) : null} |
| ... | ... | @@ -766,9 +528,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 766 | 528 | multiline |
| 767 | 529 | value={props.draftMessage} |
| 768 | 530 | skills={selectedProviderStatus?.skills ?? []} |
| 769 | selection={composerSelection} | |
| 531 | selection={composerMenu.selection} | |
| 770 | 532 | onChangeText={props.onChangeDraftMessage} |
| 771 | onSelectionChange={handleSelectionChange} | |
| 533 | onSelectionChange={composerMenu.onSelectionChange} | |
| 772 | 534 | onPasteImages={(uris) => void props.onNativePasteImages(uris)} |
| 773 | 535 | placeholder={props.placeholder} |
| 774 | 536 | onFocus={handleFocus} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+6-6| ... | ... | @@ -7,7 +7,7 @@ import type { |
| 7 | 7 | ProviderInteractionMode, |
| 8 | 8 | ProviderOptionSelection, |
| 9 | 9 | RuntimeMode, |
| 10 | ServerProviderSkill, | |
| 10 | ServerProvider, | |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { |
| 13 | 13 | CommandId, |
| ... | ... | @@ -156,7 +156,7 @@ type NewTaskFlowContextValue = { |
| 156 | 156 | readonly modelOptions: ReadonlyArray<ModelOption>; |
| 157 | 157 | readonly selectedModel: ModelSelection | null; |
| 158 | 158 | readonly selectedModelOption: ModelOption | null; |
| 159 | readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>; | |
| 159 | readonly selectedProviderStatus: ServerProvider | null; | |
| 160 | 160 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 161 | 161 | readonly filteredBranches: ReadonlyArray<VcsRef>; |
| 162 | 162 | readonly reset: () => void; |
| ... | ... | @@ -459,11 +459,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 459 | 459 | option.selection.instanceId === selectedModel.instanceId && |
| 460 | 460 | option.selection.model === selectedModel.model, |
| 461 | 461 | ) ?? null; |
| 462 | const selectedProviderSkills = useMemo( | |
| 462 | const selectedProviderStatus = useMemo( | |
| 463 | 463 | () => |
| 464 | 464 | selectedEnvironmentServerConfig?.providers.find( |
| 465 | 465 | (provider) => provider.instanceId === selectedModel?.instanceId, |
| 466 | )?.skills ?? [], | |
| 466 | ) ?? null, | |
| 467 | 467 | [selectedEnvironmentServerConfig, selectedModel?.instanceId], |
| 468 | 468 | ); |
| 469 | 469 | const setSelectedModelKey = useCallback( |
| ... | ... | @@ -1044,7 +1044,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1044 | 1044 | modelOptions, |
| 1045 | 1045 | selectedModel, |
| 1046 | 1046 | selectedModelOption, |
| 1047 | selectedProviderSkills, | |
| 1047 | selectedProviderStatus, | |
| 1048 | 1048 | providerGroups, |
| 1049 | 1049 | filteredBranches, |
| 1050 | 1050 | reset, |
| ... | ... | @@ -1106,7 +1106,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1106 | 1106 | selectedModelKey, |
| 1107 | 1107 | selectedModelOption, |
| 1108 | 1108 | selectedProjectDraftKey, |
| 1109 | selectedProviderSkills, | |
| 1109 | selectedProviderStatus, | |
| 1110 | 1110 | setSelectedModelOptions, |
| 1111 | 1111 | selectedProject, |
| 1112 | 1112 | selectedProjectKey, |
apps/mobile/src/features/threads/use-composer-command-menu.ts created+283| ... | ... | @@ -0,0 +1,283 @@ |
| 1 | import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | detectComposerTrigger, | |
| 4 | replaceTextRange, | |
| 5 | serializeComposerFileLink, | |
| 6 | } from "@t3tools/shared/composerTrigger"; | |
| 7 | import { | |
| 8 | insertRankedSearchResult, | |
| 9 | normalizeSearchQuery, | |
| 10 | scoreQueryMatch, | |
| 11 | } from "@t3tools/shared/searchRanking"; | |
| 12 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 13 | ||
| 14 | import type { ComposerEditorSelection } from "../../components/ComposerEditor"; | |
| 15 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | |
| 16 | import type { ComposerCommandItem } from "./ComposerCommandPopover"; | |
| 17 | import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; | |
| 18 | ||
| 19 | /** Shared autocomplete for thread composers and unsent new-task drafts. */ | |
| 20 | export 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. |
| 19 | 19 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, |
| 20 | 20 | such as System, Personal, Project, or App. |
| 21 | 21 | |
| 22 | On mobile, these menus are available on the **New task** screen before you start a thread. They | |
| 23 | use the skills and commands from the selected environment and provider. | |
| 24 | ||
| 22 | 25 | By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in |
| 23 | 26 | slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the |
| 24 | 27 | same `$name` skill token to your message. The original skill name remains searchable. If the provider |