| ... | @@ -20,7 +20,6 @@ import { | ... | @@ -20,7 +20,6 @@ import { |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 21 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 22 | import { | 22 | import { |
| 23 | forwardRef, | | |
| 24 | memo, | 23 | memo, |
| 25 | useCallback, | 24 | useCallback, |
| 26 | useEffect, | 25 | useEffect, |
| ... | @@ -446,12 +445,13 @@ export interface ChatComposerProps { | ... | @@ -446,12 +445,13 @@ export interface ChatComposerProps { |
| 446 | gitCwd: string | null; | 445 | gitCwd: string | null; |
| 447 | | 446 | |
| 448 | // Refs the parent needs kept in sync | 447 | // Refs the parent needs kept in sync |
| 449 | promptRef: React.MutableRefObject<string>; | 448 | promptRef: React.RefObject<string>; |
| 450 | composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>; | 449 | composerImagesRef: React.RefObject<ComposerImageAttachment[]>; |
| 451 | composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>; | 450 | composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>; |
| | 451 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 452 | | 452 | |
| 453 | // Scroll | 453 | // Scroll |
| 454 | shouldAutoScrollRef: React.MutableRefObject<boolean>; | 454 | shouldAutoScrollRef: React.RefObject<boolean>; |
| 455 | scheduleStickToBottom: () => void; | 455 | scheduleStickToBottom: () => void; |
| 456 | | 456 | |
| 457 | // Callbacks | 457 | // Callbacks |
| ... | @@ -489,427 +489,423 @@ export interface ChatComposerProps { | ... | @@ -489,427 +489,423 @@ export interface ChatComposerProps { |
| 489 | // Component | 489 | // Component |
| 490 | // -------------------------------------------------------------------------- | 490 | // -------------------------------------------------------------------------- |
| 491 | | 491 | |
| 492 | export const ChatComposer = memo( | 492 | export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { |
| 493 | forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) { | 493 | const { |
| 494 | const { | 494 | composerDraftTarget, |
| 495 | composerDraftTarget, | 495 | environmentId, |
| 496 | environmentId, | 496 | routeKind, |
| 497 | routeKind, | 497 | routeThreadRef, |
| 498 | routeThreadRef, | 498 | draftId, |
| 499 | draftId, | 499 | activeThreadId, |
| 500 | activeThreadId, | 500 | activeThreadEnvironmentId: _activeThreadEnvironmentId, |
| 501 | activeThreadEnvironmentId: _activeThreadEnvironmentId, | 501 | activeThread, |
| 502 | activeThread, | 502 | isServerThread: _isServerThread, |
| 503 | isServerThread: _isServerThread, | 503 | isLocalDraftThread: _isLocalDraftThread, |
| 504 | isLocalDraftThread: _isLocalDraftThread, | 504 | phase, |
| 505 | phase, | 505 | isConnecting, |
| 506 | isConnecting, | 506 | isSendBusy, |
| 507 | isSendBusy, | 507 | isPreparingWorktree, |
| 508 | isPreparingWorktree, | 508 | environmentUnavailable, |
| 509 | environmentUnavailable, | 509 | activePendingApproval, |
| 510 | activePendingApproval, | 510 | pendingApprovals, |
| 511 | pendingApprovals, | 511 | pendingUserInputs, |
| 512 | pendingUserInputs, | 512 | activePendingProgress, |
| 513 | activePendingProgress, | 513 | activePendingResolvedAnswers, |
| 514 | activePendingResolvedAnswers, | 514 | activePendingIsResponding, |
| 515 | activePendingIsResponding, | 515 | activePendingDraftAnswers, |
| 516 | activePendingDraftAnswers, | 516 | activePendingQuestionIndex, |
| 517 | activePendingQuestionIndex, | 517 | respondingRequestIds, |
| 518 | respondingRequestIds, | 518 | showPlanFollowUpPrompt, |
| 519 | showPlanFollowUpPrompt, | 519 | activeProposedPlan, |
| 520 | activeProposedPlan, | 520 | activePlan, |
| 521 | activePlan, | 521 | sidebarProposedPlan, |
| 522 | sidebarProposedPlan, | 522 | planSidebarLabel, |
| 523 | planSidebarLabel, | 523 | planSidebarOpen, |
| 524 | planSidebarOpen, | 524 | runtimeMode, |
| 525 | runtimeMode, | 525 | interactionMode, |
| 526 | interactionMode, | 526 | lockedProvider, |
| 527 | lockedProvider, | 527 | providerStatuses, |
| | 528 | activeProjectDefaultModelSelection, |
| | 529 | activeThreadModelSelection, |
| | 530 | activeThreadActivities, |
| | 531 | resolvedTheme, |
| | 532 | settings, |
| | 533 | keybindings, |
| | 534 | terminalOpen, |
| | 535 | gitCwd, |
| | 536 | promptRef, |
| | 537 | composerRef, |
| | 538 | composerImagesRef, |
| | 539 | composerTerminalContextsRef, |
| | 540 | shouldAutoScrollRef, |
| | 541 | scheduleStickToBottom, |
| | 542 | onSend, |
| | 543 | onInterrupt, |
| | 544 | onImplementPlanInNewThread, |
| | 545 | onRespondToApproval, |
| | 546 | onSelectActivePendingUserInputOption, |
| | 547 | onAdvanceActivePendingUserInput, |
| | 548 | onPreviousActivePendingUserInputQuestion, |
| | 549 | onChangeActivePendingUserInputCustomAnswer, |
| | 550 | onProviderModelSelect, |
| | 551 | toggleInteractionMode, |
| | 552 | handleRuntimeModeChange, |
| | 553 | handleInteractionModeChange, |
| | 554 | togglePlanSidebar, |
| | 555 | focusComposer, |
| | 556 | scheduleComposerFocus, |
| | 557 | setThreadError, |
| | 558 | onExpandImage, |
| | 559 | } = props; |
| | 560 | |
| | 561 | // ------------------------------------------------------------------ |
| | 562 | // Store subscriptions (prompt / images / terminal contexts) |
| | 563 | // ------------------------------------------------------------------ |
| | 564 | const composerDraft = useComposerThreadDraft(composerDraftTarget); |
| | 565 | const prompt = composerDraft.prompt; |
| | 566 | const composerImages = composerDraft.images; |
| | 567 | const composerTerminalContexts = composerDraft.terminalContexts; |
| | 568 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| | 569 | |
| | 570 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| | 571 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| | 572 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| | 573 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); |
| | 574 | const insertComposerDraftTerminalContext = useComposerDraftStore( |
| | 575 | (store) => store.insertTerminalContext, |
| | 576 | ); |
| | 577 | const removeComposerDraftTerminalContext = useComposerDraftStore( |
| | 578 | (store) => store.removeTerminalContext, |
| | 579 | ); |
| | 580 | const setComposerDraftTerminalContexts = useComposerDraftStore( |
| | 581 | (store) => store.setTerminalContexts, |
| | 582 | ); |
| | 583 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( |
| | 584 | (store) => store.clearPersistedAttachments, |
| | 585 | ); |
| | 586 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( |
| | 587 | (store) => store.syncPersistedAttachments, |
| | 588 | ); |
| | 589 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); |
| | 590 | |
| | 591 | // ------------------------------------------------------------------ |
| | 592 | // Model state |
| | 593 | // ------------------------------------------------------------------ |
| | 594 | // Instance-aware projection of the wire provider list. One entry per |
| | 595 | // configured instance (default built-in + any custom `providerInstances.*`), |
| | 596 | // sorted default-first per driver kind for a stable picker order. |
| | 597 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| | 598 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), |
| | 599 | [providerStatuses], |
| | 600 | ); |
| | 601 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| | 602 | const threadProvider = |
| | 603 | activeThread?.session?.providerInstanceId ?? |
| | 604 | activeThreadModelSelection?.instanceId ?? |
| | 605 | activeProjectDefaultModelSelection?.instanceId ?? |
| | 606 | null; |
| | 607 | const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; |
| | 608 | |
| | 609 | const unlockedSelectedProvider = |
| | 610 | resolveProviderDriverKindForInstanceSelection( |
| | 611 | providerInstanceEntries, |
| 528 | providerStatuses, | 612 | providerStatuses, |
| 529 | activeProjectDefaultModelSelection, | 613 | explicitSelectedInstanceId, |
| 530 | activeThreadModelSelection, | 614 | ) ?? ProviderDriverKind.make("codex"); |
| 531 | activeThreadActivities, | 615 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 532 | resolvedTheme, | 616 | const lockedContinuationGroupKey = useMemo((): string | null => { |
| 533 | settings, | 617 | if (!lockedProvider || !activeThread) return null; |
| 534 | keybindings, | 618 | const lockedInstanceId = |
| 535 | terminalOpen, | 619 | activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; |
| 536 | gitCwd, | 620 | if (!lockedInstanceId) return null; |
| 537 | promptRef, | 621 | return ( |
| 538 | composerImagesRef, | 622 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) |
| 539 | composerTerminalContextsRef, | 623 | ?.continuationGroupKey ?? null |
| 540 | shouldAutoScrollRef, | | |
| 541 | scheduleStickToBottom, | | |
| 542 | onSend, | | |
| 543 | onInterrupt, | | |
| 544 | onImplementPlanInNewThread, | | |
| 545 | onRespondToApproval, | | |
| 546 | onSelectActivePendingUserInputOption, | | |
| 547 | onAdvanceActivePendingUserInput, | | |
| 548 | onPreviousActivePendingUserInputQuestion, | | |
| 549 | onChangeActivePendingUserInputCustomAnswer, | | |
| 550 | onProviderModelSelect, | | |
| 551 | toggleInteractionMode, | | |
| 552 | handleRuntimeModeChange, | | |
| 553 | handleInteractionModeChange, | | |
| 554 | togglePlanSidebar, | | |
| 555 | focusComposer, | | |
| 556 | scheduleComposerFocus, | | |
| 557 | setThreadError, | | |
| 558 | onExpandImage, | | |
| 559 | } = props; | | |
| 560 | | | |
| 561 | // ------------------------------------------------------------------ | | |
| 562 | // Store subscriptions (prompt / images / terminal contexts) | | |
| 563 | // ------------------------------------------------------------------ | | |
| 564 | const composerDraft = useComposerThreadDraft(composerDraftTarget); | | |
| 565 | const prompt = composerDraft.prompt; | | |
| 566 | const composerImages = composerDraft.images; | | |
| 567 | const composerTerminalContexts = composerDraft.terminalContexts; | | |
| 568 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | | |
| 569 | | | |
| 570 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | | |
| 571 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | | |
| 572 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); | | |
| 573 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); | | |
| 574 | const insertComposerDraftTerminalContext = useComposerDraftStore( | | |
| 575 | (store) => store.insertTerminalContext, | | |
| 576 | ); | | |
| 577 | const removeComposerDraftTerminalContext = useComposerDraftStore( | | |
| 578 | (store) => store.removeTerminalContext, | | |
| 579 | ); | | |
| 580 | const setComposerDraftTerminalContexts = useComposerDraftStore( | | |
| 581 | (store) => store.setTerminalContexts, | | |
| 582 | ); | | |
| 583 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( | | |
| 584 | (store) => store.clearPersistedAttachments, | | |
| 585 | ); | | |
| 586 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( | | |
| 587 | (store) => store.syncPersistedAttachments, | | |
| 588 | ); | | |
| 589 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); | | |
| 590 | | | |
| 591 | // ------------------------------------------------------------------ | | |
| 592 | // Model state | | |
| 593 | // ------------------------------------------------------------------ | | |
| 594 | // Instance-aware projection of the wire provider list. One entry per | | |
| 595 | // configured instance (default built-in + any custom `providerInstances.*`), | | |
| 596 | // sorted default-first per driver kind for a stable picker order. | | |
| 597 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( | | |
| 598 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), | | |
| 599 | [providerStatuses], | | |
| 600 | ); | 624 | ); |
| 601 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | 625 | }, [ |
| 602 | const threadProvider = | 626 | activeThread, |
| 603 | activeThread?.session?.providerInstanceId ?? | 627 | activeThreadModelSelection?.instanceId, |
| 604 | activeThreadModelSelection?.instanceId ?? | 628 | lockedProvider, |
| 605 | activeProjectDefaultModelSelection?.instanceId ?? | 629 | providerInstanceEntries, |
| 606 | null; | 630 | ]); |
| 607 | const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; | 631 | |
| 608 | | 632 | // Resolve which configured instance the composer is currently targeting. |
| 609 | const unlockedSelectedProvider = | 633 | // Priority: |
| 610 | resolveProviderDriverKindForInstanceSelection( | 634 | // 1. The composer draft's `activeProvider` — the user's unsaved pick |
| 611 | providerInstanceEntries, | 635 | // from the model picker (must win, otherwise the UI appears to |
| 612 | providerStatuses, | 636 | // ignore picker selections). |
| 613 | explicitSelectedInstanceId, | 637 | // 2. Thread's persisted instance id (server-side saved selection). |
| 614 | ) ?? ProviderDriverKind.make("codex"); | 638 | // 3. Project default's instance id. |
| 615 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | 639 | // 4. First enabled entry matching the current driver kind. |
| 616 | const lockedContinuationGroupKey = useMemo((): string | null => { | 640 | // 5. First enabled entry overall / default instance for the kind. |
| 617 | if (!lockedProvider || !activeThread) return null; | 641 | // |
| 618 | const lockedInstanceId = | 642 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { |
| 619 | activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; | 643 | const candidates: Array<string | null | undefined> = [ |
| 620 | if (!lockedInstanceId) return null; | 644 | composerDraft.activeProvider, |
| 621 | return ( | 645 | activeThread?.session?.providerInstanceId, |
| 622 | providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) | | |
| 623 | ?.continuationGroupKey ?? null | | |
| 624 | ); | | |
| 625 | }, [ | | |
| 626 | activeThread, | | |
| 627 | activeThreadModelSelection?.instanceId, | 646 | activeThreadModelSelection?.instanceId, |
| 628 | lockedProvider, | 647 | activeProjectDefaultModelSelection?.instanceId, |
| 629 | providerInstanceEntries, | 648 | ]; |
| 630 | ]); | 649 | for (const candidate of candidates) { |
| 631 | | 650 | if (!candidate) continue; |
| 632 | // Resolve which configured instance the composer is currently targeting. | 651 | const match = providerInstanceEntries.find( |
| 633 | // Priority: | 652 | (entry) => entry.instanceId === candidate && entry.enabled, |
| 634 | // 1. The composer draft's `activeProvider` — the user's unsaved pick | 653 | ); |
| 635 | // from the model picker (must win, otherwise the UI appears to | 654 | if (match) { |
| 636 | // ignore picker selections). | 655 | // When locked to a specific driver kind, ignore persisted instance |
| 637 | // 2. Thread's persisted instance id (server-side saved selection). | 656 | // ids from a different kind or continuation group. |
| 638 | // 3. Project default's instance id. | 657 | if (lockedProvider && match.driverKind !== lockedProvider) continue; |
| 639 | // 4. First enabled entry matching the current driver kind. | 658 | if ( |
| 640 | // 5. First enabled entry overall / default instance for the kind. | 659 | lockedContinuationGroupKey && |
| 641 | // | 660 | match.continuationGroupKey !== lockedContinuationGroupKey |
| 642 | const selectedInstanceId = useMemo<ProviderInstanceId>(() => { | 661 | ) { |
| 643 | const candidates: Array<string | null | undefined> = [ | 662 | continue; |
| 644 | composerDraft.activeProvider, | | |
| 645 | activeThread?.session?.providerInstanceId, | | |
| 646 | activeThreadModelSelection?.instanceId, | | |
| 647 | activeProjectDefaultModelSelection?.instanceId, | | |
| 648 | ]; | | |
| 649 | for (const candidate of candidates) { | | |
| 650 | if (!candidate) continue; | | |
| 651 | const match = providerInstanceEntries.find( | | |
| 652 | (entry) => entry.instanceId === candidate && entry.enabled, | | |
| 653 | ); | | |
| 654 | if (match) { | | |
| 655 | // When locked to a specific driver kind, ignore persisted instance | | |
| 656 | // ids from a different kind or continuation group. | | |
| 657 | if (lockedProvider && match.driverKind !== lockedProvider) continue; | | |
| 658 | if ( | | |
| 659 | lockedContinuationGroupKey && | | |
| 660 | match.continuationGroupKey !== lockedContinuationGroupKey | | |
| 661 | ) { | | |
| 662 | continue; | | |
| 663 | } | | |
| 664 | return match.instanceId; | | |
| 665 | } | 663 | } |
| | 664 | return match.instanceId; |
| 666 | } | 665 | } |
| 667 | if (explicitSelectedInstanceId) { | 666 | } |
| 668 | return ProviderInstanceId.make(explicitSelectedInstanceId); | 667 | if (explicitSelectedInstanceId) { |
| 669 | } | 668 | return ProviderInstanceId.make(explicitSelectedInstanceId); |
| 670 | const byKind = providerInstanceEntries.find( | 669 | } |
| 671 | (entry) => | 670 | const byKind = providerInstanceEntries.find( |
| 672 | entry.enabled && | 671 | (entry) => |
| 673 | entry.driverKind === selectedProvider && | 672 | entry.enabled && |
| 674 | (!lockedContinuationGroupKey || | 673 | entry.driverKind === selectedProvider && |
| 675 | entry.continuationGroupKey === lockedContinuationGroupKey), | 674 | (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), |
| 676 | ); | | |
| 677 | if (byKind) return byKind.instanceId; | | |
| 678 | const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); | | |
| 679 | return ( | | |
| 680 | anyEnabled?.instanceId ?? | | |
| 681 | providerInstanceEntries[0]?.instanceId ?? | | |
| 682 | activeThreadModelSelection?.instanceId ?? | | |
| 683 | activeProjectDefaultModelSelection?.instanceId ?? | | |
| 684 | ProviderInstanceId.make("codex") | | |
| 685 | ); | | |
| 686 | }, [ | | |
| 687 | activeProjectDefaultModelSelection?.instanceId, | | |
| 688 | activeThread?.session?.providerInstanceId, | | |
| 689 | activeThreadModelSelection?.instanceId, | | |
| 690 | composerDraft.activeProvider, | | |
| 691 | explicitSelectedInstanceId, | | |
| 692 | lockedContinuationGroupKey, | | |
| 693 | lockedProvider, | | |
| 694 | providerInstanceEntries, | | |
| 695 | selectedProvider, | | |
| 696 | ]); | | |
| 697 | | | |
| 698 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | | |
| 699 | threadRef: composerDraftTarget, | | |
| 700 | providers: providerStatuses, | | |
| 701 | selectedProvider, | | |
| 702 | selectedInstanceId, | | |
| 703 | threadModelSelection: activeThreadModelSelection, | | |
| 704 | projectModelSelection: activeProjectDefaultModelSelection, | | |
| 705 | settings, | | |
| 706 | }); | | |
| 707 | | | |
| 708 | // Resolve the active instance's snapshot by `instanceId` so a custom | | |
| 709 | // instance gets its own slash commands, skills, and model list — not | | |
| 710 | // the first snapshot for the same driver kind. | | |
| 711 | const selectedProviderEntry = useMemo( | | |
| 712 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), | | |
| 713 | [providerInstanceEntries, selectedInstanceId], | | |
| 714 | ); | 675 | ); |
| 715 | const selectedProviderStatus = useMemo( | 676 | if (byKind) return byKind.instanceId; |
| 716 | () => selectedProviderEntry?.snapshot ?? null, | 677 | const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); |
| 717 | [selectedProviderEntry], | 678 | return ( |
| 718 | ); | 679 | anyEnabled?.instanceId ?? |
| 719 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( | 680 | providerInstanceEntries[0]?.instanceId ?? |
| 720 | () => selectedProviderEntry?.models ?? [], | 681 | activeThreadModelSelection?.instanceId ?? |
| 721 | [selectedProviderEntry], | 682 | activeProjectDefaultModelSelection?.instanceId ?? |
| | 683 | ProviderInstanceId.make("codex") |
| 722 | ); | 684 | ); |
| | 685 | }, [ |
| | 686 | activeProjectDefaultModelSelection?.instanceId, |
| | 687 | activeThread?.session?.providerInstanceId, |
| | 688 | activeThreadModelSelection?.instanceId, |
| | 689 | composerDraft.activeProvider, |
| | 690 | explicitSelectedInstanceId, |
| | 691 | lockedContinuationGroupKey, |
| | 692 | lockedProvider, |
| | 693 | providerInstanceEntries, |
| | 694 | selectedProvider, |
| | 695 | ]); |
| | 696 | |
| | 697 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| | 698 | threadRef: composerDraftTarget, |
| | 699 | providers: providerStatuses, |
| | 700 | selectedProvider, |
| | 701 | selectedInstanceId, |
| | 702 | threadModelSelection: activeThreadModelSelection, |
| | 703 | projectModelSelection: activeProjectDefaultModelSelection, |
| | 704 | settings, |
| | 705 | }); |
| 723 | | 706 | |
| 724 | const composerProviderState = useMemo( | 707 | // Resolve the active instance's snapshot by `instanceId` so a custom |
| 725 | () => | 708 | // instance gets its own slash commands, skills, and model list — not |
| 726 | getComposerProviderState({ | 709 | // the first snapshot for the same driver kind. |
| 727 | provider: selectedProvider, | 710 | const selectedProviderEntry = useMemo( |
| 728 | model: selectedModel, | 711 | () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), |
| 729 | models: selectedProviderModels, | 712 | [providerInstanceEntries, selectedInstanceId], |
| 730 | prompt, | 713 | ); |
| 731 | modelOptions: composerModelOptions?.[selectedProvider], | 714 | const selectedProviderStatus = useMemo( |
| 732 | }), | 715 | () => selectedProviderEntry?.snapshot ?? null, |
| 733 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], | 716 | [selectedProviderEntry], |
| 734 | ); | 717 | ); |
| | 718 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| | 719 | () => selectedProviderEntry?.models ?? [], |
| | 720 | [selectedProviderEntry], |
| | 721 | ); |
| 735 | | 722 | |
| 736 | const selectedPromptEffort = composerProviderState.promptEffort; | 723 | const composerProviderState = useMemo( |
| 737 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | 724 | () => |
| 738 | const composerProviderControls = useMemo( | 725 | getComposerProviderState({ |
| 739 | () => ({ | 726 | provider: selectedProvider, |
| 740 | showInteractionModeToggle: getProviderInteractionModeToggle( | 727 | model: selectedModel, |
| 741 | providerStatuses, | 728 | models: selectedProviderModels, |
| 742 | selectedProvider, | 729 | prompt, |
| 743 | ), | 730 | modelOptions: composerModelOptions?.[selectedProvider], |
| 744 | }), | 731 | }), |
| 745 | [providerStatuses, selectedProvider], | 732 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], |
| 746 | ); | 733 | ); |
| 747 | const selectedModelSelection = useMemo<ModelSelection>( | | |
| 748 | () => | | |
| 749 | createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), | | |
| 750 | [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], | | |
| 751 | ); | | |
| 752 | const selectedModelForPicker = selectedModel; | | |
| 753 | // Instance-keyed option list so the picker can show each configured | | |
| 754 | // instance (built-in + custom) as a first-class sidebar entry. The | | |
| 755 | // options are server-reported models plus that exact instance's | | |
| 756 | // configured custom models; selected slugs are not injected into lists. | | |
| 757 | const modelOptionsByInstance = useMemo< | | |
| 758 | ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>> | | |
| 759 | >(() => { | | |
| 760 | const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); | | |
| 761 | for (const entry of providerInstanceEntries) { | | |
| 762 | out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); | | |
| 763 | } | | |
| 764 | return out; | | |
| 765 | }, [providerInstanceEntries, settings]); | | |
| 766 | const selectedModelForPickerWithCustomFallback = useMemo(() => { | | |
| 767 | const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; | | |
| 768 | return currentOptions.some((option) => option.slug === selectedModelForPicker) | | |
| 769 | ? selectedModelForPicker | | |
| 770 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | | |
| 771 | }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]); | | |
| 772 | | | |
| 773 | // ------------------------------------------------------------------ | | |
| 774 | // Context window | | |
| 775 | // ------------------------------------------------------------------ | | |
| 776 | const activeContextWindow = useMemo( | | |
| 777 | () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), | | |
| 778 | [activeThreadActivities], | | |
| 779 | ); | | |
| 780 | | 734 | |
| 781 | // ------------------------------------------------------------------ | 735 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 782 | // Composer-local state | 736 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| 783 | // ------------------------------------------------------------------ | 737 | const composerProviderControls = useMemo( |
| 784 | const [composerCursor, setComposerCursor] = useState(() => | 738 | () => ({ |
| 785 | collapseExpandedComposerCursor(prompt, prompt.length), | 739 | showInteractionModeToggle: getProviderInteractionModeToggle( |
| 786 | ); | 740 | providerStatuses, |
| 787 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | 741 | selectedProvider, |
| 788 | detectComposerTrigger(prompt, prompt.length), | 742 | ), |
| 789 | ); | 743 | }), |
| 790 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 744 | [providerStatuses, selectedProvider], |
| 791 | const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>( | 745 | ); |
| 792 | null, | 746 | const selectedModelSelection = useMemo<ModelSelection>( |
| 793 | ); | 747 | () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), |
| 794 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 748 | [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], |
| 795 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); | 749 | ); |
| 796 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | 750 | const selectedModelForPicker = selectedModel; |
| 797 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); | 751 | // Instance-keyed option list so the picker can show each configured |
| 798 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 752 | // instance (built-in + custom) as a first-class sidebar entry. The |
| 799 | const isMobileViewport = useMediaQuery("max-sm"); | 753 | // options are server-reported models plus that exact instance's |
| 800 | const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; | 754 | // configured custom models; selected slugs are not injected into lists. |
| 801 | | 755 | const modelOptionsByInstance = useMemo< |
| 802 | // ------------------------------------------------------------------ | 756 | ReadonlyMap<ProviderInstanceId, ReadonlyArray<AppModelOption>> |
| 803 | // Refs | 757 | >(() => { |
| 804 | // ------------------------------------------------------------------ | 758 | const out = new Map<ProviderInstanceId, ReadonlyArray<AppModelOption>>(); |
| 805 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 759 | for (const entry of providerInstanceEntries) { |
| 806 | const composerFormRef = useRef<HTMLFormElement>(null); | 760 | out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); |
| 807 | const composerSurfaceRef = useRef<HTMLDivElement>(null); | 761 | } |
| 808 | const composerFormHeightRef = useRef(0); | 762 | return out; |
| 809 | const composerSelectLockRef = useRef(false); | 763 | }, [providerInstanceEntries, settings]); |
| 810 | const composerMenuOpenRef = useRef(false); | 764 | const selectedModelForPickerWithCustomFallback = useMemo(() => { |
| 811 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | 765 | const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; |
| 812 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); | 766 | return currentOptions.some((option) => option.slug === selectedModelForPicker) |
| 813 | const composerBlurFrameRef = useRef<number | null>(null); | 767 | ? selectedModelForPicker |
| 814 | const mobileComposerExpandFrameRef = useRef<number | null>(null); | 768 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); |
| 815 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); | 769 | }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]); |
| 816 | const mobileComposerExpandInFlightRef = useRef(false); | 770 | |
| 817 | const dragDepthRef = useRef(0); | 771 | // ------------------------------------------------------------------ |
| 818 | | 772 | // Context window |
| 819 | // ------------------------------------------------------------------ | 773 | // ------------------------------------------------------------------ |
| 820 | // Derived: composer send state | 774 | const activeContextWindow = useMemo( |
| 821 | // ------------------------------------------------------------------ | 775 | () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), |
| 822 | const composerSendState = useMemo( | 776 | [activeThreadActivities], |
| 823 | () => | 777 | ); |
| 824 | deriveComposerSendState({ | | |
| 825 | prompt, | | |
| 826 | imageCount: composerImages.length, | | |
| 827 | terminalContexts: composerTerminalContexts, | | |
| 828 | }), | | |
| 829 | [composerImages.length, composerTerminalContexts, prompt], | | |
| 830 | ); | | |
| 831 | | 778 | |
| 832 | // ------------------------------------------------------------------ | 779 | // ------------------------------------------------------------------ |
| 833 | // Derived: composer trigger / menu | 780 | // Composer-local state |
| 834 | // ------------------------------------------------------------------ | 781 | // ------------------------------------------------------------------ |
| 835 | const composerTriggerKind = composerTrigger?.kind ?? null; | 782 | const [composerCursor, setComposerCursor] = useState(() => |
| 836 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 783 | collapseExpandedComposerCursor(prompt, prompt.length), |
| 837 | const isPathTrigger = composerTriggerKind === "path"; | 784 | ); |
| 838 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | 785 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => |
| 839 | pathTriggerQuery, | 786 | detectComposerTrigger(prompt, prompt.length), |
| 840 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | 787 | ); |
| 841 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 788 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 842 | ); | 789 | const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>( |
| 843 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 790 | null, |
| 844 | const workspaceEntriesQuery = useQuery( | 791 | ); |
| 845 | projectSearchEntriesQueryOptions({ | 792 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 846 | environmentId, | 793 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); |
| 847 | cwd: gitCwd, | 794 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); |
| 848 | query: effectivePathQuery, | 795 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); |
| 849 | enabled: isPathTrigger, | 796 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 850 | limit: 80, | 797 | const isMobileViewport = useMediaQuery("max-sm"); |
| | 798 | const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; |
| | 799 | |
| | 800 | // ------------------------------------------------------------------ |
| | 801 | // Refs |
| | 802 | // ------------------------------------------------------------------ |
| | 803 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| | 804 | const composerFormRef = useRef<HTMLFormElement>(null); |
| | 805 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| | 806 | const composerFormHeightRef = useRef(0); |
| | 807 | const composerSelectLockRef = useRef(false); |
| | 808 | const composerMenuOpenRef = useRef(false); |
| | 809 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| | 810 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); |
| | 811 | const composerBlurFrameRef = useRef<number | null>(null); |
| | 812 | const mobileComposerExpandFrameRef = useRef<number | null>(null); |
| | 813 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); |
| | 814 | const mobileComposerExpandInFlightRef = useRef(false); |
| | 815 | const dragDepthRef = useRef(0); |
| | 816 | |
| | 817 | // ------------------------------------------------------------------ |
| | 818 | // Derived: composer send state |
| | 819 | // ------------------------------------------------------------------ |
| | 820 | const composerSendState = useMemo( |
| | 821 | () => |
| | 822 | deriveComposerSendState({ |
| | 823 | prompt, |
| | 824 | imageCount: composerImages.length, |
| | 825 | terminalContexts: composerTerminalContexts, |
| 851 | }), | 826 | }), |
| 852 | ); | 827 | [composerImages.length, composerTerminalContexts, prompt], |
| 853 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | 828 | ); |
| 854 | | 829 | |
| 855 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | 830 | // ------------------------------------------------------------------ |
| 856 | if (!composerTrigger) return []; | 831 | // Derived: composer trigger / menu |
| 857 | if (composerTrigger.kind === "path") { | 832 | // ------------------------------------------------------------------ |
| 858 | return workspaceEntries.map((entry) => ({ | 833 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 859 | id: `path:${entry.kind}:${entry.path}`, | 834 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 860 | type: "path", | 835 | const isPathTrigger = composerTriggerKind === "path"; |
| 861 | path: entry.path, | 836 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( |
| 862 | pathKind: entry.kind, | 837 | pathTriggerQuery, |
| 863 | label: basenameOfPath(entry.path), | 838 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, |
| 864 | description: entry.parentPath ?? "", | 839 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 865 | })); | 840 | ); |
| 866 | } | 841 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 867 | if (composerTrigger.kind === "slash-command") { | 842 | const workspaceEntriesQuery = useQuery( |
| 868 | const builtInSlashCommandItems = [ | 843 | projectSearchEntriesQueryOptions({ |
| 869 | { | 844 | environmentId, |
| 870 | id: "slash:model", | 845 | cwd: gitCwd, |
| 871 | type: "slash-command", | 846 | query: effectivePathQuery, |
| 872 | command: "model", | 847 | enabled: isPathTrigger, |
| 873 | label: "/model", | 848 | limit: 80, |
| 874 | description: "Switch response model for this thread", | 849 | }), |
| 875 | }, | 850 | ); |
| 876 | { | 851 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; |
| 877 | id: "slash:plan", | 852 | |
| 878 | type: "slash-command", | 853 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 879 | command: "plan", | 854 | if (!composerTrigger) return []; |
| 880 | label: "/plan", | 855 | if (composerTrigger.kind === "path") { |
| 881 | description: "Switch this thread into plan mode", | 856 | return workspaceEntries.map((entry) => ({ |
| 882 | }, | 857 | id: `path:${entry.kind}:${entry.path}`, |
| 883 | { | 858 | type: "path", |
| 884 | id: "slash:default", | 859 | path: entry.path, |
| 885 | type: "slash-command", | 860 | pathKind: entry.kind, |
| 886 | command: "default", | 861 | label: basenameOfPath(entry.path), |
| 887 | label: "/default", | 862 | description: entry.parentPath ?? "", |
| 888 | description: "Switch this thread back to normal build mode", | 863 | })); |
| 889 | }, | 864 | } |
| 890 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; | 865 | if (composerTrigger.kind === "slash-command") { |
| 891 | const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( | 866 | const builtInSlashCommandItems = [ |
| 892 | (command) => ({ | 867 | { |
| 893 | id: `provider-slash-command:${selectedProvider}:${command.name}`, | 868 | id: "slash:model", |
| 894 | type: "provider-slash-command" as const, | 869 | type: "slash-command", |
| 895 | provider: selectedProvider, | 870 | command: "model", |
| 896 | command, | 871 | label: "/model", |
| 897 | label: `/${command.name}`, | 872 | description: "Switch response model for this thread", |
| 898 | description: command.description ?? command.input?.hint ?? "Run provider command", | 873 | }, |
| 899 | }), | 874 | { |
| 900 | ); | 875 | id: "slash:plan", |
| 901 | const query = composerTrigger.query.trim().toLowerCase(); | 876 | type: "slash-command", |
| 902 | const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; | 877 | command: "plan", |
| 903 | if (!query) { | 878 | label: "/plan", |
| 904 | return slashCommandItems; | 879 | description: "Switch this thread into plan mode", |
| 905 | } | 880 | }, |
| 906 | return searchSlashCommandItems(slashCommandItems, query); | 881 | { |
| | 882 | id: "slash:default", |
| | 883 | type: "slash-command", |
| | 884 | command: "default", |
| | 885 | label: "/default", |
| | 886 | description: "Switch this thread back to normal build mode", |
| | 887 | }, |
| | 888 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| | 889 | const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( |
| | 890 | (command) => ({ |
| | 891 | id: `provider-slash-command:${selectedProvider}:${command.name}`, |
| | 892 | type: "provider-slash-command" as const, |
| | 893 | provider: selectedProvider, |
| | 894 | command, |
| | 895 | label: `/${command.name}`, |
| | 896 | description: command.description ?? command.input?.hint ?? "Run provider command", |
| | 897 | }), |
| | 898 | ); |
| | 899 | const query = composerTrigger.query.trim().toLowerCase(); |
| | 900 | const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; |
| | 901 | if (!query) { |
| | 902 | return slashCommandItems; |
| 907 | } | 903 | } |
| 908 | if (composerTrigger.kind === "skill") { | 904 | return searchSlashCommandItems(slashCommandItems, query); |
| 909 | return searchProviderSkills( | 905 | } |
| 910 | selectedProviderStatus?.skills ?? [], | 906 | if (composerTrigger.kind === "skill") { |
| 911 | composerTrigger.query, | 907 | return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( |
| 912 | ).map((skill) => ({ | 908 | (skill) => ({ |
| 913 | id: `skill:${selectedProvider}:${skill.name}`, | 909 | id: `skill:${selectedProvider}:${skill.name}`, |
| 914 | type: "skill" as const, | 910 | type: "skill" as const, |
| 915 | provider: selectedProvider, | 911 | provider: selectedProvider, |
| ... | @@ -919,1125 +915,1084 @@ export const ChatComposer = memo( | ... | @@ -919,1125 +915,1084 @@ export const ChatComposer = memo( |
| 919 | skill.shortDescription ?? | 915 | skill.shortDescription ?? |
| 920 | skill.description ?? | 916 | skill.description ?? |
| 921 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | 917 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), |
| 922 | })); | 918 | }), |
| 923 | } | 919 | ); |
| 924 | return []; | 920 | } |
| 925 | }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); | 921 | return []; |
| 926 | | 922 | }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); |
| 927 | const composerMenuOpen = Boolean(composerTrigger); | 923 | |
| 928 | const composerMenuSearchKey = composerTrigger | 924 | const composerMenuOpen = Boolean(composerTrigger); |
| 929 | ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` | 925 | const composerMenuSearchKey = composerTrigger |
| 930 | : null; | 926 | ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` |
| 931 | const activeComposerMenuItem = useMemo(() => { | 927 | : null; |
| 932 | const activeItemId = resolveComposerMenuActiveItemId({ | 928 | const activeComposerMenuItem = useMemo(() => { |
| 933 | items: composerMenuItems, | 929 | const activeItemId = resolveComposerMenuActiveItemId({ |
| 934 | highlightedItemId: composerHighlightedItemId, | 930 | items: composerMenuItems, |
| 935 | currentSearchKey: composerMenuSearchKey, | 931 | highlightedItemId: composerHighlightedItemId, |
| 936 | highlightedSearchKey: composerHighlightedSearchKey, | 932 | currentSearchKey: composerMenuSearchKey, |
| 937 | }); | 933 | highlightedSearchKey: composerHighlightedSearchKey, |
| 938 | return composerMenuItems.find((item) => item.id === activeItemId) ?? null; | | |
| 939 | }, [ | | |
| 940 | composerHighlightedItemId, | | |
| 941 | composerHighlightedSearchKey, | | |
| 942 | composerMenuItems, | | |
| 943 | composerMenuSearchKey, | | |
| 944 | ]); | | |
| 945 | | | |
| 946 | composerMenuOpenRef.current = composerMenuOpen; | | |
| 947 | composerMenuItemsRef.current = composerMenuItems; | | |
| 948 | activeComposerMenuItemRef.current = activeComposerMenuItem; | | |
| 949 | | | |
| 950 | const nonPersistedComposerImageIdSet = useMemo( | | |
| 951 | () => new Set(nonPersistedComposerImageIds), | | |
| 952 | [nonPersistedComposerImageIds], | | |
| 953 | ); | | |
| 954 | | | |
| 955 | const isComposerApprovalState = activePendingApproval !== null; | | |
| 956 | const activePendingUserInput = pendingUserInputs[0] ?? null; | | |
| 957 | const hasComposerHeader = | | |
| 958 | isComposerApprovalState || | | |
| 959 | pendingUserInputs.length > 0 || | | |
| 960 | (showPlanFollowUpPrompt && activeProposedPlan !== null); | | |
| 961 | const showCollapsedMobilePromptRow = | | |
| 962 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; | | |
| 963 | | | |
| 964 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; | | |
| 965 | const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); | | |
| 966 | const composerFooterActionLayoutKey = useMemo(() => { | | |
| 967 | if (activePendingProgress) { | | |
| 968 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; | | |
| 969 | } | | |
| 970 | if (phase === "running") { | | |
| 971 | return "running"; | | |
| 972 | } | | |
| 973 | if (showPlanFollowUpPrompt) { | | |
| 974 | return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; | | |
| 975 | } | | |
| 976 | return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; | | |
| 977 | }, [ | | |
| 978 | activePendingIsResponding, | | |
| 979 | activePendingProgress, | | |
| 980 | composerSendState.hasSendableContent, | | |
| 981 | isConnecting, | | |
| 982 | isPreparingWorktree, | | |
| 983 | isSendBusy, | | |
| 984 | phase, | | |
| 985 | prompt, | | |
| 986 | showPlanFollowUpPrompt, | | |
| 987 | ]); | | |
| 988 | | | |
| 989 | const isComposerMenuLoading = | | |
| 990 | composerTriggerKind === "path" && | | |
| 991 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | | |
| 992 | workspaceEntriesQuery.isLoading || | | |
| 993 | workspaceEntriesQuery.isFetching); | | |
| 994 | const composerMenuEmptyState = useMemo(() => { | | |
| 995 | if (composerTriggerKind === "skill") { | | |
| 996 | return "No skills found. Try / to browse provider commands."; | | |
| 997 | } | | |
| 998 | return composerTriggerKind === "path" | | |
| 999 | ? "No matching files or folders." | | |
| 1000 | : "No matching command."; | | |
| 1001 | }, [composerTriggerKind]); | | |
| 1002 | | | |
| 1003 | // ------------------------------------------------------------------ | | |
| 1004 | // Provider traits UI | | |
| 1005 | // ------------------------------------------------------------------ | | |
| 1006 | const setPromptFromTraits = useCallback( | | |
| 1007 | (nextPrompt: string) => { | | |
| 1008 | if (nextPrompt === promptRef.current) { | | |
| 1009 | scheduleComposerFocus(); | | |
| 1010 | return; | | |
| 1011 | } | | |
| 1012 | promptRef.current = nextPrompt; | | |
| 1013 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | | |
| 1014 | const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); | | |
| 1015 | setComposerCursor(nextCursor); | | |
| 1016 | setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); | | |
| 1017 | scheduleComposerFocus(); | | |
| 1018 | }, | | |
| 1019 | [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], | | |
| 1020 | ); | | |
| 1021 | | | |
| 1022 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ | | |
| 1023 | provider: selectedProvider, | | |
| 1024 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | | |
| 1025 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | | |
| 1026 | model: selectedModel, | | |
| 1027 | models: selectedProviderModels, | | |
| 1028 | modelOptions: composerModelOptions?.[selectedProvider], | | |
| 1029 | prompt, | | |
| 1030 | onPromptChange: setPromptFromTraits, | | |
| 1031 | }); | | |
| 1032 | const providerTraitsPicker = renderProviderTraitsPicker({ | | |
| 1033 | provider: selectedProvider, | | |
| 1034 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | | |
| 1035 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | | |
| 1036 | model: selectedModel, | | |
| 1037 | models: selectedProviderModels, | | |
| 1038 | modelOptions: composerModelOptions?.[selectedProvider], | | |
| 1039 | prompt, | | |
| 1040 | onPromptChange: setPromptFromTraits, | | |
| 1041 | }); | 934 | }); |
| 1042 | const pendingPrimaryAction = useMemo( | 935 | return composerMenuItems.find((item) => item.id === activeItemId) ?? null; |
| 1043 | () => | 936 | }, [ |
| 1044 | activePendingProgress | 937 | composerHighlightedItemId, |
| 1045 | ? { | 938 | composerHighlightedSearchKey, |
| 1046 | questionIndex: activePendingProgress.questionIndex, | 939 | composerMenuItems, |
| 1047 | isLastQuestion: activePendingProgress.isLastQuestion, | 940 | composerMenuSearchKey, |
| 1048 | canAdvance: activePendingProgress.canAdvance, | 941 | ]); |
| 1049 | isResponding: activePendingIsResponding, | 942 | |
| 1050 | isComplete: Boolean(activePendingResolvedAnswers), | 943 | composerMenuOpenRef.current = composerMenuOpen; |
| 1051 | } | 944 | composerMenuItemsRef.current = composerMenuItems; |
| 1052 | : null, | 945 | activeComposerMenuItemRef.current = activeComposerMenuItem; |
| 1053 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], | 946 | |
| 1054 | ); | 947 | const nonPersistedComposerImageIdSet = useMemo( |
| 1055 | const collapsedComposerPrimaryActionDisabled = | 948 | () => new Set(nonPersistedComposerImageIds), |
| 1056 | phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; | 949 | [nonPersistedComposerImageIds], |
| 1057 | const collapsedComposerPrimaryActionLabel = "Send message"; | 950 | ); |
| 1058 | const showMobilePendingAnswerActions = | | |
| 1059 | isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; | | |
| 1060 | | | |
| 1061 | // ------------------------------------------------------------------ | | |
| 1062 | // Prompt helpers | | |
| 1063 | // ------------------------------------------------------------------ | | |
| 1064 | const setPrompt = useCallback( | | |
| 1065 | (nextPrompt: string) => { | | |
| 1066 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | | |
| 1067 | }, | | |
| 1068 | [composerDraftTarget, setComposerDraftPrompt], | | |
| 1069 | ); | | |
| 1070 | | | |
| 1071 | const addComposerImage = useCallback( | | |
| 1072 | (image: ComposerImageAttachment) => { | | |
| 1073 | addComposerDraftImage(composerDraftTarget, image); | | |
| 1074 | }, | | |
| 1075 | [composerDraftTarget, addComposerDraftImage], | | |
| 1076 | ); | | |
| 1077 | | | |
| 1078 | const addComposerImagesToDraft = useCallback( | | |
| 1079 | (images: ComposerImageAttachment[]) => { | | |
| 1080 | addComposerDraftImages(composerDraftTarget, images); | | |
| 1081 | }, | | |
| 1082 | [composerDraftTarget, addComposerDraftImages], | | |
| 1083 | ); | | |
| 1084 | | | |
| 1085 | const removeComposerImageFromDraft = useCallback( | | |
| 1086 | (imageId: string) => { | | |
| 1087 | removeComposerDraftImage(composerDraftTarget, imageId); | | |
| 1088 | }, | | |
| 1089 | [composerDraftTarget, removeComposerDraftImage], | | |
| 1090 | ); | | |
| 1091 | | | |
| 1092 | const removeComposerTerminalContextFromDraft = useCallback( | | |
| 1093 | (contextId: string) => { | | |
| 1094 | const contextIndex = composerTerminalContexts.findIndex( | | |
| 1095 | (context) => context.id === contextId, | | |
| 1096 | ); | | |
| 1097 | if (contextIndex < 0) return; | | |
| 1098 | const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); | | |
| 1099 | promptRef.current = removal.prompt; | | |
| 1100 | setPrompt(removal.prompt); | | |
| 1101 | removeComposerDraftTerminalContext(composerDraftTarget, contextId); | | |
| 1102 | const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); | | |
| 1103 | setComposerCursor(nextCursor); | | |
| 1104 | setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); | | |
| 1105 | }, | | |
| 1106 | [ | | |
| 1107 | composerDraftTarget, | | |
| 1108 | composerTerminalContexts, | | |
| 1109 | promptRef, | | |
| 1110 | removeComposerDraftTerminalContext, | | |
| 1111 | setPrompt, | | |
| 1112 | ], | | |
| 1113 | ); | | |
| 1114 | | 951 | |
| 1115 | // ------------------------------------------------------------------ | 952 | const isComposerApprovalState = activePendingApproval !== null; |
| 1116 | // Sync refs back to parent | 953 | const activePendingUserInput = pendingUserInputs[0] ?? null; |
| 1117 | // ------------------------------------------------------------------ | 954 | const hasComposerHeader = |
| 1118 | useEffect(() => { | 955 | isComposerApprovalState || |
| 1119 | promptRef.current = prompt; | 956 | pendingUserInputs.length > 0 || |
| 1120 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); | 957 | (showPlanFollowUpPrompt && activeProposedPlan !== null); |
| 1121 | }, [prompt, promptRef]); | 958 | const showCollapsedMobilePromptRow = |
| 1122 | | 959 | isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; |
| 1123 | useEffect(() => { | 960 | |
| 1124 | composerImagesRef.current = composerImages; | 961 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; |
| 1125 | }, [composerImages, composerImagesRef]); | 962 | const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); |
| 1126 | | 963 | const composerFooterActionLayoutKey = useMemo(() => { |
| 1127 | useEffect(() => { | 964 | if (activePendingProgress) { |
| 1128 | composerTerminalContextsRef.current = composerTerminalContexts; | 965 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; |
| 1129 | }, [composerTerminalContexts, composerTerminalContextsRef]); | 966 | } |
| 1130 | | 967 | if (phase === "running") { |
| 1131 | // ------------------------------------------------------------------ | 968 | return "running"; |
| 1132 | // Composer menu highlight sync | 969 | } |
| 1133 | // ------------------------------------------------------------------ | 970 | if (showPlanFollowUpPrompt) { |
| 1134 | useEffect(() => { | 971 | return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; |
| 1135 | if (!composerMenuOpen) { | 972 | } |
| 1136 | setComposerHighlightedItemId(null); | 973 | return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; |
| 1137 | setComposerHighlightedSearchKey(null); | 974 | }, [ |
| 1138 | return; | 975 | activePendingIsResponding, |
| 1139 | } | 976 | activePendingProgress, |
| 1140 | const nextActiveItemId = resolveComposerMenuActiveItemId({ | 977 | composerSendState.hasSendableContent, |
| 1141 | items: composerMenuItems, | 978 | isConnecting, |
| 1142 | highlightedItemId: composerHighlightedItemId, | 979 | isPreparingWorktree, |
| 1143 | currentSearchKey: composerMenuSearchKey, | 980 | isSendBusy, |
| 1144 | highlightedSearchKey: composerHighlightedSearchKey, | 981 | phase, |
| 1145 | }); | 982 | prompt, |
| 1146 | setComposerHighlightedItemId((existing) => | 983 | showPlanFollowUpPrompt, |
| 1147 | existing === nextActiveItemId ? existing : nextActiveItemId, | 984 | ]); |
| 1148 | ); | 985 | |
| 1149 | setComposerHighlightedSearchKey((existing) => | 986 | const isComposerMenuLoading = |
| 1150 | existing === composerMenuSearchKey ? existing : composerMenuSearchKey, | 987 | composerTriggerKind === "path" && |
| 1151 | ); | 988 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || |
| 1152 | }, [ | 989 | workspaceEntriesQuery.isLoading || |
| 1153 | composerHighlightedItemId, | 990 | workspaceEntriesQuery.isFetching); |
| 1154 | composerHighlightedSearchKey, | 991 | const composerMenuEmptyState = useMemo(() => { |
| 1155 | composerMenuItems, | 992 | if (composerTriggerKind === "skill") { |
| 1156 | composerMenuOpen, | 993 | return "No skills found. Try / to browse provider commands."; |
| 1157 | composerMenuSearchKey, | 994 | } |
| 1158 | ]); | 995 | return composerTriggerKind === "path" |
| 1159 | | 996 | ? "No matching files or folders." |
| 1160 | const lastSyncedPendingInputRef = useRef<{ | 997 | : "No matching command."; |
| 1161 | requestId: string | null; | 998 | }, [composerTriggerKind]); |
| 1162 | questionId: string | null; | 999 | |
| 1163 | } | null>(null); | 1000 | // ------------------------------------------------------------------ |
| 1164 | | 1001 | // Provider traits UI |
| 1165 | useEffect(() => { | 1002 | // ------------------------------------------------------------------ |
| 1166 | const nextCustomAnswer = activePendingProgress?.customAnswer; | 1003 | const setPromptFromTraits = useCallback( |
| 1167 | if (typeof nextCustomAnswer !== "string") { | 1004 | (nextPrompt: string) => { |
| 1168 | lastSyncedPendingInputRef.current = null; | 1005 | if (nextPrompt === promptRef.current) { |
| | 1006 | scheduleComposerFocus(); |
| 1169 | return; | 1007 | return; |
| 1170 | } | 1008 | } |
| | 1009 | promptRef.current = nextPrompt; |
| | 1010 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); |
| | 1011 | const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); |
| | 1012 | setComposerCursor(nextCursor); |
| | 1013 | setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); |
| | 1014 | scheduleComposerFocus(); |
| | 1015 | }, |
| | 1016 | [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], |
| | 1017 | ); |
| 1171 | | 1018 | |
| 1172 | const nextRequestId = activePendingUserInput?.requestId ?? null; | 1019 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ |
| 1173 | const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; | 1020 | provider: selectedProvider, |
| 1174 | const questionChanged = | 1021 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| 1175 | lastSyncedPendingInputRef.current?.requestId !== nextRequestId || | 1022 | ...(routeKind === "draft" && draftId ? { draftId } : {}), |
| 1176 | lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; | 1023 | model: selectedModel, |
| 1177 | const textChangedExternally = promptRef.current !== nextCustomAnswer; | 1024 | models: selectedProviderModels, |
| | 1025 | modelOptions: composerModelOptions?.[selectedProvider], |
| | 1026 | prompt, |
| | 1027 | onPromptChange: setPromptFromTraits, |
| | 1028 | }); |
| | 1029 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| | 1030 | provider: selectedProvider, |
| | 1031 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| | 1032 | ...(routeKind === "draft" && draftId ? { draftId } : {}), |
| | 1033 | model: selectedModel, |
| | 1034 | models: selectedProviderModels, |
| | 1035 | modelOptions: composerModelOptions?.[selectedProvider], |
| | 1036 | prompt, |
| | 1037 | onPromptChange: setPromptFromTraits, |
| | 1038 | }); |
| | 1039 | const pendingPrimaryAction = useMemo( |
| | 1040 | () => |
| | 1041 | activePendingProgress |
| | 1042 | ? { |
| | 1043 | questionIndex: activePendingProgress.questionIndex, |
| | 1044 | isLastQuestion: activePendingProgress.isLastQuestion, |
| | 1045 | canAdvance: activePendingProgress.canAdvance, |
| | 1046 | isResponding: activePendingIsResponding, |
| | 1047 | isComplete: Boolean(activePendingResolvedAnswers), |
| | 1048 | } |
| | 1049 | : null, |
| | 1050 | [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], |
| | 1051 | ); |
| | 1052 | const collapsedComposerPrimaryActionDisabled = |
| | 1053 | phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; |
| | 1054 | const collapsedComposerPrimaryActionLabel = "Send message"; |
| | 1055 | const showMobilePendingAnswerActions = |
| | 1056 | isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; |
| | 1057 | |
| | 1058 | // ------------------------------------------------------------------ |
| | 1059 | // Prompt helpers |
| | 1060 | // ------------------------------------------------------------------ |
| | 1061 | const setPrompt = useCallback( |
| | 1062 | (nextPrompt: string) => { |
| | 1063 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); |
| | 1064 | }, |
| | 1065 | [composerDraftTarget, setComposerDraftPrompt], |
| | 1066 | ); |
| 1178 | | 1067 | |
| 1179 | lastSyncedPendingInputRef.current = { | 1068 | const addComposerImage = useCallback( |
| 1180 | requestId: nextRequestId, | 1069 | (image: ComposerImageAttachment) => { |
| 1181 | questionId: nextQuestionId, | 1070 | addComposerDraftImage(composerDraftTarget, image); |
| 1182 | }; | 1071 | }, |
| | 1072 | [composerDraftTarget, addComposerDraftImage], |
| | 1073 | ); |
| 1183 | | 1074 | |
| 1184 | if (!questionChanged && !textChangedExternally) { | 1075 | const addComposerImagesToDraft = useCallback( |
| 1185 | return; | 1076 | (images: ComposerImageAttachment[]) => { |
| 1186 | } | 1077 | addComposerDraftImages(composerDraftTarget, images); |
| | 1078 | }, |
| | 1079 | [composerDraftTarget, addComposerDraftImages], |
| | 1080 | ); |
| 1187 | | 1081 | |
| 1188 | promptRef.current = nextCustomAnswer; | 1082 | const removeComposerImageFromDraft = useCallback( |
| 1189 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); | 1083 | (imageId: string) => { |
| 1190 | setComposerCursor(nextCursor); | 1084 | removeComposerDraftImage(composerDraftTarget, imageId); |
| 1191 | setComposerTrigger( | 1085 | }, |
| 1192 | detectComposerTrigger( | 1086 | [composerDraftTarget, removeComposerDraftImage], |
| 1193 | nextCustomAnswer, | 1087 | ); |
| 1194 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), | 1088 | |
| 1195 | ), | 1089 | const removeComposerTerminalContextFromDraft = useCallback( |
| | 1090 | (contextId: string) => { |
| | 1091 | const contextIndex = composerTerminalContexts.findIndex( |
| | 1092 | (context) => context.id === contextId, |
| 1196 | ); | 1093 | ); |
| 1197 | setComposerHighlightedItemId(null); | 1094 | if (contextIndex < 0) return; |
| 1198 | }, [ | 1095 | const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); |
| 1199 | activePendingProgress?.customAnswer, | 1096 | promptRef.current = removal.prompt; |
| 1200 | activePendingProgress?.activeQuestion?.id, | 1097 | setPrompt(removal.prompt); |
| 1201 | activePendingUserInput?.requestId, | 1098 | removeComposerDraftTerminalContext(composerDraftTarget, contextId); |
| | 1099 | const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); |
| | 1100 | setComposerCursor(nextCursor); |
| | 1101 | setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); |
| | 1102 | }, |
| | 1103 | [ |
| | 1104 | composerDraftTarget, |
| | 1105 | composerTerminalContexts, |
| 1202 | promptRef, | 1106 | promptRef, |
| 1203 | ]); | 1107 | removeComposerDraftTerminalContext, |
| | 1108 | setPrompt, |
| | 1109 | ], |
| | 1110 | ); |
| 1204 | | 1111 | |
| 1205 | // ------------------------------------------------------------------ | 1112 | // ------------------------------------------------------------------ |
| 1206 | // Reset compositor state on thread/draft change | 1113 | // Sync refs back to parent |
| 1207 | // ------------------------------------------------------------------ | 1114 | // ------------------------------------------------------------------ |
| 1208 | useEffect(() => { | 1115 | useEffect(() => { |
| | 1116 | promptRef.current = prompt; |
| | 1117 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); |
| | 1118 | }, [prompt, promptRef]); |
| | 1119 | |
| | 1120 | useEffect(() => { |
| | 1121 | composerImagesRef.current = composerImages; |
| | 1122 | }, [composerImages, composerImagesRef]); |
| | 1123 | |
| | 1124 | useEffect(() => { |
| | 1125 | composerTerminalContextsRef.current = composerTerminalContexts; |
| | 1126 | }, [composerTerminalContexts, composerTerminalContextsRef]); |
| | 1127 | |
| | 1128 | // ------------------------------------------------------------------ |
| | 1129 | // Composer menu highlight sync |
| | 1130 | // ------------------------------------------------------------------ |
| | 1131 | useEffect(() => { |
| | 1132 | if (!composerMenuOpen) { |
| 1209 | setComposerHighlightedItemId(null); | 1133 | setComposerHighlightedItemId(null); |
| 1210 | setComposerCursor( | 1134 | setComposerHighlightedSearchKey(null); |
| 1211 | collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), | 1135 | return; |
| 1212 | ); | 1136 | } |
| 1213 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | 1137 | const nextActiveItemId = resolveComposerMenuActiveItemId({ |
| 1214 | dragDepthRef.current = 0; | 1138 | items: composerMenuItems, |
| 1215 | setIsDragOverComposer(false); | 1139 | highlightedItemId: composerHighlightedItemId, |
| 1216 | }, [draftId, activeThreadId, promptRef]); | 1140 | currentSearchKey: composerMenuSearchKey, |
| 1217 | | 1141 | highlightedSearchKey: composerHighlightedSearchKey, |
| 1218 | // ------------------------------------------------------------------ | 1142 | }); |
| 1219 | // Footer compact layout observation | 1143 | setComposerHighlightedItemId((existing) => |
| 1220 | // ------------------------------------------------------------------ | 1144 | existing === nextActiveItemId ? existing : nextActiveItemId, |
| 1221 | useLayoutEffect(() => { | 1145 | ); |
| 1222 | const composerForm = composerFormRef.current; | 1146 | setComposerHighlightedSearchKey((existing) => |
| 1223 | if (!composerForm) return; | 1147 | existing === composerMenuSearchKey ? existing : composerMenuSearchKey, |
| 1224 | const measureComposerFormWidth = () => composerForm.clientWidth; | 1148 | ); |
| 1225 | const measureFooterCompactness = () => { | 1149 | }, [ |
| 1226 | const composerFormWidth = measureComposerFormWidth(); | 1150 | composerHighlightedItemId, |
| 1227 | const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { | 1151 | composerHighlightedSearchKey, |
| | 1152 | composerMenuItems, |
| | 1153 | composerMenuOpen, |
| | 1154 | composerMenuSearchKey, |
| | 1155 | ]); |
| | 1156 | |
| | 1157 | const lastSyncedPendingInputRef = useRef<{ |
| | 1158 | requestId: string | null; |
| | 1159 | questionId: string | null; |
| | 1160 | } | null>(null); |
| | 1161 | |
| | 1162 | useEffect(() => { |
| | 1163 | const nextCustomAnswer = activePendingProgress?.customAnswer; |
| | 1164 | if (typeof nextCustomAnswer !== "string") { |
| | 1165 | lastSyncedPendingInputRef.current = null; |
| | 1166 | return; |
| | 1167 | } |
| | 1168 | |
| | 1169 | const nextRequestId = activePendingUserInput?.requestId ?? null; |
| | 1170 | const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; |
| | 1171 | const questionChanged = |
| | 1172 | lastSyncedPendingInputRef.current?.requestId !== nextRequestId || |
| | 1173 | lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; |
| | 1174 | const textChangedExternally = promptRef.current !== nextCustomAnswer; |
| | 1175 | |
| | 1176 | lastSyncedPendingInputRef.current = { |
| | 1177 | requestId: nextRequestId, |
| | 1178 | questionId: nextQuestionId, |
| | 1179 | }; |
| | 1180 | |
| | 1181 | if (!questionChanged && !textChangedExternally) { |
| | 1182 | return; |
| | 1183 | } |
| | 1184 | |
| | 1185 | promptRef.current = nextCustomAnswer; |
| | 1186 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); |
| | 1187 | setComposerCursor(nextCursor); |
| | 1188 | setComposerTrigger( |
| | 1189 | detectComposerTrigger( |
| | 1190 | nextCustomAnswer, |
| | 1191 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), |
| | 1192 | ), |
| | 1193 | ); |
| | 1194 | setComposerHighlightedItemId(null); |
| | 1195 | }, [ |
| | 1196 | activePendingProgress?.customAnswer, |
| | 1197 | activePendingProgress?.activeQuestion?.id, |
| | 1198 | activePendingUserInput?.requestId, |
| | 1199 | promptRef, |
| | 1200 | ]); |
| | 1201 | |
| | 1202 | // ------------------------------------------------------------------ |
| | 1203 | // Reset compositor state on thread/draft change |
| | 1204 | // ------------------------------------------------------------------ |
| | 1205 | useEffect(() => { |
| | 1206 | setComposerHighlightedItemId(null); |
| | 1207 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| | 1208 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| | 1209 | dragDepthRef.current = 0; |
| | 1210 | setIsDragOverComposer(false); |
| | 1211 | }, [draftId, activeThreadId, promptRef]); |
| | 1212 | |
| | 1213 | // ------------------------------------------------------------------ |
| | 1214 | // Footer compact layout observation |
| | 1215 | // ------------------------------------------------------------------ |
| | 1216 | useLayoutEffect(() => { |
| | 1217 | const composerForm = composerFormRef.current; |
| | 1218 | if (!composerForm) return; |
| | 1219 | const measureComposerFormWidth = () => composerForm.clientWidth; |
| | 1220 | const measureFooterCompactness = () => { |
| | 1221 | const composerFormWidth = measureComposerFormWidth(); |
| | 1222 | const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { |
| | 1223 | hasWideActions: composerFooterHasWideActions, |
| | 1224 | }); |
| | 1225 | const primaryActionsCompact = |
| | 1226 | footerCompact && |
| | 1227 | shouldUseCompactComposerPrimaryActions(composerFormWidth, { |
| 1228 | hasWideActions: composerFooterHasWideActions, | 1228 | hasWideActions: composerFooterHasWideActions, |
| 1229 | }); | 1229 | }); |
| 1230 | const primaryActionsCompact = | 1230 | return { |
| 1231 | footerCompact && | 1231 | primaryActionsCompact, |
| 1232 | shouldUseCompactComposerPrimaryActions(composerFormWidth, { | 1232 | footerCompact, |
| 1233 | hasWideActions: composerFooterHasWideActions, | | |
| 1234 | }); | | |
| 1235 | return { | | |
| 1236 | primaryActionsCompact, | | |
| 1237 | footerCompact, | | |
| 1238 | }; | | |
| 1239 | }; | 1233 | }; |
| | 1234 | }; |
| 1240 | | 1235 | |
| 1241 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | 1236 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; |
| 1242 | const initialCompactness = measureFooterCompactness(); | 1237 | const initialCompactness = measureFooterCompactness(); |
| 1243 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | 1238 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 1244 | setIsComposerFooterCompact(initialCompactness.footerCompact); | 1239 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 1245 | if (typeof ResizeObserver === "undefined") return; | 1240 | if (typeof ResizeObserver === "undefined") return; |
| 1246 | | 1241 | |
| 1247 | const observer = new ResizeObserver((entries) => { | 1242 | const observer = new ResizeObserver((entries) => { |
| 1248 | const [entry] = entries; | 1243 | const [entry] = entries; |
| 1249 | if (!entry) return; | 1244 | if (!entry) return; |
| 1250 | const nextCompactness = measureFooterCompactness(); | 1245 | const nextCompactness = measureFooterCompactness(); |
| 1251 | setIsComposerPrimaryActionsCompact((previous) => | 1246 | setIsComposerPrimaryActionsCompact((previous) => |
| 1252 | previous === nextCompactness.primaryActionsCompact | 1247 | previous === nextCompactness.primaryActionsCompact |
| 1253 | ? previous | 1248 | ? previous |
| 1254 | : nextCompactness.primaryActionsCompact, | 1249 | : nextCompactness.primaryActionsCompact, |
| 1255 | ); | 1250 | ); |
| 1256 | setIsComposerFooterCompact((previous) => | 1251 | setIsComposerFooterCompact((previous) => |
| 1257 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | 1252 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, |
| 1258 | ); | 1253 | ); |
| 1259 | const nextHeight = entry.contentRect.height; | 1254 | const nextHeight = entry.contentRect.height; |
| 1260 | const previousHeight = composerFormHeightRef.current; | 1255 | const previousHeight = composerFormHeightRef.current; |
| 1261 | composerFormHeightRef.current = nextHeight; | 1256 | composerFormHeightRef.current = nextHeight; |
| 1262 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; | 1257 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; |
| 1263 | if (!shouldAutoScrollRef.current) return; | 1258 | if (!shouldAutoScrollRef.current) return; |
| 1264 | scheduleStickToBottom(); | 1259 | scheduleStickToBottom(); |
| 1265 | }); | 1260 | }); |
| 1266 | | 1261 | |
| 1267 | observer.observe(composerForm); | 1262 | observer.observe(composerForm); |
| 1268 | return () => { | 1263 | return () => { |
| 1269 | observer.disconnect(); | 1264 | observer.disconnect(); |
| 1270 | }; | 1265 | }; |
| 1271 | }, [ | 1266 | }, [ |
| 1272 | activeThreadId, | 1267 | activeThreadId, |
| 1273 | composerFooterActionLayoutKey, | 1268 | composerFooterActionLayoutKey, |
| 1274 | composerFooterHasWideActions, | 1269 | composerFooterHasWideActions, |
| 1275 | scheduleStickToBottom, | 1270 | scheduleStickToBottom, |
| 1276 | shouldAutoScrollRef, | 1271 | shouldAutoScrollRef, |
| 1277 | ]); | 1272 | ]); |
| 1278 | | 1273 | |
| 1279 | // ------------------------------------------------------------------ | 1274 | // ------------------------------------------------------------------ |
| 1280 | // Image persist effect | 1275 | // Image persist effect |
| 1281 | // ------------------------------------------------------------------ | 1276 | // ------------------------------------------------------------------ |
| 1282 | useEffect(() => { | 1277 | useEffect(() => { |
| 1283 | let cancelled = false; | 1278 | let cancelled = false; |
| 1284 | void (async () => { | 1279 | void (async () => { |
| 1285 | if (composerImages.length === 0) { | 1280 | if (composerImages.length === 0) { |
| 1286 | clearComposerDraftPersistedAttachments(composerDraftTarget); | 1281 | clearComposerDraftPersistedAttachments(composerDraftTarget); |
| 1287 | return; | 1282 | return; |
| 1288 | } | 1283 | } |
| 1289 | const getPersistedAttachmentsForThread = () => | 1284 | const getPersistedAttachmentsForThread = () => |
| 1290 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; | 1285 | getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; |
| 1291 | try { | 1286 | try { |
| 1292 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); | 1287 | const currentPersistedAttachments = getPersistedAttachmentsForThread(); |
| 1293 | const existingPersistedById = new Map( | 1288 | const existingPersistedById = new Map( |
| 1294 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), | 1289 | currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), |
| 1295 | ); | 1290 | ); |
| 1296 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); | 1291 | const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>(); |
| 1297 | await Promise.all( | 1292 | await Promise.all( |
| 1298 | composerImages.map(async (image) => { | 1293 | composerImages.map(async (image) => { |
| 1299 | try { | 1294 | try { |
| 1300 | const dataUrl = await readFileAsDataUrl(image.file); | 1295 | const dataUrl = await readFileAsDataUrl(image.file); |
| 1301 | stagedAttachmentById.set(image.id, { | 1296 | stagedAttachmentById.set(image.id, { |
| 1302 | id: image.id, | 1297 | id: image.id, |
| 1303 | name: image.name, | 1298 | name: image.name, |
| 1304 | mimeType: image.mimeType, | 1299 | mimeType: image.mimeType, |
| 1305 | sizeBytes: image.sizeBytes, | 1300 | sizeBytes: image.sizeBytes, |
| 1306 | dataUrl, | 1301 | dataUrl, |
| 1307 | }); | 1302 | }); |
| 1308 | } catch { | 1303 | } catch { |
| 1309 | const existingPersisted = existingPersistedById.get(image.id); | 1304 | const existingPersisted = existingPersistedById.get(image.id); |
| 1310 | if (existingPersisted) { | 1305 | if (existingPersisted) { |
| 1311 | stagedAttachmentById.set(image.id, existingPersisted); | 1306 | stagedAttachmentById.set(image.id, existingPersisted); |
| 1312 | } | | |
| 1313 | } | 1307 | } |
| 1314 | }), | 1308 | } |
| 1315 | ); | 1309 | }), |
| 1316 | const serialized = Array.from(stagedAttachmentById.values()); | 1310 | ); |
| 1317 | if (cancelled) return; | 1311 | const serialized = Array.from(stagedAttachmentById.values()); |
| 1318 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); | 1312 | if (cancelled) return; |
| 1319 | } catch { | 1313 | syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); |
| 1320 | const currentImageIds = new Set(composerImages.map((image) => image.id)); | 1314 | } catch { |
| 1321 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); | 1315 | const currentImageIds = new Set(composerImages.map((image) => image.id)); |
| 1322 | const fallbackPersistedIds = fallbackPersistedAttachments | 1316 | const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); |
| 1323 | .map((attachment) => attachment.id) | 1317 | const fallbackPersistedIds = fallbackPersistedAttachments |
| 1324 | .filter((id) => currentImageIds.has(id)); | 1318 | .map((attachment) => attachment.id) |
| 1325 | const fallbackPersistedIdSet = new Set(fallbackPersistedIds); | 1319 | .filter((id) => currentImageIds.has(id)); |
| 1326 | const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => | 1320 | const fallbackPersistedIdSet = new Set(fallbackPersistedIds); |
| 1327 | fallbackPersistedIdSet.has(attachment.id), | 1321 | const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => |
| 1328 | ); | 1322 | fallbackPersistedIdSet.has(attachment.id), |
| 1329 | if (cancelled) return; | 1323 | ); |
| 1330 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); | 1324 | if (cancelled) return; |
| 1331 | } | 1325 | syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); |
| 1332 | })(); | 1326 | } |
| 1333 | return () => { | 1327 | })(); |
| 1334 | cancelled = true; | 1328 | return () => { |
| 1335 | }; | 1329 | cancelled = true; |
| 1336 | }, [ | 1330 | }; |
| 1337 | composerDraftTarget, | 1331 | }, [ |
| 1338 | clearComposerDraftPersistedAttachments, | 1332 | composerDraftTarget, |
| 1339 | composerImages, | 1333 | clearComposerDraftPersistedAttachments, |
| 1340 | getComposerDraft, | 1334 | composerImages, |
| 1341 | syncComposerDraftPersistedAttachments, | 1335 | getComposerDraft, |
| 1342 | ]); | 1336 | syncComposerDraftPersistedAttachments, |
| 1343 | | 1337 | ]); |
| 1344 | // ------------------------------------------------------------------ | 1338 | |
| 1345 | // Callbacks: prompt change | 1339 | // ------------------------------------------------------------------ |
| 1346 | // ------------------------------------------------------------------ | 1340 | // Callbacks: prompt change |
| 1347 | const onPromptChange = useCallback( | 1341 | // ------------------------------------------------------------------ |
| 1348 | ( | 1342 | const onPromptChange = useCallback( |
| 1349 | nextPrompt: string, | 1343 | ( |
| 1350 | nextCursor: number, | 1344 | nextPrompt: string, |
| 1351 | expandedCursor: number, | 1345 | nextCursor: number, |
| 1352 | cursorAdjacentToMention: boolean, | 1346 | expandedCursor: number, |
| 1353 | terminalContextIds: string[], | 1347 | cursorAdjacentToMention: boolean, |
| 1354 | ) => { | 1348 | terminalContextIds: string[], |
| 1355 | if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { | 1349 | ) => { |
| 1356 | setComposerCursor(nextCursor); | 1350 | if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { |
| 1357 | setComposerTrigger( | | |
| 1358 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | | |
| 1359 | ); | | |
| 1360 | onChangeActivePendingUserInputCustomAnswer( | | |
| 1361 | activePendingProgress.activeQuestion.id, | | |
| 1362 | nextPrompt, | | |
| 1363 | nextCursor, | | |
| 1364 | expandedCursor, | | |
| 1365 | cursorAdjacentToMention, | | |
| 1366 | ); | | |
| 1367 | return; | | |
| 1368 | } | | |
| 1369 | promptRef.current = nextPrompt; | | |
| 1370 | setPrompt(nextPrompt); | | |
| 1371 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | | |
| 1372 | setComposerDraftTerminalContexts( | | |
| 1373 | composerDraftTarget, | | |
| 1374 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), | | |
| 1375 | ); | | |
| 1376 | } | | |
| 1377 | setComposerCursor(nextCursor); | 1351 | setComposerCursor(nextCursor); |
| 1378 | setComposerTrigger( | 1352 | setComposerTrigger( |
| 1379 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | 1353 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| 1380 | ); | 1354 | ); |
| 1381 | }, | 1355 | onChangeActivePendingUserInputCustomAnswer( |
| 1382 | [ | 1356 | activePendingProgress.activeQuestion.id, |
| 1383 | activePendingProgress?.activeQuestion, | 1357 | nextPrompt, |
| 1384 | pendingUserInputs.length, | 1358 | nextCursor, |
| 1385 | onChangeActivePendingUserInputCustomAnswer, | 1359 | expandedCursor, |
| 1386 | promptRef, | 1360 | cursorAdjacentToMention, |
| 1387 | setPrompt, | 1361 | ); |
| 1388 | composerDraftTarget, | 1362 | return; |
| 1389 | composerTerminalContexts, | | |
| 1390 | setComposerDraftTerminalContexts, | | |
| 1391 | ], | | |
| 1392 | ); | | |
| 1393 | | | |
| 1394 | // ------------------------------------------------------------------ | | |
| 1395 | // Callbacks: prompt replacement / menu | | |
| 1396 | // ------------------------------------------------------------------ | | |
| 1397 | const applyPromptReplacement = useCallback( | | |
| 1398 | ( | | |
| 1399 | rangeStart: number, | | |
| 1400 | rangeEnd: number, | | |
| 1401 | replacement: string, | | |
| 1402 | options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, | | |
| 1403 | ): boolean => { | | |
| 1404 | const currentText = promptRef.current; | | |
| 1405 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); | | |
| 1406 | const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); | | |
| 1407 | if ( | | |
| 1408 | options?.expectedText !== undefined && | | |
| 1409 | currentText.slice(safeStart, safeEnd) !== options.expectedText | | |
| 1410 | ) { | | |
| 1411 | return false; | | |
| 1412 | } | | |
| 1413 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | | |
| 1414 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | | |
| 1415 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); | | |
| 1416 | promptRef.current = next.text; | | |
| 1417 | const activePendingQuestion = activePendingProgress?.activeQuestion; | | |
| 1418 | if (activePendingQuestion && activePendingUserInput) { | | |
| 1419 | onChangeActivePendingUserInputCustomAnswer( | | |
| 1420 | activePendingQuestion.id, | | |
| 1421 | next.text, | | |
| 1422 | nextCursor, | | |
| 1423 | nextExpandedCursor, | | |
| 1424 | false, | | |
| 1425 | ); | | |
| 1426 | } else { | | |
| 1427 | setPrompt(next.text); | | |
| 1428 | } | | |
| 1429 | setComposerCursor(nextCursor); | | |
| 1430 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); | | |
| 1431 | if (options?.focusEditorAfterReplace !== false) { | | |
| 1432 | window.requestAnimationFrame(() => { | | |
| 1433 | composerEditorRef.current?.focusAt(nextCursor); | | |
| 1434 | }); | | |
| 1435 | } | | |
| 1436 | return true; | | |
| 1437 | }, | | |
| 1438 | [ | | |
| 1439 | activePendingProgress?.activeQuestion, | | |
| 1440 | activePendingUserInput, | | |
| 1441 | onChangeActivePendingUserInputCustomAnswer, | | |
| 1442 | promptRef, | | |
| 1443 | setPrompt, | | |
| 1444 | ], | | |
| 1445 | ); | | |
| 1446 | | | |
| 1447 | const readComposerSnapshot = useCallback((): { | | |
| 1448 | value: string; | | |
| 1449 | cursor: number; | | |
| 1450 | expandedCursor: number; | | |
| 1451 | terminalContextIds: string[]; | | |
| 1452 | } => { | | |
| 1453 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); | | |
| 1454 | if (editorSnapshot) { | | |
| 1455 | return editorSnapshot; | | |
| 1456 | } | 1363 | } |
| 1457 | return { | 1364 | promptRef.current = nextPrompt; |
| 1458 | value: promptRef.current, | 1365 | setPrompt(nextPrompt); |
| 1459 | cursor: composerCursor, | 1366 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { |
| 1460 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | 1367 | setComposerDraftTerminalContexts( |
| 1461 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | 1368 | composerDraftTarget, |
| 1462 | }; | 1369 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), |
| 1463 | }, [composerCursor, composerTerminalContexts, promptRef]); | 1370 | ); |
| 1464 | | 1371 | } |
| 1465 | const resolveActiveComposerTrigger = useCallback((): { | 1372 | setComposerCursor(nextCursor); |
| 1466 | snapshot: { value: string; cursor: number; expandedCursor: number }; | 1373 | setComposerTrigger( |
| 1467 | trigger: ComposerTrigger | null; | 1374 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| 1468 | } => { | 1375 | ); |
| 1469 | const snapshot = readComposerSnapshot(); | 1376 | }, |
| 1470 | return { | 1377 | [ |
| 1471 | snapshot, | 1378 | activePendingProgress?.activeQuestion, |
| 1472 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | 1379 | pendingUserInputs.length, |
| 1473 | }; | 1380 | onChangeActivePendingUserInputCustomAnswer, |
| 1474 | }, [readComposerSnapshot]); | 1381 | promptRef, |
| | 1382 | setPrompt, |
| | 1383 | composerDraftTarget, |
| | 1384 | composerTerminalContexts, |
| | 1385 | setComposerDraftTerminalContexts, |
| | 1386 | ], |
| | 1387 | ); |
| 1475 | | 1388 | |
| 1476 | const onSelectComposerItem = useCallback( | 1389 | // ------------------------------------------------------------------ |
| 1477 | (item: ComposerCommandItem) => { | 1390 | // Callbacks: prompt replacement / menu |
| 1478 | if (composerSelectLockRef.current) return; | 1391 | // ------------------------------------------------------------------ |
| 1479 | composerSelectLockRef.current = true; | 1392 | const applyPromptReplacement = useCallback( |
| | 1393 | ( |
| | 1394 | rangeStart: number, |
| | 1395 | rangeEnd: number, |
| | 1396 | replacement: string, |
| | 1397 | options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, |
| | 1398 | ): boolean => { |
| | 1399 | const currentText = promptRef.current; |
| | 1400 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); |
| | 1401 | const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); |
| | 1402 | if ( |
| | 1403 | options?.expectedText !== undefined && |
| | 1404 | currentText.slice(safeStart, safeEnd) !== options.expectedText |
| | 1405 | ) { |
| | 1406 | return false; |
| | 1407 | } |
| | 1408 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| | 1409 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); |
| | 1410 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); |
| | 1411 | promptRef.current = next.text; |
| | 1412 | const activePendingQuestion = activePendingProgress?.activeQuestion; |
| | 1413 | if (activePendingQuestion && activePendingUserInput) { |
| | 1414 | onChangeActivePendingUserInputCustomAnswer( |
| | 1415 | activePendingQuestion.id, |
| | 1416 | next.text, |
| | 1417 | nextCursor, |
| | 1418 | nextExpandedCursor, |
| | 1419 | false, |
| | 1420 | ); |
| | 1421 | } else { |
| | 1422 | setPrompt(next.text); |
| | 1423 | } |
| | 1424 | setComposerCursor(nextCursor); |
| | 1425 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); |
| | 1426 | if (options?.focusEditorAfterReplace !== false) { |
| 1480 | window.requestAnimationFrame(() => { | 1427 | window.requestAnimationFrame(() => { |
| 1481 | composerSelectLockRef.current = false; | 1428 | composerEditorRef.current?.focusAt(nextCursor); |
| 1482 | }); | 1429 | }); |
| 1483 | const { snapshot, trigger } = resolveActiveComposerTrigger(); | 1430 | } |
| 1484 | if (!trigger) return; | 1431 | return true; |
| 1485 | if (item.type === "path") { | 1432 | }, |
| 1486 | const replacement = `@${item.path} `; | 1433 | [ |
| 1487 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | 1434 | activePendingProgress?.activeQuestion, |
| 1488 | snapshot.value, | 1435 | activePendingUserInput, |
| 1489 | trigger.rangeEnd, | 1436 | onChangeActivePendingUserInputCustomAnswer, |
| 1490 | replacement, | 1437 | promptRef, |
| 1491 | ); | 1438 | setPrompt, |
| 1492 | const applied = applyPromptReplacement( | 1439 | ], |
| 1493 | trigger.rangeStart, | 1440 | ); |
| 1494 | replacementRangeEnd, | 1441 | |
| 1495 | replacement, | 1442 | const readComposerSnapshot = useCallback((): { |
| 1496 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | 1443 | value: string; |
| 1497 | ); | 1444 | cursor: number; |
| 1498 | if (applied) { | 1445 | expandedCursor: number; |
| 1499 | setComposerHighlightedItemId(null); | 1446 | terminalContextIds: string[]; |
| 1500 | } | 1447 | } => { |
| 1501 | return; | 1448 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); |
| | 1449 | if (editorSnapshot) { |
| | 1450 | return editorSnapshot; |
| | 1451 | } |
| | 1452 | return { |
| | 1453 | value: promptRef.current, |
| | 1454 | cursor: composerCursor, |
| | 1455 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), |
| | 1456 | terminalContextIds: composerTerminalContexts.map((context) => context.id), |
| | 1457 | }; |
| | 1458 | }, [composerCursor, composerTerminalContexts, promptRef]); |
| | 1459 | |
| | 1460 | const resolveActiveComposerTrigger = useCallback((): { |
| | 1461 | snapshot: { value: string; cursor: number; expandedCursor: number }; |
| | 1462 | trigger: ComposerTrigger | null; |
| | 1463 | } => { |
| | 1464 | const snapshot = readComposerSnapshot(); |
| | 1465 | return { |
| | 1466 | snapshot, |
| | 1467 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), |
| | 1468 | }; |
| | 1469 | }, [readComposerSnapshot]); |
| | 1470 | |
| | 1471 | const onSelectComposerItem = useCallback( |
| | 1472 | (item: ComposerCommandItem) => { |
| | 1473 | if (composerSelectLockRef.current) return; |
| | 1474 | composerSelectLockRef.current = true; |
| | 1475 | window.requestAnimationFrame(() => { |
| | 1476 | composerSelectLockRef.current = false; |
| | 1477 | }); |
| | 1478 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| | 1479 | if (!trigger) return; |
| | 1480 | if (item.type === "path") { |
| | 1481 | const replacement = `@${item.path} `; |
| | 1482 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| | 1483 | snapshot.value, |
| | 1484 | trigger.rangeEnd, |
| | 1485 | replacement, |
| | 1486 | ); |
| | 1487 | const applied = applyPromptReplacement( |
| | 1488 | trigger.rangeStart, |
| | 1489 | replacementRangeEnd, |
| | 1490 | replacement, |
| | 1491 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, |
| | 1492 | ); |
| | 1493 | if (applied) { |
| | 1494 | setComposerHighlightedItemId(null); |
| 1502 | } | 1495 | } |
| 1503 | if (item.type === "slash-command") { | 1496 | return; |
| 1504 | if (item.command === "model") { | 1497 | } |
| 1505 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 1498 | if (item.type === "slash-command") { |
| 1506 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | 1499 | if (item.command === "model") { |
| 1507 | focusEditorAfterReplace: false, | | |
| 1508 | }); | | |
| 1509 | if (applied) { | | |
| 1510 | setComposerHighlightedItemId(null); | | |
| 1511 | setIsComposerModelPickerOpen(true); | | |
| 1512 | } | | |
| 1513 | return; | | |
| 1514 | } | | |
| 1515 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | | |
| 1516 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 1500 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 1517 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | 1501 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), |
| | 1502 | focusEditorAfterReplace: false, |
| 1518 | }); | 1503 | }); |
| 1519 | if (applied) { | 1504 | if (applied) { |
| 1520 | setComposerHighlightedItemId(null); | 1505 | setComposerHighlightedItemId(null); |
| | 1506 | setIsComposerModelPickerOpen(true); |
| 1521 | } | 1507 | } |
| 1522 | return; | 1508 | return; |
| 1523 | } | 1509 | } |
| 1524 | if (item.type === "provider-slash-command") { | 1510 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); |
| 1525 | const replacement = `/${item.command.name} `; | 1511 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 1526 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | 1512 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), |
| 1527 | snapshot.value, | 1513 | }); |
| 1528 | trigger.rangeEnd, | 1514 | if (applied) { |
| 1529 | replacement, | 1515 | setComposerHighlightedItemId(null); |
| 1530 | ); | | |
| 1531 | const applied = applyPromptReplacement( | | |
| 1532 | trigger.rangeStart, | | |
| 1533 | replacementRangeEnd, | | |
| 1534 | replacement, | | |
| 1535 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | | |
| 1536 | ); | | |
| 1537 | if (applied) { | | |
| 1538 | setComposerHighlightedItemId(null); | | |
| 1539 | } | | |
| 1540 | return; | | |
| 1541 | } | | |
| 1542 | if (item.type === "skill") { | | |
| 1543 | const replacement = `$${item.skill.name} `; | | |
| 1544 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | | |
| 1545 | snapshot.value, | | |
| 1546 | trigger.rangeEnd, | | |
| 1547 | replacement, | | |
| 1548 | ); | | |
| 1549 | const applied = applyPromptReplacement( | | |
| 1550 | trigger.rangeStart, | | |
| 1551 | replacementRangeEnd, | | |
| 1552 | replacement, | | |
| 1553 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | | |
| 1554 | ); | | |
| 1555 | if (applied) { | | |
| 1556 | setComposerHighlightedItemId(null); | | |
| 1557 | } | | |
| 1558 | return; | | |
| 1559 | } | 1516 | } |
| 1560 | }, | 1517 | return; |
| 1561 | [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], | | |
| 1562 | ); | | |
| 1563 | | | |
| 1564 | const onComposerMenuItemHighlighted = useCallback( | | |
| 1565 | (itemId: string | null) => { | | |
| 1566 | setComposerHighlightedItemId(itemId); | | |
| 1567 | setComposerHighlightedSearchKey(composerMenuSearchKey); | | |
| 1568 | }, | | |
| 1569 | [composerMenuSearchKey], | | |
| 1570 | ); | | |
| 1571 | | | |
| 1572 | const nudgeComposerMenuHighlight = useCallback( | | |
| 1573 | (key: "ArrowDown" | "ArrowUp") => { | | |
| 1574 | if (composerMenuItems.length === 0) return; | | |
| 1575 | const highlightedIndex = composerMenuItems.findIndex( | | |
| 1576 | (item) => item.id === composerHighlightedItemId, | | |
| 1577 | ); | | |
| 1578 | const normalizedIndex = | | |
| 1579 | highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; | | |
| 1580 | const offset = key === "ArrowDown" ? 1 : -1; | | |
| 1581 | const nextIndex = | | |
| 1582 | (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; | | |
| 1583 | const nextItem = composerMenuItems[nextIndex]; | | |
| 1584 | setComposerHighlightedItemId(nextItem?.id ?? null); | | |
| 1585 | }, | | |
| 1586 | [composerHighlightedItemId, composerMenuItems], | | |
| 1587 | ); | | |
| 1588 | | | |
| 1589 | const blurMobileComposerAfterSend = useCallback(() => { | | |
| 1590 | if (!isMobileViewport) return; | | |
| 1591 | if (composerBlurFrameRef.current !== null) { | | |
| 1592 | window.cancelAnimationFrame(composerBlurFrameRef.current); | | |
| 1593 | composerBlurFrameRef.current = null; | | |
| 1594 | } | | |
| 1595 | const activeElement = document.activeElement; | | |
| 1596 | if (activeElement instanceof HTMLElement) { | | |
| 1597 | activeElement.blur(); | | |
| 1598 | } | | |
| 1599 | setIsComposerFocused(false); | | |
| 1600 | }, [isMobileViewport]); | | |
| 1601 | | | |
| 1602 | const shouldBlurMobileComposerOnSubmit = useCallback(() => { | | |
| 1603 | if (!isMobileViewport) return false; | | |
| 1604 | if (isSendBusy || isConnecting || phase === "running") return false; | | |
| 1605 | if (activePendingProgress) { | | |
| 1606 | return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); | | |
| 1607 | } | 1518 | } |
| 1608 | return showPlanFollowUpPrompt || composerSendState.hasSendableContent; | 1519 | if (item.type === "provider-slash-command") { |
| 1609 | }, [ | 1520 | const replacement = `/${item.command.name} `; |
| 1610 | activePendingProgress, | 1521 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| 1611 | activePendingResolvedAnswers, | 1522 | snapshot.value, |
| 1612 | composerSendState.hasSendableContent, | 1523 | trigger.rangeEnd, |
| 1613 | isConnecting, | 1524 | replacement, |
| 1614 | isMobileViewport, | 1525 | ); |
| 1615 | isSendBusy, | 1526 | const applied = applyPromptReplacement( |
| 1616 | phase, | 1527 | trigger.rangeStart, |
| 1617 | showPlanFollowUpPrompt, | 1528 | replacementRangeEnd, |
| 1618 | ]); | 1529 | replacement, |
| 1619 | | 1530 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, |
| 1620 | const submitComposer = useCallback( | 1531 | ); |
| 1621 | (event?: { preventDefault: () => void }) => { | 1532 | if (applied) { |
| 1622 | onSend(event); | 1533 | setComposerHighlightedItemId(null); |
| 1623 | if (shouldBlurMobileComposerOnSubmit()) { | | |
| 1624 | blurMobileComposerAfterSend(); | | |
| 1625 | } | 1534 | } |
| 1626 | }, | 1535 | return; |
| 1627 | [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], | | |
| 1628 | ); | | |
| 1629 | const expandMobileComposer = useCallback(() => { | | |
| 1630 | if (composerBlurFrameRef.current !== null) { | | |
| 1631 | window.cancelAnimationFrame(composerBlurFrameRef.current); | | |
| 1632 | composerBlurFrameRef.current = null; | | |
| 1633 | } | | |
| 1634 | if (mobileComposerExpandFrameRef.current !== null) { | | |
| 1635 | window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); | | |
| 1636 | } | 1536 | } |
| 1637 | if (mobileComposerExpandReleaseFrameRef.current !== null) { | 1537 | if (item.type === "skill") { |
| 1638 | window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); | 1538 | const replacement = `$${item.skill.name} `; |
| | 1539 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| | 1540 | snapshot.value, |
| | 1541 | trigger.rangeEnd, |
| | 1542 | replacement, |
| | 1543 | ); |
| | 1544 | const applied = applyPromptReplacement( |
| | 1545 | trigger.rangeStart, |
| | 1546 | replacementRangeEnd, |
| | 1547 | replacement, |
| | 1548 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, |
| | 1549 | ); |
| | 1550 | if (applied) { |
| | 1551 | setComposerHighlightedItemId(null); |
| | 1552 | } |
| | 1553 | return; |
| 1639 | } | 1554 | } |
| 1640 | mobileComposerExpandInFlightRef.current = true; | 1555 | }, |
| 1641 | setIsComposerFocused(true); | 1556 | [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], |
| 1642 | mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => { | 1557 | ); |
| 1643 | mobileComposerExpandFrameRef.current = null; | 1558 | |
| 1644 | composerEditorRef.current?.focusAtEnd(); | 1559 | const onComposerMenuItemHighlighted = useCallback( |
| 1645 | mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => { | 1560 | (itemId: string | null) => { |
| 1646 | mobileComposerExpandReleaseFrameRef.current = null; | 1561 | setComposerHighlightedItemId(itemId); |
| 1647 | mobileComposerExpandInFlightRef.current = false; | 1562 | setComposerHighlightedSearchKey(composerMenuSearchKey); |
| 1648 | }); | 1563 | }, |
| | 1564 | [composerMenuSearchKey], |
| | 1565 | ); |
| | 1566 | |
| | 1567 | const nudgeComposerMenuHighlight = useCallback( |
| | 1568 | (key: "ArrowDown" | "ArrowUp") => { |
| | 1569 | if (composerMenuItems.length === 0) return; |
| | 1570 | const highlightedIndex = composerMenuItems.findIndex( |
| | 1571 | (item) => item.id === composerHighlightedItemId, |
| | 1572 | ); |
| | 1573 | const normalizedIndex = |
| | 1574 | highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; |
| | 1575 | const offset = key === "ArrowDown" ? 1 : -1; |
| | 1576 | const nextIndex = |
| | 1577 | (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; |
| | 1578 | const nextItem = composerMenuItems[nextIndex]; |
| | 1579 | setComposerHighlightedItemId(nextItem?.id ?? null); |
| | 1580 | }, |
| | 1581 | [composerHighlightedItemId, composerMenuItems], |
| | 1582 | ); |
| | 1583 | |
| | 1584 | const blurMobileComposerAfterSend = useCallback(() => { |
| | 1585 | if (!isMobileViewport) return; |
| | 1586 | if (composerBlurFrameRef.current !== null) { |
| | 1587 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| | 1588 | composerBlurFrameRef.current = null; |
| | 1589 | } |
| | 1590 | const activeElement = document.activeElement; |
| | 1591 | if (activeElement instanceof HTMLElement) { |
| | 1592 | activeElement.blur(); |
| | 1593 | } |
| | 1594 | setIsComposerFocused(false); |
| | 1595 | }, [isMobileViewport]); |
| | 1596 | |
| | 1597 | const shouldBlurMobileComposerOnSubmit = useCallback(() => { |
| | 1598 | if (!isMobileViewport) return false; |
| | 1599 | if (isSendBusy || isConnecting || phase === "running") return false; |
| | 1600 | if (activePendingProgress) { |
| | 1601 | return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); |
| | 1602 | } |
| | 1603 | return showPlanFollowUpPrompt || composerSendState.hasSendableContent; |
| | 1604 | }, [ |
| | 1605 | activePendingProgress, |
| | 1606 | activePendingResolvedAnswers, |
| | 1607 | composerSendState.hasSendableContent, |
| | 1608 | isConnecting, |
| | 1609 | isMobileViewport, |
| | 1610 | isSendBusy, |
| | 1611 | phase, |
| | 1612 | showPlanFollowUpPrompt, |
| | 1613 | ]); |
| | 1614 | |
| | 1615 | const submitComposer = useCallback( |
| | 1616 | (event?: { preventDefault: () => void }) => { |
| | 1617 | onSend(event); |
| | 1618 | if (shouldBlurMobileComposerOnSubmit()) { |
| | 1619 | blurMobileComposerAfterSend(); |
| | 1620 | } |
| | 1621 | }, |
| | 1622 | [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], |
| | 1623 | ); |
| | 1624 | const expandMobileComposer = useCallback(() => { |
| | 1625 | if (composerBlurFrameRef.current !== null) { |
| | 1626 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| | 1627 | composerBlurFrameRef.current = null; |
| | 1628 | } |
| | 1629 | if (mobileComposerExpandFrameRef.current !== null) { |
| | 1630 | window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); |
| | 1631 | } |
| | 1632 | if (mobileComposerExpandReleaseFrameRef.current !== null) { |
| | 1633 | window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); |
| | 1634 | } |
| | 1635 | mobileComposerExpandInFlightRef.current = true; |
| | 1636 | setIsComposerFocused(true); |
| | 1637 | mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => { |
| | 1638 | mobileComposerExpandFrameRef.current = null; |
| | 1639 | composerEditorRef.current?.focusAtEnd(); |
| | 1640 | mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => { |
| | 1641 | mobileComposerExpandReleaseFrameRef.current = null; |
| | 1642 | mobileComposerExpandInFlightRef.current = false; |
| 1649 | }); | 1643 | }); |
| 1650 | }, []); | 1644 | }); |
| 1651 | | 1645 | }, []); |
| 1652 | // ------------------------------------------------------------------ | 1646 | |
| 1653 | // Callbacks: command key | 1647 | // ------------------------------------------------------------------ |
| 1654 | // ------------------------------------------------------------------ | 1648 | // Callbacks: command key |
| 1655 | const onComposerCommandKey = ( | 1649 | // ------------------------------------------------------------------ |
| 1656 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | 1650 | const onComposerCommandKey = ( |
| 1657 | event: KeyboardEvent, | 1651 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| 1658 | ) => { | 1652 | event: KeyboardEvent, |
| 1659 | if (key === "Tab" && event.shiftKey) { | 1653 | ) => { |
| 1660 | toggleInteractionMode(); | 1654 | if (key === "Tab" && event.shiftKey) { |
| | 1655 | toggleInteractionMode(); |
| | 1656 | return true; |
| | 1657 | } |
| | 1658 | const { trigger } = resolveActiveComposerTrigger(); |
| | 1659 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; |
| | 1660 | if (menuIsActive) { |
| | 1661 | const currentItems = composerMenuItemsRef.current; |
| | 1662 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; |
| | 1663 | if (key === "ArrowDown" && currentItems.length > 0) { |
| | 1664 | nudgeComposerMenuHighlight("ArrowDown"); |
| 1661 | return true; | 1665 | return true; |
| 1662 | } | 1666 | } |
| 1663 | const { trigger } = resolveActiveComposerTrigger(); | 1667 | if (key === "ArrowUp" && currentItems.length > 0) { |
| 1664 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | 1668 | nudgeComposerMenuHighlight("ArrowUp"); |
| 1665 | if (menuIsActive) { | 1669 | return true; |
| 1666 | const currentItems = composerMenuItemsRef.current; | | |
| 1667 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; | | |
| 1668 | if (key === "ArrowDown" && currentItems.length > 0) { | | |
| 1669 | nudgeComposerMenuHighlight("ArrowDown"); | | |
| 1670 | return true; | | |
| 1671 | } | | |
| 1672 | if (key === "ArrowUp" && currentItems.length > 0) { | | |
| 1673 | nudgeComposerMenuHighlight("ArrowUp"); | | |
| 1674 | return true; | | |
| 1675 | } | | |
| 1676 | if ((key === "Enter" || key === "Tab") && selectedItem) { | | |
| 1677 | onSelectComposerItem(selectedItem); | | |
| 1678 | return true; | | |
| 1679 | } | | |
| 1680 | } | 1670 | } |
| 1681 | if (key === "Enter" && !event.shiftKey) { | 1671 | if ((key === "Enter" || key === "Tab") && selectedItem) { |
| 1682 | submitComposer(); | 1672 | onSelectComposerItem(selectedItem); |
| 1683 | return true; | 1673 | return true; |
| 1684 | } | 1674 | } |
| 1685 | return false; | 1675 | } |
| 1686 | }; | 1676 | if (key === "Enter" && !event.shiftKey) { |
| | 1677 | submitComposer(); |
| | 1678 | return true; |
| | 1679 | } |
| | 1680 | return false; |
| | 1681 | }; |
| 1687 | | 1682 | |
| 1688 | // ------------------------------------------------------------------ | 1683 | // ------------------------------------------------------------------ |
| 1689 | // Callbacks: images | 1684 | // Callbacks: images |
| 1690 | // ------------------------------------------------------------------ | 1685 | // ------------------------------------------------------------------ |
| 1691 | const addComposerImages = (files: File[]) => { | 1686 | const addComposerImages = (files: File[]) => { |
| 1692 | if (!activeThreadId || files.length === 0) return; | 1687 | if (!activeThreadId || files.length === 0) return; |
| 1693 | if (pendingUserInputs.length > 0) { | 1688 | if (pendingUserInputs.length > 0) { |
| 1694 | toastManager.add({ | 1689 | toastManager.add({ |
| 1695 | type: "error", | 1690 | type: "error", |
| 1696 | title: "Attach images after answering plan questions.", | 1691 | title: "Attach images after answering plan questions.", |
| 1697 | }); | 1692 | }); |
| 1698 | return; | 1693 | return; |
| | 1694 | } |
| | 1695 | const nextImages: ComposerImageAttachment[] = []; |
| | 1696 | let nextImageCount = composerImagesRef.current.length; |
| | 1697 | let error: string | null = null; |
| | 1698 | for (const file of files) { |
| | 1699 | if (!file.type.startsWith("image/")) { |
| | 1700 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; |
| | 1701 | continue; |
| 1699 | } | 1702 | } |
| 1700 | const nextImages: ComposerImageAttachment[] = []; | 1703 | if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { |
| 1701 | let nextImageCount = composerImagesRef.current.length; | 1704 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; |
| 1702 | let error: string | null = null; | 1705 | continue; |
| 1703 | for (const file of files) { | | |
| 1704 | if (!file.type.startsWith("image/")) { | | |
| 1705 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | | |
| 1706 | continue; | | |
| 1707 | } | | |
| 1708 | if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | | |
| 1709 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | | |
| 1710 | continue; | | |
| 1711 | } | | |
| 1712 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | | |
| 1713 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | | |
| 1714 | break; | | |
| 1715 | } | | |
| 1716 | const previewUrl = URL.createObjectURL(file); | | |
| 1717 | nextImages.push({ | | |
| 1718 | type: "image", | | |
| 1719 | id: randomUUID(), | | |
| 1720 | name: file.name || "image", | | |
| 1721 | mimeType: file.type, | | |
| 1722 | sizeBytes: file.size, | | |
| 1723 | previewUrl, | | |
| 1724 | file, | | |
| 1725 | }); | | |
| 1726 | nextImageCount += 1; | | |
| 1727 | } | 1706 | } |
| 1728 | if (nextImages.length === 1 && nextImages[0]) { | 1707 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| 1729 | addComposerImage(nextImages[0]); | 1708 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 1730 | } else if (nextImages.length > 1) { | 1709 | break; |
| 1731 | addComposerImagesToDraft(nextImages); | | |
| 1732 | } | 1710 | } |
| 1733 | setThreadError(activeThreadId, error); | 1711 | const previewUrl = URL.createObjectURL(file); |
| 1734 | }; | 1712 | nextImages.push({ |
| 1735 | | 1713 | type: "image", |
| 1736 | const removeComposerImage = (imageId: string) => { | 1714 | id: randomUUID(), |
| 1737 | removeComposerImageFromDraft(imageId); | 1715 | name: file.name || "image", |
| 1738 | }; | 1716 | mimeType: file.type, |
| | 1717 | sizeBytes: file.size, |
| | 1718 | previewUrl, |
| | 1719 | file, |
| | 1720 | }); |
| | 1721 | nextImageCount += 1; |
| | 1722 | } |
| | 1723 | if (nextImages.length === 1 && nextImages[0]) { |
| | 1724 | addComposerImage(nextImages[0]); |
| | 1725 | } else if (nextImages.length > 1) { |
| | 1726 | addComposerImagesToDraft(nextImages); |
| | 1727 | } |
| | 1728 | setThreadError(activeThreadId, error); |
| | 1729 | }; |
| 1739 | | 1730 | |
| 1740 | // ------------------------------------------------------------------ | 1731 | const removeComposerImage = (imageId: string) => { |
| 1741 | // Callbacks: paste / drag | 1732 | removeComposerImageFromDraft(imageId); |
| 1742 | // ------------------------------------------------------------------ | 1733 | }; |
| 1743 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | | |
| 1744 | const files = Array.from(event.clipboardData.files); | | |
| 1745 | if (files.length === 0) return; | | |
| 1746 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); | | |
| 1747 | if (imageFiles.length === 0) return; | | |
| 1748 | event.preventDefault(); | | |
| 1749 | addComposerImages(imageFiles); | | |
| 1750 | }; | | |
| 1751 | | 1734 | |
| 1752 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | 1735 | // ------------------------------------------------------------------ |
| 1753 | if (!event.dataTransfer.types.includes("Files")) return; | 1736 | // Callbacks: paste / drag |
| 1754 | event.preventDefault(); | 1737 | // ------------------------------------------------------------------ |
| 1755 | dragDepthRef.current += 1; | 1738 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 1756 | setIsDragOverComposer(true); | 1739 | const files = Array.from(event.clipboardData.files); |
| 1757 | }; | 1740 | if (files.length === 0) return; |
| | 1741 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); |
| | 1742 | if (imageFiles.length === 0) return; |
| | 1743 | event.preventDefault(); |
| | 1744 | addComposerImages(imageFiles); |
| | 1745 | }; |
| 1758 | | 1746 | |
| 1759 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { | 1747 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { |
| 1760 | if (!event.dataTransfer.types.includes("Files")) return; | 1748 | if (!event.dataTransfer.types.includes("Files")) return; |
| 1761 | event.preventDefault(); | 1749 | event.preventDefault(); |
| 1762 | event.dataTransfer.dropEffect = "copy"; | 1750 | dragDepthRef.current += 1; |
| 1763 | setIsDragOverComposer(true); | 1751 | setIsDragOverComposer(true); |
| 1764 | }; | 1752 | }; |
| 1765 | | 1753 | |
| 1766 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { | 1754 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { |
| 1767 | if (!event.dataTransfer.types.includes("Files")) return; | 1755 | if (!event.dataTransfer.types.includes("Files")) return; |
| 1768 | event.preventDefault(); | 1756 | event.preventDefault(); |
| 1769 | const nextTarget = event.relatedTarget; | 1757 | event.dataTransfer.dropEffect = "copy"; |
| 1770 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; | 1758 | setIsDragOverComposer(true); |
| 1771 | dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); | 1759 | }; |
| 1772 | if (dragDepthRef.current === 0) { | | |
| 1773 | setIsDragOverComposer(false); | | |
| 1774 | } | | |
| 1775 | }; | | |
| 1776 | | 1760 | |
| 1777 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { | 1761 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { |
| 1778 | if (!event.dataTransfer.types.includes("Files")) return; | 1762 | if (!event.dataTransfer.types.includes("Files")) return; |
| 1779 | event.preventDefault(); | 1763 | event.preventDefault(); |
| 1780 | dragDepthRef.current = 0; | 1764 | const nextTarget = event.relatedTarget; |
| | 1765 | if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; |
| | 1766 | dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); |
| | 1767 | if (dragDepthRef.current === 0) { |
| 1781 | setIsDragOverComposer(false); | 1768 | setIsDragOverComposer(false); |
| 1782 | const files = Array.from(event.dataTransfer.files); | 1769 | } |
| 1783 | addComposerImages(files); | 1770 | }; |
| 1784 | focusComposer(); | 1771 | |
| 1785 | }; | 1772 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { |
| 1786 | const handleInterruptPrimaryAction = useCallback(() => { | 1773 | if (!event.dataTransfer.types.includes("Files")) return; |
| 1787 | void onInterrupt(); | 1774 | event.preventDefault(); |
| 1788 | }, [onInterrupt]); | 1775 | dragDepthRef.current = 0; |
| 1789 | const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { | 1776 | setIsDragOverComposer(false); |
| 1790 | void onImplementPlanInNewThread(); | 1777 | const files = Array.from(event.dataTransfer.files); |
| 1791 | }, [onImplementPlanInNewThread]); | 1778 | addComposerImages(files); |
| 1792 | const scheduleComposerCollapseCheck = useCallback(() => { | 1779 | focusComposer(); |
| 1793 | if (!isMobileViewport) { | 1780 | }; |
| | 1781 | const handleInterruptPrimaryAction = useCallback(() => { |
| | 1782 | void onInterrupt(); |
| | 1783 | }, [onInterrupt]); |
| | 1784 | const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { |
| | 1785 | void onImplementPlanInNewThread(); |
| | 1786 | }, [onImplementPlanInNewThread]); |
| | 1787 | const scheduleComposerCollapseCheck = useCallback(() => { |
| | 1788 | if (!isMobileViewport) { |
| | 1789 | return; |
| | 1790 | } |
| | 1791 | if (mobileComposerExpandInFlightRef.current) { |
| | 1792 | return; |
| | 1793 | } |
| | 1794 | if (composerBlurFrameRef.current !== null) { |
| | 1795 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| | 1796 | } |
| | 1797 | composerBlurFrameRef.current = window.requestAnimationFrame(() => { |
| | 1798 | composerBlurFrameRef.current = null; |
| | 1799 | if (mobileComposerExpandInFlightRef.current) { |
| 1794 | return; | 1800 | return; |
| 1795 | } | 1801 | } |
| 1796 | if (mobileComposerExpandInFlightRef.current) { | 1802 | const composerSurface = composerSurfaceRef.current; |
| | 1803 | const activeElement = document.activeElement; |
| | 1804 | if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { |
| 1797 | return; | 1805 | return; |
| 1798 | } | 1806 | } |
| | 1807 | if ( |
| | 1808 | composerSurface && |
| | 1809 | activeElement instanceof Node && |
| | 1810 | composerSurface.contains(activeElement) |
| | 1811 | ) { |
| | 1812 | return; |
| | 1813 | } |
| | 1814 | setIsComposerFocused(false); |
| | 1815 | }); |
| | 1816 | }, [isMobileViewport]); |
| | 1817 | |
| | 1818 | useEffect(() => { |
| | 1819 | return () => { |
| 1799 | if (composerBlurFrameRef.current !== null) { | 1820 | if (composerBlurFrameRef.current !== null) { |
| 1800 | window.cancelAnimationFrame(composerBlurFrameRef.current); | 1821 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| 1801 | } | 1822 | } |
| 1802 | composerBlurFrameRef.current = window.requestAnimationFrame(() => { | 1823 | if (mobileComposerExpandFrameRef.current !== null) { |
| 1803 | composerBlurFrameRef.current = null; | 1824 | window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); |
| 1804 | if (mobileComposerExpandInFlightRef.current) { | 1825 | } |
| 1805 | return; | 1826 | if (mobileComposerExpandReleaseFrameRef.current !== null) { |
| 1806 | } | 1827 | window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); |
| 1807 | const composerSurface = composerSurfaceRef.current; | 1828 | } |
| 1808 | const activeElement = document.activeElement; | 1829 | }; |
| 1809 | if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { | 1830 | }, []); |
| 1810 | return; | 1831 | |
| 1811 | } | 1832 | // ------------------------------------------------------------------ |
| 1812 | if ( | 1833 | // Imperative handle |
| 1813 | composerSurface && | 1834 | // ------------------------------------------------------------------ |
| 1814 | activeElement instanceof Node && | 1835 | useImperativeHandle( |
| 1815 | composerSurface.contains(activeElement) | 1836 | composerRef, |
| 1816 | ) { | 1837 | () => ({ |
| 1817 | return; | 1838 | focusAtEnd: () => { |
| 1818 | } | 1839 | composerEditorRef.current?.focusAtEnd(); |
| 1819 | setIsComposerFocused(false); | 1840 | }, |
| 1820 | }); | 1841 | focusAt: (cursor: number) => { |
| 1821 | }, [isMobileViewport]); | 1842 | composerEditorRef.current?.focusAt(cursor); |
| 1822 | | 1843 | }, |
| 1823 | useEffect(() => { | 1844 | openModelPicker: () => { |
| 1824 | return () => { | 1845 | setIsComposerModelPickerOpen(true); |
| 1825 | if (composerBlurFrameRef.current !== null) { | 1846 | }, |
| 1826 | window.cancelAnimationFrame(composerBlurFrameRef.current); | 1847 | toggleModelPicker: () => { |
| 1827 | } | 1848 | setIsComposerModelPickerOpen((open) => !open); |
| 1828 | if (mobileComposerExpandFrameRef.current !== null) { | 1849 | }, |
| 1829 | window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); | 1850 | isModelPickerOpen: () => isComposerModelPickerOpen, |
| 1830 | } | 1851 | readSnapshot: () => { |
| 1831 | if (mobileComposerExpandReleaseFrameRef.current !== null) { | 1852 | return readComposerSnapshot(); |
| 1832 | window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); | 1853 | }, |
| 1833 | } | 1854 | resetCursorState: (options?: { |
| 1834 | }; | 1855 | cursor?: number; |
| 1835 | }, []); | 1856 | prompt?: string; |
| 1836 | | 1857 | detectTrigger?: boolean; |
| 1837 | // ------------------------------------------------------------------ | 1858 | }) => { |
| 1838 | // Imperative handle | 1859 | const promptForState = options?.prompt ?? promptRef.current; |
| 1839 | // ------------------------------------------------------------------ | 1860 | const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); |
| 1840 | useImperativeHandle( | 1861 | setComposerHighlightedItemId(null); |
| 1841 | ref, | 1862 | setComposerCursor(cursor); |
| 1842 | () => ({ | 1863 | setComposerTrigger( |
| 1843 | focusAtEnd: () => { | 1864 | options?.detectTrigger |
| 1844 | composerEditorRef.current?.focusAtEnd(); | 1865 | ? detectComposerTrigger( |
| 1845 | }, | 1866 | promptForState, |
| 1846 | focusAt: (cursor: number) => { | 1867 | expandCollapsedComposerCursor(promptForState, cursor), |
| 1847 | composerEditorRef.current?.focusAt(cursor); | 1868 | ) |
| 1848 | }, | 1869 | : null, |
| 1849 | openModelPicker: () => { | 1870 | ); |
| 1850 | setIsComposerModelPickerOpen(true); | 1871 | }, |
| 1851 | }, | 1872 | addTerminalContext: (selection: TerminalContextSelection) => { |
| 1852 | toggleModelPicker: () => { | 1873 | if (!activeThread) return; |
| 1853 | setIsComposerModelPickerOpen((open) => !open); | 1874 | const snapshot = composerEditorRef.current?.readSnapshot() ?? { |
| 1854 | }, | 1875 | value: promptRef.current, |
| 1855 | isModelPickerOpen: () => isComposerModelPickerOpen, | 1876 | cursor: composerCursor, |
| 1856 | readSnapshot: () => { | 1877 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), |
| 1857 | return readComposerSnapshot(); | 1878 | terminalContextIds: composerTerminalContexts.map((context) => context.id), |
| 1858 | }, | 1879 | }; |
| 1859 | resetCursorState: (options?: { | 1880 | const insertion = insertInlineTerminalContextPlaceholder( |
| 1860 | cursor?: number; | 1881 | snapshot.value, |
| 1861 | prompt?: string; | 1882 | snapshot.expandedCursor, |
| 1862 | detectTrigger?: boolean; | 1883 | ); |
| 1863 | }) => { | 1884 | const nextCollapsedCursor = collapseExpandedComposerCursor( |
| 1864 | const promptForState = options?.prompt ?? promptRef.current; | 1885 | insertion.prompt, |
| 1865 | const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); | 1886 | insertion.cursor, |
| 1866 | setComposerHighlightedItemId(null); | 1887 | ); |
| 1867 | setComposerCursor(cursor); | 1888 | const inserted = insertComposerDraftTerminalContext( |
| 1868 | setComposerTrigger( | 1889 | composerDraftTarget, |
| 1869 | options?.detectTrigger | 1890 | insertion.prompt, |
| 1870 | ? detectComposerTrigger( | 1891 | { |
| 1871 | promptForState, | 1892 | id: randomUUID(), |
| 1872 | expandCollapsedComposerCursor(promptForState, cursor), | 1893 | threadId: activeThread.id, |
| 1873 | ) | 1894 | createdAt: new Date().toISOString(), |
| 1874 | : null, | 1895 | ...selection, |
| 1875 | ); | 1896 | }, |
| 1876 | }, | 1897 | insertion.contextIndex, |
| 1877 | addTerminalContext: (selection: TerminalContextSelection) => { | 1898 | ); |
| 1878 | if (!activeThread) return; | 1899 | if (!inserted) return; |
| 1879 | const snapshot = composerEditorRef.current?.readSnapshot() ?? { | 1900 | promptRef.current = insertion.prompt; |
| 1880 | value: promptRef.current, | 1901 | setComposerCursor(nextCollapsedCursor); |
| 1881 | cursor: composerCursor, | 1902 | setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); |
| 1882 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | 1903 | window.requestAnimationFrame(() => { |
| 1883 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | 1904 | composerEditorRef.current?.focusAt(nextCollapsedCursor); |
| 1884 | }; | 1905 | }); |
| 1885 | const insertion = insertInlineTerminalContextPlaceholder( | 1906 | }, |
| 1886 | snapshot.value, | 1907 | getSendContext: () => ({ |
| 1887 | snapshot.expandedCursor, | 1908 | prompt: promptRef.current, |
| 1888 | ); | 1909 | images: composerImagesRef.current, |
| 1889 | const nextCollapsedCursor = collapseExpandedComposerCursor( | 1910 | terminalContexts: composerTerminalContextsRef.current, |
| 1890 | insertion.prompt, | 1911 | selectedPromptEffort, |
| 1891 | insertion.cursor, | | |
| 1892 | ); | | |
| 1893 | const inserted = insertComposerDraftTerminalContext( | | |
| 1894 | composerDraftTarget, | | |
| 1895 | insertion.prompt, | | |
| 1896 | { | | |
| 1897 | id: randomUUID(), | | |
| 1898 | threadId: activeThread.id, | | |
| 1899 | createdAt: new Date().toISOString(), | | |
| 1900 | ...selection, | | |
| 1901 | }, | | |
| 1902 | insertion.contextIndex, | | |
| 1903 | ); | | |
| 1904 | if (!inserted) return; | | |
| 1905 | promptRef.current = insertion.prompt; | | |
| 1906 | setComposerCursor(nextCollapsedCursor); | | |
| 1907 | setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); | | |
| 1908 | window.requestAnimationFrame(() => { | | |
| 1909 | composerEditorRef.current?.focusAt(nextCollapsedCursor); | | |
| 1910 | }); | | |
| 1911 | }, | | |
| 1912 | getSendContext: () => ({ | | |
| 1913 | prompt: promptRef.current, | | |
| 1914 | images: composerImagesRef.current, | | |
| 1915 | terminalContexts: composerTerminalContextsRef.current, | | |
| 1916 | selectedPromptEffort, | | |
| 1917 | selectedModelOptionsForDispatch, | | |
| 1918 | selectedModelSelection, | | |
| 1919 | selectedProvider, | | |
| 1920 | selectedModel, | | |
| 1921 | selectedProviderModels, | | |
| 1922 | }), | | |
| 1923 | }), | | |
| 1924 | [ | | |
| 1925 | activeThread, | | |
| 1926 | composerDraftTarget, | | |
| 1927 | composerCursor, | | |
| 1928 | composerTerminalContexts, | | |
| 1929 | insertComposerDraftTerminalContext, | | |
| 1930 | promptRef, | | |
| 1931 | composerImagesRef, | | |
| 1932 | composerTerminalContextsRef, | | |
| 1933 | isComposerModelPickerOpen, | | |
| 1934 | readComposerSnapshot, | | |
| 1935 | selectedModel, | | |
| 1936 | selectedModelOptionsForDispatch, | 1912 | selectedModelOptionsForDispatch, |
| 1937 | selectedModelSelection, | 1913 | selectedModelSelection, |
| 1938 | selectedPromptEffort, | | |
| 1939 | selectedProvider, | 1914 | selectedProvider, |
| | 1915 | selectedModel, |
| 1940 | selectedProviderModels, | 1916 | selectedProviderModels, |
| 1941 | ], | 1917 | }), |
| 1942 | ); | 1918 | }), |
| | 1919 | [ |
| | 1920 | activeThread, |
| | 1921 | composerDraftTarget, |
| | 1922 | composerCursor, |
| | 1923 | composerTerminalContexts, |
| | 1924 | insertComposerDraftTerminalContext, |
| | 1925 | promptRef, |
| | 1926 | composerImagesRef, |
| | 1927 | composerTerminalContextsRef, |
| | 1928 | isComposerModelPickerOpen, |
| | 1929 | readComposerSnapshot, |
| | 1930 | selectedModel, |
| | 1931 | selectedModelOptionsForDispatch, |
| | 1932 | selectedModelSelection, |
| | 1933 | selectedPromptEffort, |
| | 1934 | selectedProvider, |
| | 1935 | selectedProviderModels, |
| | 1936 | ], |
| | 1937 | ); |
| 1943 | | 1938 | |
| 1944 | // Render | 1939 | // Render |
| 1945 | // ------------------------------------------------------------------ | 1940 | // ------------------------------------------------------------------ |
| 1946 | return ( | 1941 | return ( |
| 1947 | <form | 1942 | <form |
| 1948 | ref={composerFormRef} | 1943 | ref={composerFormRef} |
| 1949 | onSubmit={submitComposer} | 1944 | onSubmit={submitComposer} |
| 1950 | className="mx-auto w-full min-w-0 max-w-208" | 1945 | className="mx-auto w-full min-w-0 max-w-208" |
| 1951 | data-chat-composer-form="true" | 1946 | data-chat-composer-form="true" |
| | 1947 | > |
| | 1948 | <div |
| | 1949 | className={cn( |
| | 1950 | "group rounded-[22px] p-px transition-colors duration-200", |
| | 1951 | composerProviderState.composerFrameClassName, |
| | 1952 | )} |
| | 1953 | onDragEnter={onComposerDragEnter} |
| | 1954 | onDragOver={onComposerDragOver} |
| | 1955 | onDragLeave={onComposerDragLeave} |
| | 1956 | onDrop={onComposerDrop} |
| 1952 | > | 1957 | > |
| 1953 | <div | 1958 | <div |
| | 1959 | ref={composerSurfaceRef} |
| | 1960 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} |
| 1954 | className={cn( | 1961 | className={cn( |
| 1955 | "group rounded-[22px] p-px transition-colors duration-200", | 1962 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", |
| 1956 | composerProviderState.composerFrameClassName, | 1963 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", |
| | 1964 | environmentUnavailable ? "opacity-75" : null, |
| | 1965 | composerProviderState.composerSurfaceClassName, |
| 1957 | )} | 1966 | )} |
| 1958 | onDragEnter={onComposerDragEnter} | 1967 | onFocusCapture={(event) => { |
| 1959 | onDragOver={onComposerDragOver} | 1968 | const activeElement = event.target; |
| 1960 | onDragLeave={onComposerDragLeave} | 1969 | if ( |
| 1961 | onDrop={onComposerDrop} | 1970 | isComposerCollapsedMobile && |
| | 1971 | activeElement instanceof HTMLElement && |
| | 1972 | activeElement.closest('[data-chat-composer-collapsed-controls="true"]') |
| | 1973 | ) { |
| | 1974 | return; |
| | 1975 | } |
| | 1976 | if (composerBlurFrameRef.current !== null) { |
| | 1977 | window.cancelAnimationFrame(composerBlurFrameRef.current); |
| | 1978 | composerBlurFrameRef.current = null; |
| | 1979 | } |
| | 1980 | setIsComposerFocused(true); |
| | 1981 | }} |
| | 1982 | onBlurCapture={() => { |
| | 1983 | scheduleComposerCollapseCheck(); |
| | 1984 | }} |
| 1962 | > | 1985 | > |
| 1963 | <div | 1986 | {!isComposerCollapsedMobile && |
| 1964 | ref={composerSurfaceRef} | 1987 | (activePendingApproval ? ( |
| 1965 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} | 1988 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> |
| 1966 | className={cn( | | |
| 1967 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", | | |
| 1968 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", | | |
| 1969 | environmentUnavailable ? "opacity-75" : null, | | |
| 1970 | composerProviderState.composerSurfaceClassName, | | |
| 1971 | )} | | |
| 1972 | onFocusCapture={(event) => { | | |
| 1973 | const activeElement = event.target; | | |
| 1974 | if ( | | |
| 1975 | isComposerCollapsedMobile && | | |
| 1976 | activeElement instanceof HTMLElement && | | |
| 1977 | activeElement.closest('[data-chat-composer-collapsed-controls="true"]') | | |
| 1978 | ) { | | |
| 1979 | return; | | |
| 1980 | } | | |
| 1981 | if (composerBlurFrameRef.current !== null) { | | |
| 1982 | window.cancelAnimationFrame(composerBlurFrameRef.current); | | |
| 1983 | composerBlurFrameRef.current = null; | | |
| 1984 | } | | |
| 1985 | setIsComposerFocused(true); | | |
| 1986 | }} | | |
| 1987 | onBlurCapture={() => { | | |
| 1988 | scheduleComposerCollapseCheck(); | | |
| 1989 | }} | | |
| 1990 | > | | |
| 1991 | {!isComposerCollapsedMobile && | | |
| 1992 | (activePendingApproval ? ( | | |
| 1993 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | | |
| 1994 | <ComposerPendingApprovalPanel | | |
| 1995 | approval={activePendingApproval} | | |
| 1996 | pendingCount={pendingApprovals.length} | | |
| 1997 | /> | | |
| 1998 | </div> | | |
| 1999 | ) : pendingUserInputs.length > 0 ? ( | | |
| 2000 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | | |
| 2001 | <ComposerPendingUserInputPanel | | |
| 2002 | pendingUserInputs={pendingUserInputs} | | |
| 2003 | respondingRequestIds={respondingRequestIds} | | |
| 2004 | answers={activePendingDraftAnswers} | | |
| 2005 | questionIndex={activePendingQuestionIndex} | | |
| 2006 | onToggleOption={onSelectActivePendingUserInputOption} | | |
| 2007 | onAdvance={onAdvanceActivePendingUserInput} | | |
| 2008 | /> | | |
| 2009 | </div> | | |
| 2010 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | | |
| 2011 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> | | |
| 2012 | <ComposerPlanFollowUpBanner | | |
| 2013 | key={activeProposedPlan.id} | | |
| 2014 | planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} | | |
| 2015 | /> | | |
| 2016 | </div> | | |
| 2017 | ) : null)} | | |
| 2018 | | | |
| 2019 | {isComposerCollapsedMobile && activePendingApproval ? ( | | |
| 2020 | <div | | |
| 2021 | className="rounded-t-[19px] border-b border-border/65 bg-muted/20" | | |
| 2022 | data-chat-composer-collapsed-controls="true" | | |
| 2023 | > | | |
| 2024 | <ComposerPendingApprovalPanel | 1989 | <ComposerPendingApprovalPanel |
| 2025 | approval={activePendingApproval} | 1990 | approval={activePendingApproval} |
| 2026 | pendingCount={pendingApprovals.length} | 1991 | pendingCount={pendingApprovals.length} |
| 2027 | /> | 1992 | /> |
| 2028 | <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4"> | | |
| 2029 | <ComposerPendingApprovalActions | | |
| 2030 | requestId={activePendingApproval.requestId} | | |
| 2031 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} | | |
| 2032 | onRespondToApproval={onRespondToApproval} | | |
| 2033 | /> | | |
| 2034 | </div> | | |
| 2035 | </div> | 1993 | </div> |
| 2036 | ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( | 1994 | ) : pendingUserInputs.length > 0 ? ( |
| 2037 | <div | 1995 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> |
| 2038 | className="rounded-t-[19px] border-b border-border/65 bg-muted/20" | | |
| 2039 | data-chat-composer-collapsed-controls="true" | | |
| 2040 | > | | |
| 2041 | <ComposerPendingUserInputPanel | 1996 | <ComposerPendingUserInputPanel |
| 2042 | pendingUserInputs={pendingUserInputs} | 1997 | pendingUserInputs={pendingUserInputs} |
| 2043 | respondingRequestIds={respondingRequestIds} | 1998 | respondingRequestIds={respondingRequestIds} |
| ... | @@ -2046,240 +2001,70 @@ export const ChatComposer = memo( | ... | @@ -2046,240 +2001,70 @@ export const ChatComposer = memo( |
| 2046 | onToggleOption={onSelectActivePendingUserInputOption} | 2001 | onToggleOption={onSelectActivePendingUserInputOption} |
| 2047 | onAdvance={onAdvanceActivePendingUserInput} | 2002 | onAdvance={onAdvanceActivePendingUserInput} |
| 2048 | /> | 2003 | /> |
| 2049 | <div className="px-3 pb-3 sm:px-4"> | | |
| 2050 | <div | | |
| 2051 | data-chat-composer-mobile-pending-compact="true" | | |
| 2052 | className={cn( | | |
| 2053 | "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80", | | |
| 2054 | !activePendingProgress?.activeQuestion?.multiSelect && "p-0", | | |
| 2055 | )} | | |
| 2056 | > | | |
| 2057 | <button | | |
| 2058 | type="button" | | |
| 2059 | className={cn( | | |
| 2060 | "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", | | |
| 2061 | activePendingProgress?.customAnswer | | |
| 2062 | ? "text-foreground" | | |
| 2063 | : "text-muted-foreground/60", | | |
| 2064 | !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", | | |
| 2065 | )} | | |
| 2066 | onPointerDown={(event) => event.preventDefault()} | | |
| 2067 | onClick={expandMobileComposer} | | |
| 2068 | aria-label="Write custom answer" | | |
| 2069 | > | | |
| 2070 | {activePendingProgress?.customAnswer || "Write custom answer"} | | |
| 2071 | </button> | | |
| 2072 | {activePendingProgress?.activeQuestion?.multiSelect ? ( | | |
| 2073 | <ComposerPrimaryActions | | |
| 2074 | compact | | |
| 2075 | pendingAction={pendingPrimaryAction} | | |
| 2076 | isRunning={false} | | |
| 2077 | showPlanFollowUpPrompt={false} | | |
| 2078 | promptHasText={false} | | |
| 2079 | isSendBusy={isSendBusy} | | |
| 2080 | isConnecting={isConnecting} | | |
| 2081 | isEnvironmentUnavailable={environmentUnavailable !== null} | | |
| 2082 | isPreparingWorktree={false} | | |
| 2083 | hasSendableContent={false} | | |
| 2084 | preserveComposerFocusOnPointerDown | | |
| 2085 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | | |
| 2086 | onInterrupt={handleInterruptPrimaryAction} | | |
| 2087 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | | |
| 2088 | /> | | |
| 2089 | ) : null} | | |
| 2090 | </div> | | |
| 2091 | </div> | | |
| 2092 | </div> | 2004 | </div> |
| 2093 | ) : null} | 2005 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( |
| 2094 | | 2006 | <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20"> |
| 2095 | {showCollapsedMobilePromptRow ? ( | 2007 | <ComposerPlanFollowUpBanner |
| 2096 | <div className="flex items-center justify-between gap-2 px-3 py-2"> | 2008 | key={activeProposedPlan.id} |
| 2097 | <button | 2009 | planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} |
| 2098 | type="button" | 2010 | /> |
| 2099 | className={cn( | | |
| 2100 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", | | |
| 2101 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) | | |
| 2102 | ? "text-foreground" | | |
| 2103 | : "text-muted-foreground/35", | | |
| 2104 | )} | | |
| 2105 | onPointerDown={(event) => event.preventDefault()} | | |
| 2106 | onClick={expandMobileComposer} | | |
| 2107 | aria-label="Expand composer" | | |
| 2108 | > | | |
| 2109 | {activePendingProgress | | |
| 2110 | ? activePendingProgress.customAnswer || | | |
| 2111 | "Type your own answer, or leave this blank to use the selected option" | | |
| 2112 | : prompt.trim() || "Ask anything..."} | | |
| 2113 | </button> | | |
| 2114 | <button | | |
| 2115 | type="button" | | |
| 2116 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" | | |
| 2117 | disabled={collapsedComposerPrimaryActionDisabled} | | |
| 2118 | aria-label={collapsedComposerPrimaryActionLabel} | | |
| 2119 | onPointerDown={(event) => event.preventDefault()} | | |
| 2120 | onClick={(event) => { | | |
| 2121 | event.stopPropagation(); | | |
| 2122 | submitComposer(); | | |
| 2123 | }} | | |
| 2124 | > | | |
| 2125 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | | |
| 2126 | <path | | |
| 2127 | d="M8 3L8 13M8 3L4 7M8 3L12 7" | | |
| 2128 | stroke="currentColor" | | |
| 2129 | strokeWidth="2" | | |
| 2130 | strokeLinecap="round" | | |
| 2131 | strokeLinejoin="round" | | |
| 2132 | /> | | |
| 2133 | </svg> | | |
| 2134 | </button> | | |
| 2135 | </div> | 2011 | </div> |
| 2136 | ) : null} | 2012 | ) : null)} |
| 2137 | | 2013 | |
| | 2014 | {isComposerCollapsedMobile && activePendingApproval ? ( |
| 2138 | <div | 2015 | <div |
| 2139 | className={cn( | 2016 | className="rounded-t-[19px] border-b border-border/65 bg-muted/20" |
| 2140 | "relative px-3 pb-2 sm:px-4", | 2017 | data-chat-composer-collapsed-controls="true" |
| 2141 | hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4", | | |
| 2142 | isComposerCollapsedMobile && "hidden", | | |
| 2143 | )} | | |
| 2144 | > | 2018 | > |
| 2145 | {composerMenuOpen && !isComposerApprovalState && ( | 2019 | <ComposerPendingApprovalPanel |
| 2146 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 2020 | approval={activePendingApproval} |
| 2147 | <ComposerCommandMenu | 2021 | pendingCount={pendingApprovals.length} |
| 2148 | items={composerMenuItems} | 2022 | /> |
| 2149 | resolvedTheme={resolvedTheme} | 2023 | <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4"> |
| 2150 | isLoading={isComposerMenuLoading} | 2024 | <ComposerPendingApprovalActions |
| 2151 | triggerKind={composerTriggerKind} | 2025 | requestId={activePendingApproval.requestId} |
| 2152 | groupSlashCommandSections={ | 2026 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 2153 | composerTrigger?.kind === "slash-command" && | 2027 | onRespondToApproval={onRespondToApproval} |
| 2154 | composerTrigger.query.trim().length === 0 | | |
| 2155 | } | | |
| 2156 | emptyStateText={composerMenuEmptyState} | | |
| 2157 | activeItemId={activeComposerMenuItem?.id ?? null} | | |
| 2158 | onHighlightedItemChange={onComposerMenuItemHighlighted} | | |
| 2159 | onSelect={onSelectComposerItem} | | |
| 2160 | /> | | |
| 2161 | </div> | | |
| 2162 | )} | | |
| 2163 | | | |
| 2164 | {!isComposerCollapsedMobile && | | |
| 2165 | !isComposerApprovalState && | | |
| 2166 | pendingUserInputs.length === 0 && | | |
| 2167 | composerImages.length > 0 && ( | | |
| 2168 | <div className="mb-3 flex flex-wrap gap-2"> | | |
| 2169 | {composerImages.map((image) => ( | | |
| 2170 | <div | | |
| 2171 | key={image.id} | | |
| 2172 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | | |
| 2173 | > | | |
| 2174 | {image.previewUrl ? ( | | |
| 2175 | <button | | |
| 2176 | type="button" | | |
| 2177 | className="h-full w-full cursor-zoom-in" | | |
| 2178 | aria-label={`Preview ${image.name}`} | | |
| 2179 | onClick={() => { | | |
| 2180 | const preview = buildExpandedImagePreview(composerImages, image.id); | | |
| 2181 | if (!preview) return; | | |
| 2182 | onExpandImage(preview); | | |
| 2183 | }} | | |
| 2184 | > | | |
| 2185 | <img | | |
| 2186 | src={image.previewUrl} | | |
| 2187 | alt={image.name} | | |
| 2188 | className="h-full w-full object-cover" | | |
| 2189 | /> | | |
| 2190 | </button> | | |
| 2191 | ) : ( | | |
| 2192 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | | |
| 2193 | {image.name} | | |
| 2194 | </div> | | |
| 2195 | )} | | |
| 2196 | {nonPersistedComposerImageIdSet.has(image.id) && ( | | |
| 2197 | <Tooltip> | | |
| 2198 | <TooltipTrigger | | |
| 2199 | render={ | | |
| 2200 | <span | | |
| 2201 | role="img" | | |
| 2202 | aria-label="Draft attachment may not persist" | | |
| 2203 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | | |
| 2204 | > | | |
| 2205 | <CircleAlertIcon className="size-3" /> | | |
| 2206 | </span> | | |
| 2207 | } | | |
| 2208 | /> | | |
| 2209 | <TooltipPopup | | |
| 2210 | side="top" | | |
| 2211 | className="max-w-64 whitespace-normal leading-tight" | | |
| 2212 | > | | |
| 2213 | Draft attachment could not be saved locally and may be lost on | | |
| 2214 | navigation. | | |
| 2215 | </TooltipPopup> | | |
| 2216 | </Tooltip> | | |
| 2217 | )} | | |
| 2218 | <Button | | |
| 2219 | variant="ghost" | | |
| 2220 | size="icon-xs" | | |
| 2221 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | | |
| 2222 | onClick={() => removeComposerImage(image.id)} | | |
| 2223 | aria-label={`Remove ${image.name}`} | | |
| 2224 | > | | |
| 2225 | <XIcon /> | | |
| 2226 | </Button> | | |
| 2227 | </div> | | |
| 2228 | ))} | | |
| 2229 | </div> | | |
| 2230 | )} | | |
| 2231 | | | |
| 2232 | <div className="relative"> | | |
| 2233 | <ComposerPromptEditor | | |
| 2234 | ref={composerEditorRef} | | |
| 2235 | value={ | | |
| 2236 | isComposerApprovalState | | |
| 2237 | ? "" | | |
| 2238 | : activePendingProgress | | |
| 2239 | ? activePendingProgress.customAnswer | | |
| 2240 | : prompt | | |
| 2241 | } | | |
| 2242 | cursor={composerCursor} | | |
| 2243 | terminalContexts={ | | |
| 2244 | !isComposerApprovalState && pendingUserInputs.length === 0 | | |
| 2245 | ? composerTerminalContexts | | |
| 2246 | : [] | | |
| 2247 | } | | |
| 2248 | skills={selectedProviderStatus?.skills ?? []} | | |
| 2249 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} | | |
| 2250 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} | | |
| 2251 | onChange={onPromptChange} | | |
| 2252 | onCommandKeyDown={onComposerCommandKey} | | |
| 2253 | onPaste={onComposerPaste} | | |
| 2254 | placeholder={ | | |
| 2255 | isComposerApprovalState | | |
| 2256 | ? (activePendingApproval?.detail ?? | | |
| 2257 | "Resolve this approval request to continue") | | |
| 2258 | : activePendingProgress | | |
| 2259 | ? "Type your own answer, or leave this blank to use the selected option" | | |
| 2260 | : showPlanFollowUpPrompt && activeProposedPlan | | |
| 2261 | ? "Add feedback to refine the plan, or leave this blank to implement it" | | |
| 2262 | : environmentUnavailable | | |
| 2263 | ? `${environmentUnavailable.label} is ${ | | |
| 2264 | environmentUnavailable.connectionState === "connecting" | | |
| 2265 | ? "connecting" | | |
| 2266 | : "disconnected" | | |
| 2267 | }` | | |
| 2268 | : phase === "disconnected" | | |
| 2269 | ? "Ask for follow-up changes or attach images" | | |
| 2270 | : "Ask anything, @tag files/folders, $use skills, or / for commands" | | |
| 2271 | } | | |
| 2272 | disabled={ | | |
| 2273 | isConnecting || | | |
| 2274 | isComposerApprovalState || | | |
| 2275 | (environmentUnavailable !== null && activePendingProgress === null) | | |
| 2276 | } | | |
| 2277 | /> | 2028 | /> |
| 2278 | {showMobilePendingAnswerActions ? ( | 2029 | </div> |
| 2279 | <div | 2030 | </div> |
| 2280 | data-chat-composer-mobile-pending-actions="true" | 2031 | ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( |
| 2281 | className="absolute bottom-0 right-0 flex justify-end" | 2032 | <div |
| | 2033 | className="rounded-t-[19px] border-b border-border/65 bg-muted/20" |
| | 2034 | data-chat-composer-collapsed-controls="true" |
| | 2035 | > |
| | 2036 | <ComposerPendingUserInputPanel |
| | 2037 | pendingUserInputs={pendingUserInputs} |
| | 2038 | respondingRequestIds={respondingRequestIds} |
| | 2039 | answers={activePendingDraftAnswers} |
| | 2040 | questionIndex={activePendingQuestionIndex} |
| | 2041 | onToggleOption={onSelectActivePendingUserInputOption} |
| | 2042 | onAdvance={onAdvanceActivePendingUserInput} |
| | 2043 | /> |
| | 2044 | <div className="px-3 pb-3 sm:px-4"> |
| | 2045 | <div |
| | 2046 | data-chat-composer-mobile-pending-compact="true" |
| | 2047 | className={cn( |
| | 2048 | "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80", |
| | 2049 | !activePendingProgress?.activeQuestion?.multiSelect && "p-0", |
| | 2050 | )} |
| | 2051 | > |
| | 2052 | <button |
| | 2053 | type="button" |
| | 2054 | className={cn( |
| | 2055 | "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm", |
| | 2056 | activePendingProgress?.customAnswer |
| | 2057 | ? "text-foreground" |
| | 2058 | : "text-muted-foreground/60", |
| | 2059 | !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", |
| | 2060 | )} |
| | 2061 | onPointerDown={(event) => event.preventDefault()} |
| | 2062 | onClick={expandMobileComposer} |
| | 2063 | aria-label="Write custom answer" |
| 2282 | > | 2064 | > |
| | 2065 | {activePendingProgress?.customAnswer || "Write custom answer"} |
| | 2066 | </button> |
| | 2067 | {activePendingProgress?.activeQuestion?.multiSelect ? ( |
| 2283 | <ComposerPrimaryActions | 2068 | <ComposerPrimaryActions |
| 2284 | compact | 2069 | compact |
| 2285 | pendingAction={pendingPrimaryAction} | 2070 | pendingAction={pendingPrimaryAction} |
| ... | @@ -2296,128 +2081,328 @@ export const ChatComposer = memo( | ... | @@ -2296,128 +2081,328 @@ export const ChatComposer = memo( |
| 2296 | onInterrupt={handleInterruptPrimaryAction} | 2081 | onInterrupt={handleInterruptPrimaryAction} |
| 2297 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | 2082 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
| 2298 | /> | 2083 | /> |
| 2299 | </div> | 2084 | ) : null} |
| 2300 | ) : null} | 2085 | </div> |
| 2301 | </div> | 2086 | </div> |
| 2302 | </div> | 2087 | </div> |
| | 2088 | ) : null} |
| 2303 | | 2089 | |
| 2304 | {/* Bottom toolbar */} | 2090 | {showCollapsedMobilePromptRow ? ( |
| 2305 | {isComposerCollapsedMobile ? null : activePendingApproval ? ( | 2091 | <div className="flex items-center justify-between gap-2 px-3 py-2"> |
| 2306 | <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3"> | 2092 | <button |
| 2307 | <ComposerPendingApprovalActions | 2093 | type="button" |
| 2308 | requestId={activePendingApproval.requestId} | | |
| 2309 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} | | |
| 2310 | onRespondToApproval={onRespondToApproval} | | |
| 2311 | /> | | |
| 2312 | </div> | | |
| 2313 | ) : ( | | |
| 2314 | <div | | |
| 2315 | data-chat-composer-footer="true" | | |
| 2316 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} | | |
| 2317 | className={cn( | 2094 | className={cn( |
| 2318 | "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3", | 2095 | "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", |
| 2319 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", | 2096 | (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) |
| 2320 | showMobilePendingAnswerActions && "hidden sm:flex", | 2097 | ? "text-foreground" |
| | 2098 | : "text-muted-foreground/35", |
| 2321 | )} | 2099 | )} |
| | 2100 | onPointerDown={(event) => event.preventDefault()} |
| | 2101 | onClick={expandMobileComposer} |
| | 2102 | aria-label="Expand composer" |
| | 2103 | > |
| | 2104 | {activePendingProgress |
| | 2105 | ? activePendingProgress.customAnswer || |
| | 2106 | "Type your own answer, or leave this blank to use the selected option" |
| | 2107 | : prompt.trim() || "Ask anything..."} |
| | 2108 | </button> |
| | 2109 | <button |
| | 2110 | type="button" |
| | 2111 | className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30" |
| | 2112 | disabled={collapsedComposerPrimaryActionDisabled} |
| | 2113 | aria-label={collapsedComposerPrimaryActionLabel} |
| | 2114 | onPointerDown={(event) => event.preventDefault()} |
| | 2115 | onClick={(event) => { |
| | 2116 | event.stopPropagation(); |
| | 2117 | submitComposer(); |
| | 2118 | }} |
| 2322 | > | 2119 | > |
| 2323 | <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | 2120 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| 2324 | <ProviderModelPicker | 2121 | <path |
| 2325 | compact={isComposerFooterCompact} | 2122 | d="M8 3L8 13M8 3L4 7M8 3L12 7" |
| 2326 | activeInstanceId={selectedInstanceId} | 2123 | stroke="currentColor" |
| 2327 | model={selectedModelForPickerWithCustomFallback} | 2124 | strokeWidth="2" |
| 2328 | lockedProvider={lockedProvider} | 2125 | strokeLinecap="round" |
| 2329 | lockedContinuationGroupKey={lockedContinuationGroupKey} | 2126 | strokeLinejoin="round" |
| 2330 | instanceEntries={providerInstanceEntries} | | |
| 2331 | keybindings={keybindings} | | |
| 2332 | modelOptionsByInstance={modelOptionsByInstance} | | |
| 2333 | terminalOpen={terminalOpen} | | |
| 2334 | open={isComposerModelPickerOpen} | | |
| 2335 | {...(composerProviderState.modelPickerIconClassName | | |
| 2336 | ? { | | |
| 2337 | activeProviderIconClassName: | | |
| 2338 | composerProviderState.modelPickerIconClassName, | | |
| 2339 | } | | |
| 2340 | : {})} | | |
| 2341 | onOpenChange={(open) => { | | |
| 2342 | setIsComposerModelPickerOpen(open); | | |
| 2343 | }} | | |
| 2344 | onInstanceModelChange={onProviderModelSelect} | | |
| 2345 | /> | 2127 | /> |
| | 2128 | </svg> |
| | 2129 | </button> |
| | 2130 | </div> |
| | 2131 | ) : null} |
| 2346 | | 2132 | |
| 2347 | {isComposerFooterCompact ? ( | 2133 | <div |
| 2348 | <CompactComposerControlsMenu | 2134 | className={cn( |
| 2349 | activePlan={showPlanSidebarToggle} | 2135 | "relative px-3 pb-2 sm:px-4", |
| 2350 | interactionMode={interactionMode} | 2136 | hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4", |
| 2351 | planSidebarLabel={planSidebarLabel} | 2137 | isComposerCollapsedMobile && "hidden", |
| 2352 | planSidebarOpen={planSidebarOpen} | 2138 | )} |
| 2353 | runtimeMode={runtimeMode} | 2139 | > |
| 2354 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | 2140 | {composerMenuOpen && !isComposerApprovalState && ( |
| 2355 | traitsMenuContent={providerTraitsMenuContent} | 2141 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 2356 | onToggleInteractionMode={toggleInteractionMode} | 2142 | <ComposerCommandMenu |
| 2357 | onTogglePlanSidebar={togglePlanSidebar} | 2143 | items={composerMenuItems} |
| 2358 | onRuntimeModeChange={handleRuntimeModeChange} | 2144 | resolvedTheme={resolvedTheme} |
| 2359 | /> | 2145 | isLoading={isComposerMenuLoading} |
| 2360 | ) : ( | 2146 | triggerKind={composerTriggerKind} |
| 2361 | <> | 2147 | groupSlashCommandSections={ |
| 2362 | {providerTraitsPicker ? ( | 2148 | composerTrigger?.kind === "slash-command" && |
| 2363 | <> | 2149 | composerTrigger.query.trim().length === 0 |
| 2364 | <Separator | 2150 | } |
| 2365 | orientation="vertical" | 2151 | emptyStateText={composerMenuEmptyState} |
| 2366 | className="mx-0.5 hidden h-4 sm:block" | 2152 | activeItemId={activeComposerMenuItem?.id ?? null} |
| | 2153 | onHighlightedItemChange={onComposerMenuItemHighlighted} |
| | 2154 | onSelect={onSelectComposerItem} |
| | 2155 | /> |
| | 2156 | </div> |
| | 2157 | )} |
| | 2158 | |
| | 2159 | {!isComposerCollapsedMobile && |
| | 2160 | !isComposerApprovalState && |
| | 2161 | pendingUserInputs.length === 0 && |
| | 2162 | composerImages.length > 0 && ( |
| | 2163 | <div className="mb-3 flex flex-wrap gap-2"> |
| | 2164 | {composerImages.map((image) => ( |
| | 2165 | <div |
| | 2166 | key={image.id} |
| | 2167 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" |
| | 2168 | > |
| | 2169 | {image.previewUrl ? ( |
| | 2170 | <button |
| | 2171 | type="button" |
| | 2172 | className="h-full w-full cursor-zoom-in" |
| | 2173 | aria-label={`Preview ${image.name}`} |
| | 2174 | onClick={() => { |
| | 2175 | const preview = buildExpandedImagePreview(composerImages, image.id); |
| | 2176 | if (!preview) return; |
| | 2177 | onExpandImage(preview); |
| | 2178 | }} |
| | 2179 | > |
| | 2180 | <img |
| | 2181 | src={image.previewUrl} |
| | 2182 | alt={image.name} |
| | 2183 | className="h-full w-full object-cover" |
| 2367 | /> | 2184 | /> |
| 2368 | {providerTraitsPicker} | 2185 | </button> |
| 2369 | </> | 2186 | ) : ( |
| 2370 | ) : null} | 2187 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> |
| 2371 | <ComposerFooterModeControls | 2188 | {image.name} |
| 2372 | showInteractionModeToggle={ | 2189 | </div> |
| 2373 | composerProviderControls.showInteractionModeToggle | 2190 | )} |
| 2374 | } | 2191 | {nonPersistedComposerImageIdSet.has(image.id) && ( |
| 2375 | interactionMode={interactionMode} | 2192 | <Tooltip> |
| 2376 | runtimeMode={runtimeMode} | 2193 | <TooltipTrigger |
| 2377 | showPlanToggle={showPlanSidebarToggle} | 2194 | render={ |
| 2378 | planSidebarLabel={planSidebarLabel} | 2195 | <span |
| 2379 | planSidebarOpen={planSidebarOpen} | 2196 | role="img" |
| 2380 | onToggleInteractionMode={toggleInteractionMode} | 2197 | aria-label="Draft attachment may not persist" |
| 2381 | onRuntimeModeChange={handleRuntimeModeChange} | 2198 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" |
| 2382 | onTogglePlanSidebar={togglePlanSidebar} | 2199 | > |
| 2383 | /> | 2200 | <CircleAlertIcon className="size-3" /> |
| 2384 | </> | 2201 | </span> |
| 2385 | )} | 2202 | } |
| | 2203 | /> |
| | 2204 | <TooltipPopup |
| | 2205 | side="top" |
| | 2206 | className="max-w-64 whitespace-normal leading-tight" |
| | 2207 | > |
| | 2208 | Draft attachment could not be saved locally and may be lost on |
| | 2209 | navigation. |
| | 2210 | </TooltipPopup> |
| | 2211 | </Tooltip> |
| | 2212 | )} |
| | 2213 | <Button |
| | 2214 | variant="ghost" |
| | 2215 | size="icon-xs" |
| | 2216 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" |
| | 2217 | onClick={() => removeComposerImage(image.id)} |
| | 2218 | aria-label={`Remove ${image.name}`} |
| | 2219 | > |
| | 2220 | <XIcon /> |
| | 2221 | </Button> |
| | 2222 | </div> |
| | 2223 | ))} |
| 2386 | </div> | 2224 | </div> |
| | 2225 | )} |
| 2387 | | 2226 | |
| 2388 | {/* Right side: send / stop button */} | 2227 | <div className="relative"> |
| | 2228 | <ComposerPromptEditor |
| | 2229 | editorRef={composerEditorRef} |
| | 2230 | value={ |
| | 2231 | isComposerApprovalState |
| | 2232 | ? "" |
| | 2233 | : activePendingProgress |
| | 2234 | ? activePendingProgress.customAnswer |
| | 2235 | : prompt |
| | 2236 | } |
| | 2237 | cursor={composerCursor} |
| | 2238 | terminalContexts={ |
| | 2239 | !isComposerApprovalState && pendingUserInputs.length === 0 |
| | 2240 | ? composerTerminalContexts |
| | 2241 | : [] |
| | 2242 | } |
| | 2243 | skills={selectedProviderStatus?.skills ?? []} |
| | 2244 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} |
| | 2245 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} |
| | 2246 | onChange={onPromptChange} |
| | 2247 | onCommandKeyDown={onComposerCommandKey} |
| | 2248 | onPaste={onComposerPaste} |
| | 2249 | placeholder={ |
| | 2250 | isComposerApprovalState |
| | 2251 | ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") |
| | 2252 | : activePendingProgress |
| | 2253 | ? "Type your own answer, or leave this blank to use the selected option" |
| | 2254 | : showPlanFollowUpPrompt && activeProposedPlan |
| | 2255 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| | 2256 | : environmentUnavailable |
| | 2257 | ? `${environmentUnavailable.label} is ${ |
| | 2258 | environmentUnavailable.connectionState === "connecting" |
| | 2259 | ? "connecting" |
| | 2260 | : "disconnected" |
| | 2261 | }` |
| | 2262 | : phase === "disconnected" |
| | 2263 | ? "Ask for follow-up changes or attach images" |
| | 2264 | : "Ask anything, @tag files/folders, $use skills, or / for commands" |
| | 2265 | } |
| | 2266 | disabled={ |
| | 2267 | isConnecting || |
| | 2268 | isComposerApprovalState || |
| | 2269 | (environmentUnavailable !== null && activePendingProgress === null) |
| | 2270 | } |
| | 2271 | /> |
| | 2272 | {showMobilePendingAnswerActions ? ( |
| 2389 | <div | 2273 | <div |
| 2390 | data-chat-composer-actions="right" | 2274 | data-chat-composer-mobile-pending-actions="true" |
| 2391 | data-chat-composer-primary-actions-compact={ | 2275 | className="absolute bottom-0 right-0 flex justify-end" |
| 2392 | isComposerPrimaryActionsCompact ? "true" : "false" | | |
| 2393 | } | | |
| 2394 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" | | |
| 2395 | > | 2276 | > |
| 2396 | <ComposerFooterPrimaryActions | 2277 | <ComposerPrimaryActions |
| 2397 | compact={isComposerPrimaryActionsCompact} | 2278 | compact |
| 2398 | activeContextWindow={activeContextWindow} | | |
| 2399 | pendingAction={pendingPrimaryAction} | 2279 | pendingAction={pendingPrimaryAction} |
| 2400 | isRunning={phase === "running"} | 2280 | isRunning={false} |
| 2401 | showPlanFollowUpPrompt={ | 2281 | showPlanFollowUpPrompt={false} |
| 2402 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt | 2282 | promptHasText={false} |
| 2403 | } | | |
| 2404 | promptHasText={prompt.trim().length > 0} | | |
| 2405 | isSendBusy={isSendBusy} | 2283 | isSendBusy={isSendBusy} |
| 2406 | isConnecting={isConnecting} | 2284 | isConnecting={isConnecting} |
| 2407 | isEnvironmentUnavailable={environmentUnavailable !== null} | 2285 | isEnvironmentUnavailable={environmentUnavailable !== null} |
| 2408 | isPreparingWorktree={isPreparingWorktree} | 2286 | isPreparingWorktree={false} |
| 2409 | hasSendableContent={composerSendState.hasSendableContent} | 2287 | hasSendableContent={false} |
| 2410 | preserveComposerFocusOnPointerDown={isMobileViewport} | 2288 | preserveComposerFocusOnPointerDown |
| 2411 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | 2289 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| 2412 | onInterrupt={handleInterruptPrimaryAction} | 2290 | onInterrupt={handleInterruptPrimaryAction} |
| 2413 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} | 2291 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
| 2414 | /> | 2292 | /> |
| 2415 | </div> | 2293 | </div> |
| 2416 | </div> | 2294 | ) : null} |
| 2417 | )} | 2295 | </div> |
| 2418 | </div> | 2296 | </div> |
| | 2297 | |
| | 2298 | {/* Bottom toolbar */} |
| | 2299 | {isComposerCollapsedMobile ? null : activePendingApproval ? ( |
| | 2300 | <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3"> |
| | 2301 | <ComposerPendingApprovalActions |
| | 2302 | requestId={activePendingApproval.requestId} |
| | 2303 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| | 2304 | onRespondToApproval={onRespondToApproval} |
| | 2305 | /> |
| | 2306 | </div> |
| | 2307 | ) : ( |
| | 2308 | <div |
| | 2309 | data-chat-composer-footer="true" |
| | 2310 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} |
| | 2311 | className={cn( |
| | 2312 | "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3", |
| | 2313 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", |
| | 2314 | showMobilePendingAnswerActions && "hidden sm:flex", |
| | 2315 | )} |
| | 2316 | > |
| | 2317 | <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> |
| | 2318 | <ProviderModelPicker |
| | 2319 | compact={isComposerFooterCompact} |
| | 2320 | activeInstanceId={selectedInstanceId} |
| | 2321 | model={selectedModelForPickerWithCustomFallback} |
| | 2322 | lockedProvider={lockedProvider} |
| | 2323 | lockedContinuationGroupKey={lockedContinuationGroupKey} |
| | 2324 | instanceEntries={providerInstanceEntries} |
| | 2325 | keybindings={keybindings} |
| | 2326 | modelOptionsByInstance={modelOptionsByInstance} |
| | 2327 | terminalOpen={terminalOpen} |
| | 2328 | open={isComposerModelPickerOpen} |
| | 2329 | {...(composerProviderState.modelPickerIconClassName |
| | 2330 | ? { |
| | 2331 | activeProviderIconClassName: composerProviderState.modelPickerIconClassName, |
| | 2332 | } |
| | 2333 | : {})} |
| | 2334 | onOpenChange={(open) => { |
| | 2335 | setIsComposerModelPickerOpen(open); |
| | 2336 | }} |
| | 2337 | onInstanceModelChange={onProviderModelSelect} |
| | 2338 | /> |
| | 2339 | |
| | 2340 | {isComposerFooterCompact ? ( |
| | 2341 | <CompactComposerControlsMenu |
| | 2342 | activePlan={showPlanSidebarToggle} |
| | 2343 | interactionMode={interactionMode} |
| | 2344 | planSidebarLabel={planSidebarLabel} |
| | 2345 | planSidebarOpen={planSidebarOpen} |
| | 2346 | runtimeMode={runtimeMode} |
| | 2347 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} |
| | 2348 | traitsMenuContent={providerTraitsMenuContent} |
| | 2349 | onToggleInteractionMode={toggleInteractionMode} |
| | 2350 | onTogglePlanSidebar={togglePlanSidebar} |
| | 2351 | onRuntimeModeChange={handleRuntimeModeChange} |
| | 2352 | /> |
| | 2353 | ) : ( |
| | 2354 | <> |
| | 2355 | {providerTraitsPicker ? ( |
| | 2356 | <> |
| | 2357 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| | 2358 | {providerTraitsPicker} |
| | 2359 | </> |
| | 2360 | ) : null} |
| | 2361 | <ComposerFooterModeControls |
| | 2362 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} |
| | 2363 | interactionMode={interactionMode} |
| | 2364 | runtimeMode={runtimeMode} |
| | 2365 | showPlanToggle={showPlanSidebarToggle} |
| | 2366 | planSidebarLabel={planSidebarLabel} |
| | 2367 | planSidebarOpen={planSidebarOpen} |
| | 2368 | onToggleInteractionMode={toggleInteractionMode} |
| | 2369 | onRuntimeModeChange={handleRuntimeModeChange} |
| | 2370 | onTogglePlanSidebar={togglePlanSidebar} |
| | 2371 | /> |
| | 2372 | </> |
| | 2373 | )} |
| | 2374 | </div> |
| | 2375 | |
| | 2376 | {/* Right side: send / stop button */} |
| | 2377 | <div |
| | 2378 | data-chat-composer-actions="right" |
| | 2379 | data-chat-composer-primary-actions-compact={ |
| | 2380 | isComposerPrimaryActionsCompact ? "true" : "false" |
| | 2381 | } |
| | 2382 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" |
| | 2383 | > |
| | 2384 | <ComposerFooterPrimaryActions |
| | 2385 | compact={isComposerPrimaryActionsCompact} |
| | 2386 | activeContextWindow={activeContextWindow} |
| | 2387 | pendingAction={pendingPrimaryAction} |
| | 2388 | isRunning={phase === "running"} |
| | 2389 | showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} |
| | 2390 | promptHasText={prompt.trim().length > 0} |
| | 2391 | isSendBusy={isSendBusy} |
| | 2392 | isConnecting={isConnecting} |
| | 2393 | isEnvironmentUnavailable={environmentUnavailable !== null} |
| | 2394 | isPreparingWorktree={isPreparingWorktree} |
| | 2395 | hasSendableContent={composerSendState.hasSendableContent} |
| | 2396 | preserveComposerFocusOnPointerDown={isMobileViewport} |
| | 2397 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| | 2398 | onInterrupt={handleInterruptPrimaryAction} |
| | 2399 | onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} |
| | 2400 | /> |
| | 2401 | </div> |
| | 2402 | </div> |
| | 2403 | )} |
| 2419 | </div> | 2404 | </div> |
| 2420 | </form> | 2405 | </div> |
| 2421 | ); | 2406 | </form> |
| 2422 | }), | 2407 | ); |
| 2423 | ); | 2408 | }); |