import { memo, useEffect, useState, type MouseEventHandler, type PointerEventHandler, type ReactNode, } from "react"; import { CheckIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon, CornerUpRightIcon, ListPlusIcon, } from "lucide-react"; 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 { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { alternateComposerDispatchAction, resolveComposerDispatchMode, } from "@t3tools/client-runtime/state/composer-dispatch"; interface PendingActionState { questionIndex: number; isLastQuestion: boolean; canAdvance: boolean; isResponding: boolean; isComplete: boolean; } interface ComposerPrimaryActionsProps { /** Footer controls (dictation) that always sit right before the primary button. */ children?: ReactNode; compact: boolean; canOperateThread: boolean; pendingAction: PendingActionState | null; isRunning: boolean; canInterrupt: boolean; runId?: string | null; followUpBehavior?: "queue" | "steer"; canSteer?: boolean; alternateShortcutLabel?: string | null; /** 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; compactBeforeSendTokens?: number | null; hideCompactToggle?: boolean; 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 STOP_PENDING_MAX_MS = 10_000; 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(); }; type StopPhase = "pending" | "timedOut" | "landed" | null; function useStopPhase(input: { runId: string | null; canInterrupt: boolean; canOperateThread: boolean; }) { const { runId, canInterrupt, canOperateThread } = input; const [stopPhase, setStopPhase] = useState(null); const [stopRunId, setStopRunId] = useState(runId); if (runId !== null && stopRunId !== runId) { setStopRunId(runId); setStopPhase(null); } else if (canOperateThread) { // Losing permission also clears canInterrupt; it does not acknowledge the stop. if ((stopPhase === "pending" || stopPhase === "timedOut") && !canInterrupt) setStopPhase("landed"); else if (stopPhase === "landed" && canInterrupt) setStopPhase(null); } // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer that re-enables Stop if the interrupt never settles useEffect(() => { if (stopPhase !== "pending") return; const timeout = setTimeout(() => setStopPhase("timedOut"), STOP_PENDING_MAX_MS); return () => clearTimeout(timeout); }, [stopPhase]); return [stopPhase, setStopPhase] as const; } function StopGenerationButton({ stopPhase, canOperateThread, insidePendingAction, preserveComposerFocusOnPointerDown, onStopRequested, onInterrupt, }: { stopPhase: StopPhase; canOperateThread: boolean; insidePendingAction: boolean; preserveComposerFocusOnPointerDown: boolean; onStopRequested: () => void; onInterrupt: () => void; }) { const isStopDisabled = stopPhase === "pending" || stopPhase === "landed"; return ( { if (!canOperateThread || isStopDisabled) return; onStopRequested(); onInterrupt(); }} aria-label={ stopPhase === "landed" ? "Stopped" : stopPhase === "pending" ? "Stopping" : "Stop generation" } /> } > {stopPhase === "pending" ? "Stopping…" : stopPhase === "landed" ? "Stopped" : "Interrupt"} ); } /** The collapsed mobile composer's Stop, shown in place of `children` while a turn can be stopped. */ export function CollapsedComposerStopAction({ children, canOperateThread, canInterrupt, runId, hasSendableContent, isEditingQueuedMessage, onInterrupt, }: { children: ReactNode; canOperateThread: boolean; canInterrupt: boolean; runId: string | null; hasSendableContent: boolean; isEditingQueuedMessage: boolean; onInterrupt: () => void; }) { const [stopPhase, setStopPhase] = useStopPhase({ runId, canInterrupt, canOperateThread }); if ( !(canInterrupt || stopPhase === "pending" || stopPhase === "timedOut") || hasSendableContent || isEditingQueuedMessage ) { return children; } return ( setStopPhase("pending")} onInterrupt={onInterrupt} /> ); } export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ children, compact, canOperateThread, pendingAction, isRunning, canInterrupt, runId = null, followUpBehavior = "steer", canSteer = true, alternateShortcutLabel = null, voiceInputActive = false, isSendBusy, sendDisabledReason, isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, canResume = false, preserveComposerFocusOnPointerDown = false, isEditingQueuedMessage = false, onSubmitMessage, onResume, onPreviousPendingQuestion, onInterrupt, compactBeforeSendTokens = null, hideCompactToggle = false, keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { const [stopPhase, setStopPhase] = useStopPhase({ runId, canInterrupt, canOperateThread }); const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; const environmentIdentificationMode = useEnvironmentIdentificationMode(); const shortcutModifiers = useShortcutModifierState(); const isQueuing = !isEditingQueuedMessage && 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) => ( setStopPhase("pending")} onInterrupt={onInterrupt} /> ); // Keep the clicked target at the right edge even if typing changes the primary action. const withStopButton = (action: ReactNode) => ( <> {action} {stopPhase === null ? null : renderStopGenerationButton(false)} ); if (pendingAction) { return (
{pendingAction.questionIndex > 0 ? ( compact ? ( ) : ( ) ) : null} {children} {canInterrupt || stopPhase !== null ? renderStopGenerationButton(true) : null}
); } if ( (canInterrupt || stopPhase === "pending" || stopPhase === "timedOut") && !hasSendableContent && !isEditingQueuedMessage ) { return ( <> {children} {renderStopGenerationButton(false)} ); } const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage; const compactTokens = compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage ? formatContextWindowTokens(compactBeforeSendTokens) : null; const compactsBeforeSend = compactTokens !== null && !keepFullHistory; const submitLabel = showResume ? "Resume thread" : isEditingQueuedMessage ? "Update queued message" : 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 ?? (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 || hideCompactToggle) { return withStopButton( <> {children} {submit} , ); } return withStopButton(
} >
{keepFullHistory ? `Next send keeps all ${compactTokens} tokens.` : `Next send compacts ${compactTokens} tokens first.`}
{keepFullHistory ? "Click to compact first" : "Click to keep full history"}
{children} {submit}
, ); });