import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react"; import { CornerUpRight, ListPlus } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { formatContextWindowTokens } from "~/lib/contextWindow"; import { cn, isMacPlatform } from "~/lib/utils"; import { useShortcutModifierState } from "../../shortcutModifierState"; import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; import { alternateComposerDispatchAction, type ComposerLocalCommand, resolveComposerDispatchMode, } from "@t3tools/client-runtime/state/composer-dispatch"; interface PendingActionState { questionIndex: number; isLastQuestion: boolean; canAdvance: boolean; isResponding: boolean; isComplete: boolean; } interface ComposerPrimaryActionsProps { compact: boolean; canOperateThread: boolean; pendingAction: PendingActionState | null; isRunning: boolean; canInterrupt: boolean; followUpBehavior?: "queue" | "steer"; canSteer?: boolean; /** A command the composer runs itself, so the running turn neither queues nor steers it. */ localCommand?: ComposerLocalCommand | null; alternateShortcutLabel?: string | null; showPlanFollowUpPrompt: boolean; promptHasText: boolean; /** Dictation is running; Send finishes it and submits once the transcript lands. */ voiceInputActive?: boolean; isSendBusy: boolean; sendDisabledReason: string | null; isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; canResume?: boolean; preserveComposerFocusOnPointerDown?: boolean; isEditingQueuedMessage?: boolean; onSubmitMessage?: MouseEventHandler; onResume?: () => void; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; compactBeforeSendTokens?: number | null; keepFullHistory?: boolean; onToggleKeepFullHistory?: () => void; } const formatPendingPrimaryActionLabel = (input: { compact: boolean; isLastQuestion: boolean; isResponding: boolean; questionIndex: number; }) => { if (input.isResponding) { return "Submitting..."; } if (input.compact) { return input.isLastQuestion ? "Submit" : "Next"; } if (!input.isLastQuestion) { return "Next question"; } return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; const messageActionPillClassName = "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm"; const preventPointerFocus: PointerEventHandler = (event) => { event.preventDefault(); }; const LOCAL_COMMAND_LABELS: Record = { "side-chat": "Start side chat", "side-chat-unavailable": "Side chat unavailable", "side-chat-text-only": "Side chat requires text only", clear: "Start fresh session", plan: "Switch to Plan", build: "Switch to Build", feedback: "Submit feedback", "usage-limits": "Show usage limits", }; export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, canOperateThread, pendingAction, isRunning, canInterrupt, followUpBehavior = "steer", canSteer = true, localCommand = null, alternateShortcutLabel = null, showPlanFollowUpPrompt, promptHasText, voiceInputActive = false, isSendBusy, sendDisabledReason, isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, canResume = false, preserveComposerFocusOnPointerDown = false, isEditingQueuedMessage = false, onSubmitMessage, onResume, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, compactBeforeSendTokens = null, keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; const environmentIdentificationMode = useEnvironmentIdentificationMode(); const shortcutModifiers = useShortcutModifierState(); const isQueuing = !isEditingQueuedMessage && localCommand === null && resolveComposerDispatchMode({ running: isRunning, activeTurnDefault: followUpBehavior, canSteer, alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey, }) === "queue"; const alternateAction = alternateComposerDispatchAction(followUpBehavior); const isSendDisabled = !canOperateThread || sendDisabledReason !== null; const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); const renderStopGenerationButton = (insidePendingAction: boolean) => ( { if (canOperateThread) onInterrupt(); }} aria-label="Stop generation" /> } > Interrupt ); if (pendingAction) { return (
{canInterrupt ? renderStopGenerationButton(true) : null} {pendingAction.questionIndex > 0 ? ( compact ? ( ) : ( ) ) : null}
); } if (showPlanFollowUpPrompt && (promptHasText || !canResume)) { if (promptHasText) { return ( ); } return (
event.preventDefault() : undefined } disabled={isSendBusy || isSendDisabled || isEnvironmentUnavailable} /> } > { if (canOperateThread) onImplementPlanInNewThread(); }} > Implement in a new thread
); } if (canInterrupt && !hasSendableContent && !isEditingQueuedMessage) { return renderStopGenerationButton(false); } const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage; const compactTokens = compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage ? formatContextWindowTokens(compactBeforeSendTokens) : null; const compactsBeforeSend = compactTokens !== null && !keepFullHistory; const localCommandLabel = localCommand === null ? null : LOCAL_COMMAND_LABELS[localCommand]; const localCommandTooltip = localCommand === "side-chat" ? isRunning ? "Start a side chat without interrupting the running turn" : "Start a side chat with this question" : localCommandLabel; const submitLabel = showResume ? "Resume thread" : isEditingQueuedMessage ? "Update queued message" : (localCommandLabel ?? (compactsBeforeSend ? "Compact and send" : isQueuing ? "Queue message" : isRunning ? "Steer message" : "Submit message")); const submitStatus = isEnvironmentUnavailable ? "Environment disconnected" : (sendDisabledReason ?? (isPreparingWorktree ? "Preparing worktree" : isSendBusy ? isEditingQueuedMessage ? "Updating queued message" : "Submitting message" : null)); const submitTooltip = submitStatus ?? (isEditingQueuedMessage ? null : localCommandTooltip) ?? (compactsBeforeSend ? `Summarize ${compactTokens} tokens of history, then send` : isRunning && !isEditingQueuedMessage && canSteer ? `Click to ${followUpBehavior}, ${isMacPlatform(navigator.platform) ? "⌘" : "Ctrl"}-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` : submitLabel); const sendButton = ( ); const submit = ( }>{sendButton} {submitTooltip} ); if (compactTokens === null) return submit; return (
} >
{keepFullHistory ? `Next send keeps all ${compactTokens} tokens.` : `Next send compacts ${compactTokens} tokens first.`}
{keepFullHistory ? "Click to compact first" : "Click to keep full history"}
{submit}
); });